Skip to content

refactor pool #192

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 38 commits into from
May 18, 2020
Merged

refactor pool #192

merged 38 commits into from
May 18, 2020

Conversation

artemredkin
Copy link
Collaborator

@artemredkin artemredkin commented Apr 3, 2020

Ideas for connection pool refactoring. Main idea is that we need to simplify connection lifecycle. Right now its spread over many callbacks, that return some kind of potential action. I think this part can be simpler. There are basically two operations, polling a connection and releasing the connection. All magic should as much as possible be contained in those two methods.
One thing I'm still figuring out is how to simplify idle connection handling.
Closes #175 and #176

There are two things missing at the moment:

  1. removing connections from pool and removing providers when they are idle (this is caught by the failing test)
  2. there are some verifications needed for event loop preference (and possibly tests?)

Other things to check:

  1. what about inflight connection requests that are not finished yet (+test?)
    we assert on openedConnectionCount on deinit, so we good there, only question is should we cancel those requests on shutdown or just let them fail when we stop eventLoop
  2. Code handling idle pool connections is tough to follow, but I haven't found a way to simplify it yet
  3. Same for code handling when channel is inactive
  4. we probably need to move IdlePoolConnectionHandler stuff to connection pool file, otherwise we add idle handler in one place and remove in another

@artemredkin artemredkin added the kind/enhancement Improvements to existing feature. label Apr 3, 2020
@artemredkin artemredkin added this to the 1.2.0 milestone Apr 3, 2020
@artemredkin artemredkin requested review from weissi and Lukasa April 3, 2020 16:45
@artemredkin
Copy link
Collaborator Author

cc @adtrevor

Copy link
Contributor

@weissi weissi left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! I left a few comments but I'm not sure if removing the struct State is the right call here. It makes it quite possible that we're calling out to the user whilst we hold a lock which we must prevent from happening. In the new code, it's unclear to me what bits call out and what bits don't so I think struct State was actually a good idea.

assert(self.state.activity == .closing)
return self.state.availableConnections
self.lock.withLock {
self.availableConnections.forEach { $0.close() }
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

$0.close() might also be calling out

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same as above

@PopFlamingo
Copy link
Contributor

PopFlamingo commented Apr 4, 2020

@artemredkin Thank you very much for this!

  1. what about inflight connection requests that are not finished yet (+test?)
    we assert on openedConnectionCount on deinit, so we good there, only question is should we cancel those requests on shutdown or just let them fail when we stop eventLoop

I think currently we can't rely on the event loop correctly notifying remaining futures that it is shutdown, IIRC this was the related issue: apple/swift-nio#1309, the best option would be to fail the requests on shutdown IMO, it also gives a more consistent behaviour between client owned EL and user provided EL I guess?
I think there is already a test for checking they are indeed canceled on client shutdown

  1. Code handling idle pool connections is tough to follow, but I haven't found a way to simplify it yet
  2. Same for code handling when channel is inactive

Tell me if something doesn't make sense, I'll hopefully still remember the reasoning behind them 🙂

  1. we probably need to move IdlePoolConnectionHandler stuff to connection pool file, otherwise we add idle handler in one place and remove in another

Agreed, that's not clean enough

@PopFlamingo
Copy link
Contributor

PopFlamingo commented Apr 4, 2020

EDIT: It's actually an assertion in HTTPBin, so not too much problematic for now (and I think it existed prior to this)

I hit this assertion in testRaceNewRequestsVsShutdown:

Test Suite 'Selected tests' started at 2020-04-04 05:25:47.352
Test Suite 'AsyncHTTPClientTests.xctest' started at 2020-04-04 05:25:47.354
Test Suite 'HTTPClientTests' started at 2020-04-04 05:25:47.354
Test Case '-[AsyncHTTPClientTests.HTTPClientTests testRaceNewRequestsVsShutdown]' started.
Fatal error: ioOnClosedChannel: file /Users/adtrevor/Development/AsyncHTTPClient/Tests/AsyncHTTPClientTests/HTTPClientTestUtils.swift, line 525
2020-04-04 05:25:48.502101+0200 xctest[1407:29744] Fatal error: ioOnClosedChannel: file /Users/adtrevor/Development/AsyncHTTPClient/Tests/AsyncHTTPClientTests/HTTPClientTestUtils.swift, line 525

@artemredkin artemredkin mentioned this pull request Apr 4, 2020
@artemredkin artemredkin marked this pull request as ready for review April 16, 2020 13:05
@artemredkin artemredkin requested a review from weissi April 18, 2020 18:22
@artemredkin
Copy link
Collaborator Author

@swift-server-bot test this please

@artemredkin
Copy link
Collaborator Author

@swift-server-bot test this please

@artemredkin
Copy link
Collaborator Author

This also closes #188

Copy link
Contributor

@weissi weissi left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@artemredkin awesome, that looks like a good improvement! Left some more comments.

indirect case parkAnd(Connection, Action)
}

struct ConnectionsState {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we maybe move the state machine to a separate file?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good idea, done, thanks!

}

