Skip to content
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

Add serverset #6921

Merged
merged 9 commits into from
Dec 17, 2018
Merged

Conversation

illicitonion
Copy link
Contributor

This round-robins between a number of backends, skipping any which are
observed to be bad, with exponential backoff and ease-in.

This will be used in remote process execution for CAS operations.

Copy link
Member

@stuhood stuhood left a comment

Choose a reason for hiding this comment

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

This is large: will review before end of day. One quick comment.

}

#[derive(Clone, Copy, Debug)]
pub struct BackoffConfig {
Copy link
Member

Choose a reason for hiding this comment

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

Copy link
Contributor Author

Choose a reason for hiding this comment

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

There's a comment about avoiding futures here because fork safety; when we're out of our fork world, I will gladly rewrite this to use tokio_retry :)

Copy link
Contributor

@blorente blorente left a comment

Choose a reason for hiding this comment

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

This looks good to me, I won't approve because I don't have much experience with multithreading and thus I can't be confident that this is correct. But it looks nice!

if let Some(ref mut unhealthy_info) = *unhealthy_info {
unhealthy_info.unhealthy_since = Instant::now();
// failure_backoff_ratio's numer and denom both fit in u8s, so hopefully this won't
// overflow of lose too much precision...
Copy link
Contributor

Choose a reason for hiding this comment

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

s/of/or/

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done

// overflow of lose too much precision...
unhealthy_info.next_attempt_after *= *self.inner.failure_backoff_ratio.numer();
unhealthy_info.next_attempt_after /= *self.inner.failure_backoff_ratio.denom();
unhealthy_info.next_attempt_after = std::cmp::min(
Copy link
Contributor

Choose a reason for hiding this comment

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

This series of assignments seems odd to me, why not calculate the correct value and then assign it once? As far as I can tell we won't have any write race conditions because we lock unhealty_info, we won't have read RCs because self.inner is atomic, and we won't have read-after-write hazards on self.inner.failure_backoff_ratio because it doesn't change except for initialization. And even if it did change, the correct behavior would presumably to make a thread-local copy first to read from.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done

// failure_backoff_ratio's numer and denom both fit in u8s, so hopefully this won't
// overflow of lose too much precision...
unhealthy_info.next_attempt_after *= *self.inner.failure_backoff_ratio.denom();
unhealthy_info.next_attempt_after /= *self.inner.failure_backoff_ratio.numer();
Copy link
Contributor

Choose a reason for hiding this comment

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

Same as above

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done

break (i, server);
};

let serverset: Serverset<T> = (*self).clone();
Copy link
Contributor

Choose a reason for hiding this comment

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

How expensive is this clone? It looks like not very much, because it's essentially a copy of an Arc, but just checking.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah, just an Arc, super cheap :)

assert_eq!(expect, saw);
}

fn mark(s: &Serverset<&'static str>, health: Health) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Stringly typed servers, I like it! (I really do, it's a cute way to test)

/// last known status.
///
/// If all resources are unhealthy, this function will block the calling thread until the backoff
/// period has completed. We'd probably prefer to use some Future-based scheduling, but that
Copy link
Contributor

Choose a reason for hiding this comment

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

Maybe add a TODO for the Future? (a little, unfunny pun intended)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done

#[derive(Debug)]
struct Backend<T> {
server: T,
unhealthy_info: Arc<Mutex<Option<UnhealthyInfo>>>,
Copy link
Contributor

Choose a reason for hiding this comment

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

Would the interface improve if we made this an Option<Mutex<Arc<>>>? Here is a solution for the Scoping problem.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The Option is specifically the thing we may set/unset under the lock, so it needs to be this way around :)

