-
Notifications
You must be signed in to change notification settings - Fork 123
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
Make metrics collection optional/faster #1147
base: main
Are you sure you want to change the base?
Make metrics collection optional/faster #1147
Conversation
See the following report for details: cargo semver-checks output
|
74b7fa3
to
d989a59
Compare
d7b32d0
to
f9a7153
Compare
bff846c
to
88de7ff
Compare
88de7ff
to
185ff57
Compare
Changelog:
|
185ff57
to
7be0e38
Compare
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 still need to review commits that introduce Meter
and connection metrics. Posting this review early, because there are some matters to discuss.
#[derive(Error, Debug, PartialEq)] | ||
pub enum LFError { | ||
#[error("invalid use of histogram")] | ||
HistogramErr(#[from] histogram::Error), | ||
#[error("could not lock the snapshot mutex")] | ||
Mutex, | ||
} |
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.
- Please add a docstring. Even the simplest one - mentioning that it indicates some failure during LockFreeHistogram's operation
- I don't like the name. Since this is a public type, the error name should be more descriptive (current version would be fine for driver's internal error type). I suggest a verbose name such as
LockFreeHistogramError
(WDYT @wprzytula ?) - The additional context is not displayed for the variants. You can display it via
{0}
, such as#[error("Invalid use of histogram: {0}")]
. Also, please begin the error messages with capital letter (this is a convention we currently use in the driver). HistogramErr
->HistogramError
(variant name)HistogramErr
variant currently has a type from a pre-1.0.0 crate. We need to think what to do with this. Corresponding issue: Remove types from pre-1.0 crates from the public API, or hide them behind features #771. I see that later in this PR, you introuce ametrics
feature, so I assume that this error type will be hidden behind it as well. Is it OK if feature name does not directly correspond to the unstable crate's name? In this case, the unstable crate is histogram, and feature name ismetrics
. I'm not sure how this interacts with API stability. cc: @piodul @Lorak-mmk
Mutex, | ||
} | ||
|
||
pub struct LockFreeHistogram { |
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.
This needs a docstring as well. Not only this is a public type (does it need to be public?), but its methods contain some very complex logic, which could be briefly explained here.
Also, it would be nice to mention the motivation behind this struct. I understand the logic (after reviewing the methods), but I still don't quite get WHY we need it. Why can't we just use the AtomicHistogram
from histogram
crate? Are there any races that can occur without this additional layer of synchronization?
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.
Okay, so there are a couple of things to mention here, but I'll start from the beginning.
In the issue description here it is noted that no suitable solution for the problem was found on crates.io (I believe AtomicHistogram
already existed by the time of writing of that issue), so I assumed it must have had a flaw. And, as it turned out, it did.
I highly recommend reading through the issue I opened up on the crate histogram repo, where I explain all of the motivation in detail, but in short: the .load()
method has no synchronisation with increments, which causes a logical race (the state of the loaded histogram is dependent on the speed at which it is loaded).
The idea of some sort of a lock-free algorithm was also proposed in the "Make metrics optional" issue, along with sharding, which I considered potentially harder to implement, thus I went with a lock-free algorithm.
However (!), the LockFreeHistogram
's implementation comes with potential drawbacks in terms of performance (in comparison to AtomicHistogram
, not a global mutex) due to global atomic counters accessed upon each bucket increment.
I haven't managed to run any benchmarks in this regard, nor do I have concrete examples of cases when AtomicHistogram
's implementation yields a very significant error in results (though I did come up with some ideas and calculations; they can be found on my linked issue on crate histogram's repo). Therefore, the decision of which histogram implementation to incorporate into this driver is up to you. I've just provided a safe alternative and done some research.
Also, should you choose to go with AtomicHistogram
, the change will be rather effortless, as I maintained the API schema used in crate histogram for my implementation.
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.
Thank you for the explanation! I think part of this deserves to be put in the docstring.
Also, should you choose to go with
AtomicHistogram
, the change will be rather effortless, as I maintained the API schema used in crate histogram for my implementation.
This is great. Also, please unpub (pub(crate)
) the LockFreeHistogram
and its methods. Only then will we be 100% sure that if we ever decide to drop/modify LockFreeHistogram
, such change will not be API breaking.
Which histogram implementation we want to incorporate to the driver? Well, this needs to be discussed. I'd wait until @Lorak-mmk and @wprzytula review the code.
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 unpubbed LockFreeHistogram
and its methods, but now I can see it might be difficult to make such a change completely non-API-breaking. That's because LockFreeHistogramError
is propagated as MetricsError
's cause and thus needs to be pub. Upon change to AtomicHistogram
this error struct would be removed entirely.
I'm not yet sure how we could go around this issue.
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, good catch. There are some workarounds for this, however. For example, we could always hide underlying LockFreeHistogramError
under Arc<dyn Error>
. I wouldn't worry about this now. Let's wait for others to join the discussion.
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.
For what it's worth, while .load()
is not atomic wrt concurrent increments into the histogram. I'd still consider using the AtomicHistogram
which is used in metrics for both rpc-perf and Pelikan.
As I said in the issue opened in rustcommon, I'm not sure that the potential skew here would be meaningful. But I do welcome some concrete details if such skew does prove to be meaningful.
The TLDR is I'm not sure how much it matters whether it's on one side or the other of loading a histogram. If you envision periodically snapshotting the histogram, it seems you have to accept that the latency is already being recorded at the tail end of the event. Imagine a request that takes a long time, that latency gets incremented after the fact, when the service might be back to responding quickly.
My feeling is that ultimately this is all an approximation and I've found the AtomicHistogram
as in the histogram
crate to be satisfactory for projects I work on.
impl Default for LockFreeHistogram { | ||
fn default() -> Self { | ||
// Config: 64ms error, values in range [0ms, ~262_000ms]. | ||
// Size: (2^13 + 5 * 2^12) * 8B * 2 ~= 450kB. | ||
let grouping_power = 12; | ||
let max_value_power = 18; | ||
LockFreeHistogram::new(grouping_power, max_value_power) | ||
} | ||
} |
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.
Where are these defaults taken from?
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.
Since the histogram crate no longer provides defaults, I had to come up with some choice here. It was my best guess at what might be needed, though that is obviously to be discussed and modified if needed.
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.
You may find our calculator useful while evaluating what parameters are appropriate: https://observablehq.com/@iopsystems/h2-histogram
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.
❓ @NikodemGapski Have you consulted the above calculator about the defaults?
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 mean, I had made my guess according to the mentioned calculator, but I can't know if it meets the needs of the users of this driver (the range and absolute error I noted in the comment for reference). It is your decision whether or not to change it.
7be0e38
to
2f6ae32
Compare
2f6ae32
to
b36ef1f
Compare
0bcde8e
to
cf43ed2
Compare
cf43ed2
to
4392cb8
Compare
scylla/src/observability/metrics.rs
Outdated
/// Snapshot is a structure that contains histogram statistics such as | ||
/// min, max, mean, standard deviation, median, and most common percentiles | ||
/// collected in a certain moment. | ||
#[derive(Debug)] | ||
pub struct Snapshot { | ||
pub min: u64, | ||
pub max: u64, | ||
pub mean: u64, | ||
pub stddev: u64, | ||
pub median: u64, | ||
pub percentile_75: u64, | ||
pub percentile_95: u64, | ||
pub percentile_98: u64, | ||
pub percentile_99: u64, | ||
pub percentile_99_9: u64, | ||
} |
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.
🔧 For future compatibility, let's either:
- make this struct
#[non_exhaustive]
to allow adding more fields in the future without breaking the API; - or make all those fields private and expose a getter for each field.
Which one do you find a more suitable solution? @Lorak-mmk @muzarski
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.
So far I've used the #[non_exhaustive]
macro, but this case is still open for discussion.
4392cb8
to
6dab731
Compare
6dab731
to
32537f9
Compare
Changelog:
|
#[cfg(not(feature = "metrics"))] | ||
fn retry_interval(&self, _: &Context) -> Duration { | ||
warn!("PercentileSpeculativeExecutionPolicy requires the 'metrics' feature to work as intended, defaulting to 100 ms"); | ||
Duration::from_millis(100) | ||
} |
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.
💭 Perhaps we should hide the PercentileSpeculativeExecutionPolicy
behind the metrics
feature, too? @Lorak-mmk @muzarski
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.
Sounds reasonable. Current approach is no different from SimpleSpeculativeExecutionPolicy
with retry_interval
set to 100ms.
bcb3b77
to
f297b75
Compare
Changelog:
|
f297b75
to
b31d836
Compare
b31d836
to
27e4e86
Compare
Changelog:
I think this PR should pass all the CI 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.
💯 Apart from the open discussion over PercentileSpeculativeExecutionPolicy
, LGTM!
🎉 You've done a truly great job!
@Lorak-mmk do we need your review here in order to merge? |
I'm currently reviewing it. |
I'll be reviewing it soon. |
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 noticed that there are some potential logical races in rate metrics logic (yet another time I'm disappointed with cpp-driver...). Apart from that, LGTM. Nice job!
scylla/src/observability/metrics.rs
Outdated
fn tick(&self) { | ||
let count = self.uncounted.swap(0, ORDER_TYPE); | ||
let instant_rate = count as f64 / INTERVAL as f64; | ||
|
||
if self.is_initialized.load(Ordering::Acquire) { | ||
let rate = f64::from_bits(self.rate.load(Ordering::Acquire)); | ||
self.rate.store( | ||
f64::to_bits(rate + self.alpha * (instant_rate - rate)), | ||
Ordering::Release, | ||
); | ||
} else { | ||
self.rate | ||
.store(f64::to_bits(instant_rate), Ordering::Release); | ||
self.is_initialized.store(true, Ordering::Release); | ||
} | ||
} |
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.
AFAIU, there can be a logical race if multiple threads execute this method concurrently, correct? Potential two-threads scenario: both threads enter the method. 1st thread reads the non-zero count, and atomically sets uncounted
to 0. 2nd thread reads 0. Then they both land in the else
branch. 1st thread sets rate
to non-zero value, then the 2nd thread sets rate to zero. If I understand correctly, the acquire-release relationship of ordering does not prevent from such scenario.
OTOH, if this is not possible for two threads to enter this method concurrently (or there are some other safety guarantees), I think it should be documented.
I'm aware that this implementation is based on cpp-driver's implementation, and I wonder if we should ignore these issues. There is always the cpp_rust_unstable
cfg if we do not want to expose this API to the standard users - we would only use it in cpp-rust-driver.
cc: @Lorak-mmk @wprzytula
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.
AFAIU, there can be a logical race if multiple threads execute this method concurrently, correct?
Your observation is correct.
OTOH, if this is not possible for two threads to enter this method concurrently (or there are some other safety guarantees), I think it should be documented.
Agreed. From what I see in the code, there are safety guarantees in the calling code:
// Multiple threads could read the same `old_tick`...
let old_tick = self.last_tick.load(ORDER_TYPE);
let new_tick = self.start_time.elapsed().as_nanos() as u64;
let elapsed = new_tick - old_tick;
// _"Problematic"_ `if` - see a comment below.
if elapsed > INTERVAL * 1_000_000_000 {
let new_interval_start_tick = new_tick - elapsed % (INTERVAL * 1_000_000_000);
// But then only one will succeed in the following COMPARE EXCHANGE operation.
if self
.last_tick
.compare_exchange(old_tick, new_interval_start_tick, ORDER_TYPE, ORDER_TYPE)
.is_ok()
{
let required_ticks = elapsed / (INTERVAL * 1_000_000_000);
// So only one thread will do the following ticks.
// My only concern is that this loop might take so long that another thread
// enters the _"problematic"_ `if` and then we have a logical race there.
// BUT this is extremely unlikely, because then the loop would have to take
// 5 seconds! (INTERVAL * 1e9). So I think we are safe from those races.
for _ in 0..required_ticks {
self.one_minute_rate.tick();
self.five_minute_rate.tick();
self.fifteen_minute_rate.tick();
}
}
}
Perhaps my comments could be added to the code, along with a SHOUTING WARNING that tick
must not be called concurrently?
Or, we could use advanced type system magic machinery and make tick
require a SafetyMark
instance as an argument, which would be constructible only the block guarded by that COMPARE EXCHANGE
idiom, with the restriction enforced by the visibility rules?
The idea is a bit similar to the SendAttemptedProof
in pager.rs
. Actually, I'd happily write such a type-system-guaranteed mechanism. WDYT? @muzarski @Lorak-mmk
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 did not read the code yet, but if something needs to not have concurrent access, then it should accept &mut
instead of reinventing it using some type system trickery. Why is that not possible?
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.
Well, it's not possible because of the complex logic involved. From what I described in the comments it's clear that it's not statically provable by the borrow checker that the code is sound.
Hi, thanks for the PR! I did not read all the code yet, but I did read the linked histogram issue. I think that the best course for now is to:
Why do I think we should use AtomicHistogram (at least for now)?
wdyt @wprzytula @muzarski ? |
Yep, sounds good to me. |
@QuerthDP can you please fix the conflicts and address any comment left? (if any) |
27e4e86
to
8a0ea57
Compare
Changelog:
TODO:
|
/// **WARNING: MUST NOT BE CALLED CONCURRENTLY!** | ||
fn tick(&self) { |
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.
💭 This makes a valid case to employ unsafe
, I believe, to force callers to prove that the contract is upheld.
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.
Will there be a memory corruption if the contract is violated? If not, then it is not a valid use case for unsafe.
fn tick_if_necessary(&self) { | ||
// Multiple threads could read the same `old_tick`... | ||
let old_tick = self.last_tick.load(ORDER_TYPE); | ||
let new_tick = self.start_time.elapsed().as_nanos() as u64; | ||
let elapsed = new_tick - old_tick; | ||
|
||
// _"Problematic"_ `if` - see a comment below. | ||
if elapsed > INTERVAL * 1_000_000_000 { | ||
let new_interval_start_tick = new_tick - elapsed % (INTERVAL * 1_000_000_000); | ||
// But then only one will succeed in the following COMPARE EXCHANGE operation. | ||
if self | ||
.last_tick | ||
.compare_exchange(old_tick, new_interval_start_tick, ORDER_TYPE, ORDER_TYPE) | ||
.is_ok() | ||
{ | ||
let required_ticks = elapsed / (INTERVAL * 1_000_000_000); | ||
// So only one thread will do the following ticks. | ||
// My only concern is that this loop might take so long that another thread | ||
// enters the _"problematic"_ `if` and then we have a logical race there. | ||
// BUT this is extremely unlikely, because then the loop would have to take | ||
// 5 seconds! (INTERVAL * 1e9). So I think we are safe from those races. | ||
for _ in 0..required_ticks { | ||
self.one_minute_rate.tick(); | ||
self.five_minute_rate.tick(); | ||
self.fifteen_minute_rate.tick(); | ||
} | ||
} | ||
} | ||
} | ||
} |
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 believe the body of this function could be put into unsafe
block in order to warn readers and further implementers, and to allow calling tick()
after making it an unsafe
function.
Add atomic histogram from the histogram crate to metrics instead of a plain histogram placed under a mutex. This commit also updates crate histogram dependency from 0.6.9 to 0.11.1 for atomic functionalities and cleaner error handling.
Implement metrics making use of the histogram to measure query latencies. Added metrics are provided by the Snapshot structure containing metrics such as: min, max, mean, median, standard deviation and various percentiles. Co-authored-by: NikodemGapski <[email protected]>
Implement rates similar to those available in cpp-driver. [CPP-Driver implementation](https://github.com/scylladb/cpp-driver/blob/9d6b05c9d4ebd0a6d7006af4df3e33fcdf956eeb/src/metrics.hpp#L39C1-L252C5)
Implement gathering of connection metrics like total number of active connections, connection timeouts and request timeouts. Co-authored-by: Wojciech Przytuła <[email protected]>
Add metrics crate feature which enables usage and gathering of metrics. Therefore everyone willing to use metrics in their code is required to add metrics feature in their Cargo.toml file or compile otherwise with --features metrics flag. Additionally, add a CI step with cargo checks for this feature.
Inform that metrics may now only be used under crate feature 'metrics'. Mention new metrics in documentation and show an example how to collect them. Adjust examples to include new metrics.
As the user should not be able to create metrics instance otherwise than by `get_metrics()` function, the `Metrics::new()` method shall be set to pub(crate) visibility to support only internal usage.
8a0ea57
to
66b63c9
Compare
Changelog:
|
This patch contains:
Fixes: #330
Pre-review checklist
./docs/source/
.Fixes:
annotations to PR description.