var connectionProviderCount: Int {
return self.connectionProvidersLock.withLock {
self.connectionProviders.count
return self.lock.withLock {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we need this connectionProviderCount property? Seems racy so I'd just remove it.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we use it one place - client deinit to assert that all providers are deleted (so no decisions based on it are made)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@artemredkin in deinit we don't need the lock. So we can just do

assert(self.providers.count == 0)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

var state = HTTP1ConnectionProvider.ConnectionsState(eventLoop: eventLoop)

var snapshot = state.testsOnly_getInternalState()
XCTAssertEqual(0, snapshot.availableConnections.count)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

they look all great! Quick question: Why are we asserting on the (private) state and not the Actions that the state machine returns? Examples here: https://github.com/apple/swift-nio-examples/blob/master/backpressure-file-io-channel/Tests/BackpressureChannelToFileIOTests/StateMachineTest.swift#L267-L309

For the example above, I extended the Action to have lots of functions (one for each of the actions) like

    @discardableResult
    func assertOpenFile(_ check: (String) throws -> Void,
                        file: StaticString = #file,
                        line: UInt = #line) -> Self {
        if case .openFile(let path) = self.main {
            XCTAssertNoThrow(try check(path))
        } else {
            XCTFail("action \(self) not \(#function)", file: file, line: line)
        }
        return self
    }

And then you can just write

self.stateMachine.someInput()
    .assertActionIsX()
    .assertWhateverElse()

and we wouldn't need to expose the state. But again, I think that's something we can do in the future.

The benefit of asserting on the Action is that we can change the state machine representation to say an enum without rewriting all the tests.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we do assert on actions as well (not in this test specifically, but in other tests), I want to test not only action but the way state is changing. I test before the action to "show" what state I was expecting in this test and after to see if we arrived at it, also serves as a documentation of sorts for the test case

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, but the tests shouldn't care how the state machine achieves its goal, assuming it does, right? When we refactor, we'll lose all the 'private state tests'.
The state machine is fully deterministic, so there isn't anything you can't test with just inputs & actions. The state is fully determined by the inputs that have been fed.

As long as most of the tests do only action assertions, I think there's no harm caused also asserting on private state but I think it's a bit of an anti-pattern.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree, but it can be a bit easier to test that when we aqcuire a connection that it is inside the waiters for example, than to get a series of transformations to aquire and then release some other connection and then test that our promise was succeeded

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Github’s broken, i was meaning to thumbs UP only but fat fingered a DOWN too which I can’t remove now 🤪

Copy link
Contributor

@weissi weissi left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you! I think this is a good improvement and the remaining bits can be done iteratively from here on. Let’s get this in soon but maybe @Lukasa could give it a sanity check?

Copy link
Collaborator

@Lukasa Lukasa left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some minor notes here.

}
return http1Provider
let provider = HTTP1ConnectionProvider(key: key, eventLoop: eventLoop, configuration: self.configuration, pool: self)
self.providers[key] = provider
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are we not missing a call to enqueue here?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, after reading through the entire PR I finally found out why we aren't. Can we add a comment here that states that providers begin at +1?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done!

self.state = State(eventLoop: eventLoop, parentPool: parentPool, key: key)
/// Sets idle timeout handler and channel inactivity listener.
func setIdleTimeout(timeout: TimeAmount?) {
_ = self.channel.pipeline.addHandler(IdleStateHandler(writeTimeout: timeout)).flatMap { _ in
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Idle handlers should usually go first in the pipeline.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Even if the timeout handling handler is added immediately after it? I don't think there are any handlers that can (or should) handle timeouts in the pipeline at this point...

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reason they should go first in the pipeline is so that they observe the TCP writes/reads, rather than something more structured that may be delayed. Otherwise slow connections risk being killed for being idle, when in practice there's data flow occurring and just not being seen due to intermediate buffering.

This function is potentially callable at any time, so we'll risk fewer bugs by explicitly calling out that the idle state handler should go first. I'm fine for the actual timeout handler to go wherever.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah! That makes sense, done

var description: String {
return "HTTP1ConnectionProvider { key: \(self.key), state: \(self.state) }"
}
extension Connection: Hashable {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor nit, but I'd rather not conform something to Hashable/Equatable if its definition of hash value and equality is identity.

Copy link
Collaborator Author

@artemredkin artemredkin May 18, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why? And what would you recommend instead?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Users explicitly testing for identity and explicitly using ObjectIdentifier as a hash key.

The main reason I object is that Swift pretty pervasively separates the idea of equality and identity. Indeed, the Equatable docs call this out directly. In particular, being equatable implies that two objects are substitutable. If two objects are only equal if they are literally the same object, we have a way to express that already and do not need to bring equatability into the mix.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think there is a good way to use ObjectIdentifier is a Set, while I can add and test for contains and remove, since ObjectIdentifier is not generic, I cannot say leased.map { $0.close() }

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you need a Set? Why not just Array?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Lukasa and 4) (which I think may be best):

fileprivate struct LeasedConnectionKey: Hashable {
    private let connection: Connection

   func ==(...) {}
   func hash...
}

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, works for me.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do add and remove connections constantly, its added on every new http call and removed when call is finished. This is a result from a simple benchmark (adds, removes, searches) on a small (8 element) array/set:

name   time       std         iterations  
----------------------------------------
array  3208.0 ns  ±  29.53 %  425528      
set    1677.0 ns  ±  52.07 %  827306 

In this case set is almost twice as fast...

I think I'll go with 4

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, the problem with Array will be the removals of elements that are not the last...

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

case .failure(let error):
self.connectFailed(error: error, waiter: waiter)
}
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This block is repeated a few times, it seems like we could factor it out a bit.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea, done, thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
kind/enhancement Improvements to existing feature.
Projects
None yet
4 participants