-
Notifications
You must be signed in to change notification settings - Fork 2
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
globalize concurrency management #55
Conversation
/// Enforces a limit on the concurrent number of requests the underlying | ||
/// service can handle. | ||
#[derive(Debug, Clone)] | ||
pub(crate) struct ConcurrencyLimitLayer { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The overall code structure is confusing. We have middleware/limit/
and middleware/limit/concurrency
, but it contains the ConcurrencyLimit service. I am a bit confused about why we don't have something simpler like middleware/concurrency_limit.rs
, middleware/concurrency_limit/*
and get rid of limit?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We could. I sort of just followed tokio's existing module structure (they have both a concurrency limit and a rate limit middleware and thus both are under the limit
module). I don't have a strong opinion on this one.
#[derive(Debug)] | ||
enum State { | ||
/// Permit acquired and ready | ||
Ready(OwnedWorkPermit), | ||
/// Waiting on a permit (or haven't attempted to acquire one) | ||
Pending(Option<AcquirePermitFuture>), | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
#[derive(Debug)] | |
enum State { | |
/// Permit acquired and ready | |
Ready(OwnedWorkPermit), | |
/// Waiting on a permit (or haven't attempted to acquire one) | |
Pending(Option<AcquirePermitFuture>), | |
} | |
#[derive(Debug)] | |
enum State { | |
/// Permit acquired and ready | |
Ready(OwnedWorkPermit), | |
/// Waiting on a permit | |
PendingAcquire(AcquirePermitFuture), | |
/// Haven't attempted to acquire a permit yet | |
PendingStart, | |
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not sure this third state improves the state machine much (to me anyway). When we extract the permit it has to go back to some kind of pending. Setting it to PendingStart
seems odd to me though as we will have acquired/attempted to acquire before. Maybe it's just a naming thing 🤷♂️ .
Curious what others think.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Interesting thought. I suppose thoughPendingStart
is implied by Pending(None)
in the original code, so maybe it's ok to keep what we have?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, it's fine, it just felt odd to me that we are using an Option inside an enum to represent a third state. I think it's more obvious/cleaner to have 3 explicit states instead of 2 states, and one of the states also has two other states.
When we extract the permit it has to go back to some kind of pending. Setting
We can introduce a 4th done state or better naming for PendingStart state. Generally, I prefer an explicit state machine over a concise machine where you have to read the code to understand all the different branches. However, this state machine is small enough that this is probably fine.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
trivial: agree with waqar that 3 explicit states > 2 states (where one states has 2 substates)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Aaron's original way is how it's done in Tokio, so now I'm fine with this now
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
blerg already moved it to 3 states, I'll leave it that way for now but yeah this isn't all that uncommon in Rust since pattern matching allows you to separate them anyway
e.g.
match state {
State::Ready(permit) => ...,
State::Pending(Some(fut)) => ...,
State::Pending(None) => ...,
}
// TODO - add some notion of "work type" and/or "work estimate" to permit acquisition to allow | ||
// for scheduler to make choices on what work gets prioritized |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Without this, we can probably hang forever. If there are 100 parts, and we schedule parts 1-10, and now parts 11-100 are waiting for the semaphore to release. Is it possible to just schedule parts out of order, like 33-43, and then we are stuck waiting for the body to make the space, which will never happen?
Tokio semaphore is fair so it will respect the acquisition order.
aws-s3-transfer-manager/src/lib.rs
Outdated
@@ -21,7 +21,8 @@ | |||
rust_2018_idioms | |||
)] | |||
|
|||
pub(crate) const DEFAULT_CONCURRENCY: usize = 8; | |||
/// Default in-flight concurrency | |||
pub(crate) const DEFAULT_CONCURRENCY: usize = 96; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Assuming 90 MB/request throughput for S3...
Yeah, 90MiB/s seems like a really good assumption for now 👍.
If we assume 90MiB/s:
- For a c5n.18xlarge with 100Gb/s: 132 is theoretically ideal.
- For a c7gn.16xlarge with 200Gb/s: 265 is theoretically ideal.
That lines up with my HTTP benchmarks from a few weeks back, testing on a 200Gb/s machine, performance peaked at concurrency of 275 (I was testing concurrency in increments of 25)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah I said in the PR 128 but didn't update it before posting the PR 🤦♂️ .
We should talk about this one though.
#[derive(Debug)] | ||
enum State { | ||
/// Permit acquired and ready | ||
Ready(OwnedWorkPermit), | ||
/// Waiting on a permit (or haven't attempted to acquire one) | ||
Pending(Option<AcquirePermitFuture>), | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
trivial: agree with waqar that 3 explicit states > 2 states (where one states has 2 substates)
State::Pending(Some(permit_fut)) => { | ||
let permit_fut = pin!(permit_fut); | ||
let permit = | ||
ready!(permit_fut.poll(cx)).expect("permit acquisition never fails"); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
trivial: we expect this to fail in the future, right? maybe add "currently" or "at this time" to this comment? or TODO or something
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't know if we ever expect it to fail, the only reason it would fail for a semaphore is if the semaphore is closed which we never do. That's what expect()
is for though, if you ever hit it that means your assumptions were/are wrong or you broke them somewhere.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Then why not have it return a Future
with OwnedWorkPermit
inside, instead of Result<OwnedWorkPermit>
These are just internal types, right? If we ever need it to fail in the future, we change it back to Result, and the compiler shows us where we need to add error-handling.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We could but we'd just be moving the expect()
since the underlying semaphore we are (currently) using is fallible anyway.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Besides responding to feedback by Waqar and Michael, LGTM from my end.
@@ -111,6 +113,7 @@ fn handle_discovery_chunk( | |||
}) | |||
.map_err(error::discovery_failed); | |||
|
|||
drop(permit); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah, this is subtle. This task releases permit so others can move on, before sending the chunk to the sequencer 👍
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
oh yeah, that is subtle. Add comment explaining this
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we have similar logic for NON-discovery chunks? dropping the semaphore before waiting on the send() to the sequencer?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes but it's implicit / by construction. It's dropped when the response future for the middleware drops the permit (which happens before the response from the service is even returned to the caller).
#[cfg(test)] | ||
mod tests { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice!
#[derive(Debug)] | ||
enum State { | ||
/// Permit acquired and ready | ||
Ready(OwnedWorkPermit), | ||
/// Waiting on a permit (or haven't attempted to acquire one) | ||
Pending(Option<AcquirePermitFuture>), | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Aaron's original way is how it's done in Tokio, so now I'm fine with this now
State::Pending(Some(permit_fut)) => { | ||
let permit_fut = pin!(permit_fut); | ||
let permit = | ||
ready!(permit_fut.poll(cx)).expect("permit acquisition never fails"); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Then why not have it return a Future
with OwnedWorkPermit
inside, instead of Result<OwnedWorkPermit>
These are just internal types, right? If we ever need it to fail in the future, we change it back to Result, and the compiler shows us where we need to add error-handling.
@@ -111,6 +113,7 @@ fn handle_discovery_chunk( | |||
}) | |||
.map_err(error::discovery_failed); | |||
|
|||
drop(permit); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we have similar logic for NON-discovery chunks? dropping the semaphore before waiting on the send() to the sequencer?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks good overall. Feel free to submit once feedback ironed out
28e4392
to
dc56f55
Compare
Issue #, if available:
#31
Description of changes:
"Globalize" the concurrency management of the transfer manager such that all uploads and downloads respect the configured concurrency.
Scheduler
that is part of the client handle that is threaded throughout each operation already.tower::limit::ConcurrencyLimit
middleware with an equivalent based on our new scheduler. The tower middleware was based on a simple semaphore. For now we've just moved the semaphore into the scheduler. In time I expect we'll drop the semaphore for something that allows priority based scheduling, perhaps coupled withacquire(...)
taking a description/metadata of the work to acquire a permit for.I did a quick check on
c5n.18xlarge
download test to verify there are no regressions. If there are it's in the noise.By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.