datui_lib/measurements.rs
1//! What opening a dataset cost, measured rather than guessed.
2//!
3//! Every number here is one datui produced itself: it timed its own listing, counted
4//! its own requests, and added up the bytes it received. Where Polars does the reading
5//! datui cannot count the requests or the bytes, and this module records nothing rather
6//! than record a figure it cannot stand behind. That is why so much of what it holds is
7//! optional: a stretch reports a file count, a request count and a byte count only
8//! where it has one, and `docs/user-guide/dataset-info.md` says which routes have
9//! which.
10//!
11//! The tallies are written by the threads doing the reading, which is why they are
12//! atomic, and read by the render, which is why nothing here ever blocks.
13
14use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
15use std::time::Duration;
16
17/// One stretch of work, and what it cost.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub struct Cost {
20 /// How long it took, from the first request to the last answer.
21 pub took: Duration,
22 /// The data files found, or the footers read — `None` where the stretch did the
23 /// work without ever learning a count.
24 ///
25 /// Every route that reaches a user today knows its count. The one that does not is
26 /// the walk to a prefix's two ends, which never lists what lies between them; that
27 /// route cannot currently be reached (see `schema_from_one_cloud_hive`), and this
28 /// is what keeps it from reporting the two ends as the size of the dataset if it
29 /// ever is.
30 pub files: Option<usize>,
31 /// Requests datui made itself and can count, with the bytes they returned.
32 ///
33 /// `None` on every listing, and not only the local ones: a directory is read rather
34 /// than requested, and a remote prefix is one call whose round trips happen inside
35 /// the object store, which does not say how many there were. A figure of zero would
36 /// read as "no data moved" rather than "not measured here".
37 pub over_the_wire: Option<OverTheWire>,
38}
39
40/// Two stretches' wire figures as one.
41///
42/// The requests add. The bytes add only where both stretches weighed theirs: one that
43/// did not leaves the pair unable to say what its requests brought back, and carrying
44/// the other's figure forward would present it as the bytes behind all of them.
45fn combine_wire(a: OverTheWire, b: OverTheWire) -> OverTheWire {
46 OverTheWire {
47 requests: a.requests + b.requests,
48 bytes: a.bytes.zip(b.bytes).map(|(x, y)| x + y),
49 }
50}
51
52/// Every stretch added up: how long the open has taken, and what it asked for.
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub struct Total {
55 /// The stretches' times added together.
56 pub took: Duration,
57 /// The requests and bytes of the stretches that counted any, or `None` if none did.
58 pub over_the_wire: Option<OverTheWire>,
59}
60
61/// What datui asked for over a network, exactly.
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub struct OverTheWire {
64 /// Requests datui issued.
65 pub requests: usize,
66 /// Bytes those requests returned, where datui counted them.
67 ///
68 /// Nothing reports requests without weighing them today — only the footer reads
69 /// report requests at all, and they weigh what comes back. It is optional so that a
70 /// stretch which one day counts round trips it does not read cannot be made to
71 /// claim a figure of zero, which would say they arrived empty.
72 pub bytes: Option<u64>,
73}
74
75/// A running tally of one kind of work.
76///
77/// `ran` is not redundant with the other fields: a listing that returned nothing still
78/// took time and is still a measurement, so zero cannot stand in for "this never
79/// happened".
80#[derive(Debug, Default)]
81struct Tally {
82 ran: AtomicBool,
83 nanos: AtomicU64,
84 files: AtomicUsize,
85 /// Counted as the requests are made, and still climbing while a pass runs.
86 live_requests: AtomicUsize,
87 live_bytes: AtomicU64,
88 /// Whether anything recorded here counted bytes at all.
89 counted_bytes: AtomicBool,
90 /// What those counters stood at when a pass last finished, which is what is shown.
91 ///
92 /// Separate from the live pair because the time and the file count only move when a
93 /// pass ends: showing the live figures beside them would put a pass's requests next
94 /// to the previous pass's time, and read as forty-one requests over two files.
95 counted: AtomicBool,
96 requests: AtomicUsize,
97 bytes: AtomicU64,
98 /// Whether any stretch recorded here knew a count at all.
99 counted_files: AtomicBool,
100}
101
102impl Tally {
103 /// Record a finished stretch of work, adding it to whatever this tally already
104 /// holds.
105 ///
106 /// Adding rather than replacing because one kind of work can happen in more than
107 /// one stretch. A cloud dataset past a wave of concurrent reads opens on two
108 /// footers and then reads every footer behind the open; a dataset whose open could
109 /// not settle its row count reads every footer again to take it. What the footers
110 /// cost is all of those passes, re-reads included, which is why the count this
111 /// keeps is footers read rather than files. A dataset being opened starts from a
112 /// tally that holds nothing, so nothing is carried over from the last one.
113 fn record(&self, took: Duration, files: Option<usize>, over_the_wire: bool) {
114 self.nanos.fetch_add(
115 took.as_nanos().min(u64::MAX as u128) as u64,
116 Ordering::Relaxed,
117 );
118 if let Some(files) = files {
119 self.files.fetch_add(files, Ordering::Relaxed);
120 self.counted_files.store(true, Ordering::Relaxed);
121 }
122 if over_the_wire {
123 // Read from the live counters here rather than taken from the caller: the
124 // caller would have had to read them a moment earlier, and a request landing
125 // in between would be written back out again.
126 // `fetch_max`, not `store`: were two metered passes ever to finish at
127 // once, whichever read the live counter first would write its lower figure
128 // back over the higher one. Today they cannot — a count is refused while
129 // the pass behind an open is still reading, and every other pair is
130 // serialized — so this guards an interleaving the rest of the design
131 // currently forbids. It costs nothing, and within a dataset these counters
132 // only climb, so taking the larger is correct whether or not that holds.
133 self.requests.fetch_max(
134 self.live_requests.load(Ordering::Relaxed),
135 Ordering::Relaxed,
136 );
137 self.bytes
138 .fetch_max(self.live_bytes.load(Ordering::Relaxed), Ordering::Relaxed);
139 self.counted.store(true, Ordering::Relaxed);
140 }
141 // Last, and released, so a render that sees `ran` sees every field behind it.
142 self.ran.store(true, Ordering::Release);
143 }
144
145 /// Record a stretch in place of whatever this tally held, rather than adding to it.
146 fn replace(&self, took: Duration, files: Option<usize>) {
147 self.nanos.store(
148 took.as_nanos().min(u64::MAX as u128) as u64,
149 Ordering::Relaxed,
150 );
151 match files {
152 Some(files) => {
153 self.files.store(files, Ordering::Relaxed);
154 self.counted_files.store(true, Ordering::Relaxed);
155 }
156 None => self.counted_files.store(false, Ordering::Relaxed),
157 }
158 self.ran.store(true, Ordering::Release);
159 }
160
161 /// One more request, and the bytes it returned. Called from the threads doing the
162 /// reading, once per request, before the stretch is recorded.
163 fn request(&self, bytes: u64) {
164 self.live_requests.fetch_add(1, Ordering::Relaxed);
165 self.live_bytes.fetch_add(bytes, Ordering::Relaxed);
166 self.counted_bytes.store(true, Ordering::Relaxed);
167 }
168
169 /// A whole pass's worth of requests at once, for work counted against a meter of
170 /// its own and then folded in here.
171 fn add_requests(&self, wire: OverTheWire) {
172 self.live_requests
173 .fetch_add(wire.requests, Ordering::Relaxed);
174 if let Some(bytes) = wire.bytes {
175 self.live_bytes.fetch_add(bytes, Ordering::Relaxed);
176 self.counted_bytes.store(true, Ordering::Relaxed);
177 }
178 }
179
180 fn cost(&self) -> Option<Cost> {
181 if !self.ran.load(Ordering::Acquire) {
182 return None;
183 }
184 Some(Cost {
185 took: Duration::from_nanos(self.nanos.load(Ordering::Relaxed)),
186 files: self
187 .counted_files
188 .load(Ordering::Relaxed)
189 .then(|| self.files.load(Ordering::Relaxed)),
190 over_the_wire: self.counted.load(Ordering::Relaxed).then(|| OverTheWire {
191 requests: self.requests.load(Ordering::Relaxed),
192 bytes: self
193 .counted_bytes
194 .load(Ordering::Relaxed)
195 .then(|| self.bytes.load(Ordering::Relaxed)),
196 }),
197 })
198 }
199}
200
201/// What an open reports as it goes: how far its footer pass has got, and what the work
202/// has cost so far.
203///
204/// The two travel together down every route an open can take, so they are handed down
205/// together. Both are shared with the threads doing the reading, and both belong to the
206/// open that created them — see [`Meter`] for why a new open builds new ones rather
207/// than clearing these.
208#[derive(Debug, Clone, Default)]
209pub struct OpenReport {
210 /// How far the footer pass has got, for the loading screen.
211 pub progress: std::sync::Arc<crate::schema_union::FooterProgress>,
212 /// What the work has cost, for the Info panel.
213 pub meter: std::sync::Arc<Meter>,
214 /// Where to look for what a previous open of this dataset learned, and where to
215 /// leave what this one learns. `None` for a route with nowhere to keep it, and for
216 /// the tests that do not care.
217 ///
218 /// It travels with the other two because it belongs to the same moment — an open
219 /// reports what it is doing, records what it cost, and remembers what it found, and
220 /// all three are handed down the same routes.
221 pub remembered: Option<crate::cache::CacheManager>,
222 /// Where what is remembered for the home screen is written, off the open's path:
223 /// the app's, which the home listing settles before it reads.
224 pub(crate) writes: crate::background::CacheWrites,
225}
226
227/// What the open on screen cost, as it is measured.
228///
229/// Shared with the threads that do the listing and the footer reads, which is why it is
230/// held behind an `Arc` and written through `&self`.
231///
232/// There is no way to clear one. Opening a dataset builds a new meter instead, for the
233/// reason the footer counter does: abandoning a load cancels nothing, so the reads of
234/// the directory that was walked away from are still running, and a meter they still held
235/// would go on adding their figures to the next dataset's.
236#[derive(Debug, Default)]
237pub struct Meter {
238 listing: Tally,
239 footers: Tally,
240 /// The most recent page of rows, replacing rather than adding: this one says what
241 /// the page on screen cost, not what every page since the open came to.
242 last_page: Tally,
243 /// Whether a pass to settle the row count has already been counted.
244 counted_rows: AtomicBool,
245}
246
247impl Meter {
248 /// Finding the dataset's files took `took` and returned `files` of them.
249 ///
250 /// `over_the_wire` is always `false` here today, and the parameter is kept so the
251 /// two stretches record the same way. No listing route can count its requests: a
252 /// local walk makes none, and every remote one — the flat `list` and the
253 /// level-by-level walk a glob uses alike — hands the paging to the object store,
254 /// which does not say how many round trips it took.
255 pub fn listed(&self, took: Duration, files: Option<usize>, over_the_wire: bool) {
256 self.listing.record(took, files, over_the_wire);
257 }
258
259 /// A pass over `files` footers took `took`.
260 ///
261 /// `over_the_wire` publishes the requests counted by [`Self::footer_request`] since
262 /// the meter was made. A pass that read from a disk passes `false`: there were no
263 /// requests, and a zero would read as none having been needed.
264 pub fn read_footers(&self, took: Duration, files: Option<usize>, over_the_wire: bool) {
265 self.footers.record(took, files, over_the_wire);
266 }
267
268 /// A pass over `files` footers, run to settle the dataset's row count, took `took`.
269 ///
270 /// Recorded once and then never again, and the return says which happened. Counting
271 /// runs whenever the row count is invalidated — clearing a filter does it, so a few
272 /// minutes of exploring runs it several times — and those later passes are re-work
273 /// on a dataset that is already open. Adding them would make a section headed by
274 /// what opening the dataset cost climb for as long as the session lasted.
275 pub fn counted_rows(
276 &self,
277 took: Duration,
278 files: Option<usize>,
279 wire: Option<OverTheWire>,
280 ) -> bool {
281 // Only for an open this meter measured, and checked before the one shot rather
282 // than after it. A dataset opened by a route that reports nothing — a directory
283 // handed straight to Polars because `single_spine_schema` is off — still has
284 // its rows counted afterwards, and that count writing here would raise a
285 // section out of nothing whose every figure is work done after the dataset was
286 // already on screen.
287 if self.listing.cost().is_none() {
288 return false;
289 }
290 if self
291 .counted_rows
292 .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
293 .is_err()
294 {
295 return false;
296 }
297 // Folded in only once the one-shot has been won. A pass that is declined must
298 // leave the counters alone, or its requests would be published by whichever
299 // pass records next.
300 if let Some(w) = wire {
301 self.footers.add_requests(w);
302 }
303 self.footers.record(took, files, wire.is_some());
304 true
305 }
306
307 /// Reading the page now on screen took `took` and read `files` of the dataset's
308 /// files.
309 ///
310 /// Replaces rather than adds: this is the cost of the page a user is looking at,
311 /// and adding every page they have scrolled through would answer a question nobody
312 /// asked. `files` is `None` where Polars was handed the whole scan and decided for
313 /// itself what to read.
314 ///
315 /// No byte figure. Polars does this read and does not report what it fetched, and
316 /// the row-group sizes the footers hold would give the size of whole row groups for
317 /// every column — not the columns on screen, and not what crossed the wire. A
318 /// number that wrong is worse than none.
319 pub fn read_page(&self, took: Duration, files: Option<usize>) {
320 self.last_page.replace(took, files);
321 }
322
323 /// What the page now on screen cost, or `None` before one has been read.
324 pub fn last_page(&self) -> Option<Cost> {
325 self.last_page.cost()
326 }
327
328 /// One footer request, and the bytes it returned. Counted as the reads happen; what
329 /// they come to is published when the pass ends.
330 pub fn footer_request(&self, bytes: u64) {
331 self.footers.request(bytes);
332 }
333
334 /// What finding the files cost, or `None` if no listing has been measured.
335 pub fn listing(&self) -> Option<Cost> {
336 self.listing.cost()
337 }
338
339 /// What reading the footers cost, or `None` if no footer pass has been measured.
340 pub fn footers(&self) -> Option<Cost> {
341 self.footers.cost()
342 }
343
344 /// Everything measured so far, added up. `None` until something has been measured.
345 ///
346 /// No file count. The stretches count different things — the listing counts the
347 /// dataset's files, the footer pass counts footers read, which on a staged open is
348 /// more than there are files — so adding them gives a number that is not the size
349 /// of anything, sitting under the same word the listing row uses for the size of
350 /// the dataset.
351 ///
352 /// The wire figures are the sum of the stretches that counted them, and are `None`
353 /// when no stretch did: a local dataset's total is a time, not a time and a
354 /// pretence of nothing having been transferred.
355 pub fn total(&self) -> Option<Total> {
356 // Listing and footers only. The page is not part of opening the dataset — it is
357 // what looking at one costs, and it changes every time the view moves.
358 let parts: Vec<Cost> = [self.listing(), self.footers()]
359 .into_iter()
360 .flatten()
361 .collect();
362 // Two stretches or none. One stretch's total is that stretch, printed twice
363 // under two labels — which a single remote object would do, having a footer to
364 // read and nothing to list.
365 if parts.len() < 2 {
366 return None;
367 }
368 Some(Total {
369 took: parts.iter().map(|p| p.took).sum(),
370 over_the_wire: parts
371 .iter()
372 .filter_map(|p| p.over_the_wire)
373 .reduce(combine_wire),
374 })
375 }
376}
377
378#[cfg(test)]
379mod tests {
380 use super::*;
381
382 /// A meter that has measured nothing says so, rather than saying nothing happened.
383 ///
384 /// The two are different claims and the difference is the whole point of the
385 /// section: a dataset opened by a route that does not measure must show no figures
386 /// at all, not a listing that took no time over no files.
387 #[test]
388 fn nothing_measured_is_not_the_same_as_nothing_having_happened() {
389 let meter = Meter::default();
390 assert_eq!(meter.listing(), None);
391 assert_eq!(meter.footers(), None);
392 assert_eq!(meter.total(), None);
393
394 // A listing that genuinely returned nothing is still a measurement.
395 meter.listed(Duration::ZERO, Some(0), false);
396 assert_eq!(
397 meter.listing(),
398 Some(Cost {
399 took: Duration::ZERO,
400 files: Some(0),
401 over_the_wire: None
402 }),
403 "an empty prefix was still listed, and the time it took is a real figure"
404 );
405 }
406
407 /// A staged open reads two footers to open and the rest behind it, and what the
408 /// footers cost is both passes.
409 ///
410 /// The second pass adding to the first is the behaviour under test: replacing would
411 /// report the tail of the work as though it were all of it, which on a large prefix
412 /// is the difference between "four seconds of footers" and "one".
413 #[test]
414 fn a_second_footer_pass_adds_to_the_first_rather_than_replacing_it() {
415 let meter = Meter::default();
416 meter.footer_request(1_000);
417 meter.footer_request(1_000);
418 meter.read_footers(Duration::from_millis(200), Some(2), true);
419
420 // The pass behind the open, reading the rest against the same meter.
421 for _ in 0..98 {
422 meter.footer_request(1_000);
423 }
424 meter.read_footers(Duration::from_millis(3_000), Some(98), true);
425
426 let footers = meter.footers().expect("both passes were measured");
427 assert_eq!(footers.took, Duration::from_millis(3_200), "both times");
428 assert_eq!(footers.files, Some(100), "both counts");
429 assert_eq!(
430 footers.over_the_wire,
431 Some(OverTheWire {
432 requests: 100,
433 bytes: Some(100_000)
434 }),
435 "and every request either pass made"
436 );
437 }
438
439 /// The total adds the stretches, and claims wire figures only from the stretches
440 /// that had any.
441 #[test]
442 fn the_total_adds_what_there_is_and_claims_no_more() {
443 let meter = Meter::default();
444 meter.listed(Duration::from_millis(100), Some(3), false);
445 meter.footer_request(4_096);
446 meter.read_footers(Duration::from_millis(900), Some(3), true);
447
448 assert_eq!(
449 meter.total(),
450 Some(Total {
451 took: Duration::from_secs(1),
452 over_the_wire: Some(OverTheWire {
453 requests: 1,
454 bytes: Some(4_096)
455 }),
456 }),
457 "the times of both, and the requests of the one that made them — and no \
458 file count, which would be two different denominators added together"
459 );
460
461 // A total over stretches that made no requests claims none, rather than zero.
462 let local = Meter::default();
463 local.listed(Duration::from_millis(1), Some(2), false);
464 local.read_footers(Duration::from_millis(2), Some(2), false);
465 assert_eq!(
466 local.total().and_then(|t| t.over_the_wire),
467 None,
468 "nothing crossed a wire, so there is no figure to give"
469 );
470 }
471
472 /// A total carries only the figures its stretches actually had.
473 ///
474 /// A listing never reports requests — no route can count a listing's round trips,
475 /// since the object store does its own paging — so a total's requests and bytes are
476 /// the footer reads', and a listing beside them adds only time.
477 #[test]
478 fn a_total_gives_no_figure_it_cannot_account_for() {
479 let meter = Meter::default();
480 meter.listed(Duration::from_millis(1), None, false);
481 for _ in 0..4 {
482 meter.footer_request(250);
483 }
484 meter.read_footers(Duration::from_millis(3), Some(2), true);
485
486 let listing = meter.listing().expect("the walk was measured");
487 assert_eq!(
488 listing.over_the_wire, None,
489 "a listing claims nothing over the wire, having no way to count it"
490 );
491
492 let total = meter.total().expect("and a total over the two stretches");
493 assert_eq!(
494 total.took,
495 Duration::from_millis(4),
496 "the two times added up"
497 );
498 assert_eq!(
499 total.over_the_wire,
500 Some(OverTheWire {
501 requests: 4,
502 bytes: Some(1_000)
503 }),
504 "and the footer reads' requests and bytes, which account for each other"
505 );
506 }
507
508 /// A stretch that counted no bytes leaves the total unable to name any.
509 ///
510 /// Nothing produces this shape today — every stretch that reports requests also
511 /// counts their bytes — so this holds the arithmetic rather than a route: were a
512 /// stretch ever to report requests it could not weigh, carrying the other's bytes
513 /// forward would present them as the bytes behind all of them.
514 #[test]
515 fn a_total_will_not_weigh_requests_nothing_weighed() {
516 let a = Cost {
517 took: Duration::from_millis(1),
518 files: None,
519 over_the_wire: Some(OverTheWire {
520 requests: 4,
521 bytes: None,
522 }),
523 };
524 let b = Cost {
525 took: Duration::from_millis(3),
526 files: Some(2),
527 over_the_wire: Some(OverTheWire {
528 requests: 4,
529 bytes: Some(1_000),
530 }),
531 };
532 assert_eq!(
533 Some(combine_wire(
534 a.over_the_wire.unwrap(),
535 b.over_the_wire.unwrap()
536 )),
537 Some(OverTheWire {
538 requests: 8,
539 bytes: None
540 }),
541 "eight requests, and no byte figure: a thousand bytes is what four of them \
542 returned, not eight"
543 );
544 }
545
546 /// The page replaces, and is no part of what opening the dataset cost.
547 ///
548 /// Every other stretch adds, because reading a dataset's footers twice really did
549 /// cost twice. A page is different: there is one on screen, the figure describes
550 /// that one, and scrolling through a hundred of them must not report the hundred
551 /// added together as though the last one had taken a minute.
552 #[test]
553 fn the_page_on_screen_replaces_the_one_before_it_and_is_not_part_of_the_open() {
554 let meter = Meter::default();
555 meter.listed(Duration::from_millis(10), Some(3), false);
556 meter.read_footers(Duration::from_millis(20), Some(3), false);
557
558 meter.read_page(Duration::from_millis(500), Some(2));
559 meter.read_page(Duration::from_millis(300), Some(1));
560 assert_eq!(
561 meter.last_page(),
562 Some(Cost {
563 took: Duration::from_millis(300),
564 files: Some(1),
565 over_the_wire: None
566 }),
567 "the page on screen is the one before last replaced, not added to it"
568 );
569
570 let total = meter.total().expect("the open was measured");
571 assert_eq!(
572 total.took,
573 Duration::from_millis(30),
574 "and the total is the listing and the footers — looking at a page is not \
575 part of opening the dataset"
576 );
577
578 // A page Polars chose the files for reports a time and no count.
579 meter.read_page(Duration::from_millis(40), None);
580 assert_eq!(
581 meter.last_page().and_then(|c| c.files),
582 None,
583 "a whole-scan read says how long it took and not how many files it touched"
584 );
585 }
586
587 /// Publishing never moves a figure backwards.
588 ///
589 /// Two metered passes can finish at once — a dataset filtered while the pass behind
590 /// its open is still reading — and the one that read the live counter first would,
591 /// storing, write its lower figure over the other's higher one. The state below is
592 /// exactly that interleaving caught mid-way: a figure already published, and a live
593 /// counter that a slower pass read before it climbed.
594 #[test]
595 fn publishing_cannot_lower_a_figure_another_pass_has_already_published() {
596 let tally = Tally::default();
597 tally.requests.store(100, Ordering::Relaxed);
598 tally.bytes.store(100_000, Ordering::Relaxed);
599 tally.live_requests.store(50, Ordering::Relaxed);
600 tally.live_bytes.store(50_000, Ordering::Relaxed);
601 // As a pass that really read bytes leaves it.
602 tally.counted_bytes.store(true, Ordering::Relaxed);
603
604 tally.record(Duration::from_millis(1), Some(1), true);
605
606 let cost = tally.cost().expect("the pass was recorded");
607 assert_eq!(
608 cost.over_the_wire,
609 Some(OverTheWire {
610 requests: 100,
611 bytes: Some(100_000)
612 }),
613 "the higher figure stands; storing the live read would have halved both"
614 );
615 }
616
617 /// A count pass the one shot declines leaves the figures completely alone.
618 ///
619 /// Not just the time and the count: its requests must not reach the live counters
620 /// either, or the next pass to record would publish them as its own.
621 #[test]
622 fn a_declined_count_adds_nothing_at_all() {
623 let meter = Meter::default();
624 // As an open that measured itself leaves it: counting belongs to one of those.
625 meter.listed(Duration::from_millis(1), Some(3), false);
626 let wire = OverTheWire {
627 requests: 2,
628 bytes: Some(8_192),
629 };
630 assert!(
631 meter.counted_rows(Duration::from_millis(100), Some(3), Some(wire)),
632 "the first count is the one that is measured"
633 );
634 let after_first = meter.footers().expect("and it was measured");
635
636 for _ in 0..3 {
637 assert!(
638 !meter.counted_rows(Duration::from_millis(100), Some(3), Some(wire)),
639 "later counts are re-work and are declined"
640 );
641 }
642 assert_eq!(
643 meter.footers(),
644 Some(after_first),
645 "so the figures stand where the first count left them"
646 );
647
648 // And the declined passes left nothing behind for the next pass to publish.
649 meter.read_footers(Duration::from_millis(50), Some(1), true);
650 assert_eq!(
651 meter.footers().and_then(|c| c.over_the_wire),
652 Some(wire),
653 "a pass recording afterwards publishes the two requests that were really \
654 made, not the eight the declined passes would have added"
655 );
656 }
657}