pub fn next(&self) -> (T, Box<Fn(Health) + Send + Sync>) {
let (i, server) = loop {
let i = self.inner.next.fetch_add(1, Ordering::Relaxed) % self.inner.servers.len();
let server = &self.inner.servers[i];
Copy link
Contributor

Choose a reason for hiding this comment

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

I assume that inner.servers only gets mutated at creation and the privateness of that field guarantees that it doesn't get mutated elsewhere. If that is the case, this is okay. Otherwise, there could be a disconnect between i and the actual vector.

Copy link
Contributor

Choose a reason for hiding this comment

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

Still maybe worth throwing an RWLock, if reading has little overhead.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

In fact, the fact that we Arc up the Inner means that the Vec cannot ever be mutated, because there's no way for anyone to get a mutable reference to it (unless they are the only holder of the Arc, in which case they would need to unwrap it, which proves that no one else could have any dangling callbacks) :) Yay rust!

Copy link
Member

@stuhood stuhood left a comment

Choose a reason for hiding this comment

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

See the note about busywaiting. But I like that this is separated out as an independent crate!

max_lame,
} = backoff_config;

if backoff_ratio < 1.0 {
Copy link
Member

@stuhood stuhood Dec 13, 2018

Choose a reason for hiding this comment

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

Is it worth giving BackoffConfig its own fn new to perform these calculations/validation, and then having this method be infallible? The advantage would be that it would be useful to give Inner an instance of BackoffConfig rather than namespacing the fields with failure_.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done

/// unhealthy.
///
/// The inverse is used when easing back in after health recovery.
pub backoff_ratio: f64,
Copy link
Member

Choose a reason for hiding this comment

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

Given that we think we'll be switching to a library for the backoff implementation, using ratios here seems like it might be overkill. Would assuming a 2x ratio (or having a configurable integer ratio) be reasonable until then?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

After futzing with Futures, I'm pretty sure we don't want to switch to a library for the backoff implementation. None of the existing ones I could find support ease in as well as backoff, and the code is now pretty well encapsulated. I'd be happy to split off a separate backoff crate, if you'd like.

/// would require this type to be Resettable because of our fork model, which would be very
/// complex.
///
/// TODO: Switch to use tokio_retry when we don't need to worry about forking without execing.
Copy link
Member

Choose a reason for hiding this comment

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

On this topic: we already guarantee that all threads/pools/connections/etc are shut down or dropped before forking... I believe that because tokio_retry does not create threads (instead, it uses the Timer facility provided per-thread by tokio_runtime), it should be safe to use tokio_retry. One thing we'd need to ensure though is that we're interacting with the Timer from within the runtime's threads (ie, not on the io-pool... we should probably kill the io-pool).

If you think otherwise though, #6818 will need an update.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Updated to use futures :)


let serverset: Serverset<T> = (*self).clone();

let callback = Box::new(move |health: Health| serverset.callback(i, health));
Copy link
Member

Choose a reason for hiding this comment

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

Given that we have to trust the caller to call the callback anyway, maybe giving them an opaque token to explicitly call serverset.callback with would be a bit more efficient (would avoid the allocation of the callback function).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done

}

fn multiply(duration: Duration, fraction: num_rational::Ratio<u32>) -> Duration {
(duration * *fraction.numer()) / *fraction.denom()
Copy link
Member

Choose a reason for hiding this comment

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

Dividing first might be a better defense against overflow? Worse for precision though.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Switched to just use floats everywhere. Which makes me pretty sad, but added a link to rust-lang/rust#54361

Copy link
Member

Choose a reason for hiding this comment

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

I assumed that the reason for the ratio was to allow for test determinism? Or are things "close enough" with floats for that not to matter?

let i = self.inner.next.fetch_add(1, Ordering::Relaxed) % self.inner.servers.len();
let server = &self.inner.servers[i];
let unhealthy_info = server.unhealthy_info.lock();
if let Some(ref unhealthy_info) = *unhealthy_info {
Copy link
Member

@stuhood stuhood Dec 14, 2018

Choose a reason for hiding this comment

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

If all hosts are unhealthy, I think that this is going to busywait. In order to not busywait, you'd need to make the result of this method optional. And if the result were optional, your caller would still probably want to lean on a Timer.

So... I feel like incorporating timer usage (either via tokio_retry or directly via tokio_timer) is likely more ergonomic. But giving this a Result<_, Duration> to retry at would be an alternative.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Switched to use a timer.

Specifically not using tokio_retry here because the actual retries should be driven by the caller; what we want is to lame underlying servers here (with their own exponential backoff), and have the caller immediately retry without backoff.

Something we could do is have each server return a Future for when it's going to be usable (weighting between multiple healthy servers, and having unhealthy servers give a (jittered) delay) until they're healthy, and have a select around them, which we give to the caller, but that feels like a lot more stuff being scheduled and dropped. Maybe I'm overestimating how expensive kicking off and selecting between futures is, though...

Copy link
Member

@stuhood stuhood left a comment

Choose a reason for hiding this comment

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

Either explicitly passing in a futures_timer::Timer or switching to tokio_timer will likely be necessary: otherwise, looks good.

let (index, instant) = earliest_future.unwrap();
let server = self.inner.servers[index].server.clone();
// Note that Delay::new_at(time in the past) gets immediately scheduled.
Delay::new_at(instant)
Copy link
Member

Choose a reason for hiding this comment

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

So, futures_timer does have a dedicated thread, which means it isn't fork safe out of the box. To use it, you'd need to construct and provide the Timer reference manually (as described there), and ensure that it is shut down before forking.

An advantage to using tokio_timer (I think) is that it will "magically" use thread-local references to find the runtime it is running on, so you wouldn't need to pass in an explicit Runtime or Timer instance. The downside, of course, is that that is magical... you could fall into the trap I mentioned above with io-pool and see a panic due to it not being explicit.

.to_boxed()
}

pub fn callback(&self, callback_token: CallbackToken, health: Health) {
Copy link
Member

Choose a reason for hiding this comment

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

nit: Now that these are actually exposed, giving them purposeful names would be good... report_health and HealthReportToken or something maybe?

Copy link
Member

@stuhood stuhood Dec 14, 2018

Choose a reason for hiding this comment

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

Oh, could also mark the token "#[must_use]".

@illicitonion illicitonion force-pushed the dwagnerhall/serversets/1 branch from fb9e442 to 48905b0 Compare December 17, 2018 10:54
illicitonion and others added 9 commits December 17, 2018 18:42
This round-robins between a number of backends, skipping any which are
observed to be bad, with expontential backoff and ease-in.

This will be used in remote process execution for CAS operations.
@illicitonion illicitonion force-pushed the dwagnerhall/serversets/1 branch from 48905b0 to a60e644 Compare December 17, 2018 19:01
@illicitonion illicitonion merged commit 3350f7e into pantsbuild:master Dec 17, 2018
@illicitonion illicitonion deleted the dwagnerhall/serversets/1 branch December 17, 2018 21:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants