hopr_transport_session/supervision/telemetry.rs
1//! Bounded node-level accounting for the PIX Exit.
2//!
3//! Everything here is compiled unconditionally, including without the `telemetry` feature. Only the
4//! instruments the numbers are eventually written to live behind the feature, in
5//! `crate::telemetry::pix`. The split is deliberate: the delta arithmetic below is the part that
6//! can be wrong in a way an operator would never notice — a gauge that fails to come back down, a
7//! cycle counted twice — and a default `cargo nextest run --lib` must exercise it.
8//!
9//! # Why aggregates rather than more labels
10//!
11//! The two per-Session PIX gauges (`hopr_session_pix_gate_mode`, `hopr_session_pix_recovery_progress`)
12//! answer questions about one Session. An operator's questions are about the node: how much
13//! predeposit service is exposed right now, whether egress is blocked on deposits or on share lag,
14//! whether admission is refusing peers because the node is full or because their parameters are
15//! wrong. Answering those by adding labels would make the view worse rather than better — every
16//! `session_id`-labelled instrument stops reporting new Sessions after ~2000 of them (#8305), and
17//! nothing retires the series.
18//!
19//! So the aggregates carry no identifier at all. Every label value in this module comes from a
20//! closed enum, which is what makes that a property of the type system rather than a promise.
21//!
22//! # The census, and why it is not inc/dec
23//!
24//! [`SessionPixSupervisor`](super::supervisor::SessionPixSupervisor) recomputes
25//! [`PixSessionSnapshot`] from its own live state on every turn, and
26//! [`PixSessionTelemetry::publish`] applies the *difference* against what it last published. No
27//! transition increments a live-set gauge directly.
28//!
29//! That is what makes "gauges return to zero on every terminal path" structural instead of a matter
30//! of auditing call sites. A cycle that is retired, times out, is rolled back at admission, or
31//! disappears because its Session was dropped mid-turn is simply absent from the next census, and
32//! the delta that follows is the decrement. There is no path that can forget one, because there is
33//! no path that performs one.
34//!
35//! Cumulative *counters* are the opposite case — they must not be recomputed, because their whole
36//! value is that they survive the state they describe. Those are latched at the transition that
37//! changes the source of truth ([`PixTurnEvents`]) and drained by the same publish.
38
39use std::{
40 sync::{
41 Arc,
42 atomic::{AtomicBool, Ordering},
43 },
44 time::Instant,
45};
46
47use super::gate::{GateBlockReason, ServiceGate};
48
49// ---------------------------------------------------------------------------
50// Label enums
51// ---------------------------------------------------------------------------
52
53/// Front-gate mode of a live PIX Session.
54///
55/// Every live PIX Session is in exactly one of these, so the two buckets of
56/// `hopr_pix_sessions_active` sum to the number of supervised Sessions.
57#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, strum::Display)]
58#[strum(serialize_all = "snake_case")]
59pub enum PixGateMode {
60 /// The front cycle is unfunded and spending its bounded predeposit allowance.
61 ///
62 /// The default, because a Session starts here and stays until its first deposit confirms.
63 #[default]
64 Predeposit,
65 /// The front cycle is funded, or a paid recovered predecessor still holds the front.
66 Funded,
67}
68
69/// Supervisor phase of a retained SSA cycle.
70///
71/// Recovered tombstones and cycles in `Closing` are deliberately absent. They are retained state
72/// rather than live state — a tombstone exists only so late acknowledgements have something to land
73/// on — so counting them would leave `hopr_pix_cycles_active` unable to drain.
74#[derive(Clone, Copy, Debug, PartialEq, Eq, strum::Display)]
75#[strum(serialize_all = "snake_case")]
76pub enum PixCyclePhase {
77 /// Requested; waiting for a complete verifiable commitment.
78 AwaitingCommitment,
79 /// Commitment verified; waiting for the deposit pool's verdict.
80 AwaitingDeposit,
81 /// Funded and recovering shares.
82 Recovering,
83 /// A recovered predecessor still authorized to hold the paid service front while its negotiated
84 /// FIFO tail drains.
85 PaidTail,
86}
87
88/// Why a Session's fill is not keeping up, as `hopr_pix_sessions_stalled` labels it.
89///
90/// The two are different faults with different fixes, and an operator watching a cycle fail to
91/// progress needs to tell them apart: one is an Exit that cannot send, the other an Exit that is
92/// sending into a cycle which is not advancing.
93#[derive(Clone, Copy, Debug, PartialEq, Eq, strum::Display)]
94#[strum(serialize_all = "snake_case")]
95pub enum PixStallCause {
96 /// The Exit's own SURB estimate is below the fill reserve, so fill is being withheld. Tracked by
97 /// the manager's `PixFillControl`, which is the only thing that sees the estimate.
98 SurbStarved,
99 /// The cycle being filled for has not advanced for `max_recovery_idle`, so the planner has
100 /// fallen back to its heartbeat.
101 ShareStarved,
102}
103
104/// A cycle lifecycle transition worth a cumulative count.
105///
106/// There is deliberately no `retired` event. Every `RetireSsa` follows either a tombstone expiring
107/// or [`close_ssa_and_collect`](super::supervisor::SessionPixSupervisor), both of which are already
108/// counted here, so a third event would read the same transition twice. What is left is exact:
109/// `requested = recovered + failed + (still live)`.
110#[derive(Clone, Copy, Debug, PartialEq, Eq, strum::Display)]
111#[strum(serialize_all = "snake_case")]
112pub enum PixCycleEvent {
113 /// An index was allocated and put into an `SsaRequest`.
114 Requested,
115 /// Its commitment verified.
116 Committed,
117 /// Its deposit was confirmed by the pool.
118 Funded,
119 /// It reconstructed fully.
120 Recovered,
121 /// It did not recover, whatever the reason.
122 ///
123 /// Reported for a cycle retired by the supervisor *and* for one still live when its Session
124 /// ended, which is not the same as "the supervisor named a close reason for it":
125 /// `on_unverifiable_shares` closes a Session outright without routing its siblings through
126 /// retirement, and `hopr_pix_closures_total{reason}` is where the reasons are. The label
127 /// means "did not recover".
128 Failed,
129}
130
131impl PixCycleEvent {
132 /// Whether the census accounts for this transition too, so that emitting it out of step with
133 /// the census would read the same cycle twice.
134 ///
135 /// These three are the terms of `requested = recovered + failed + live`, whose `live` term is
136 /// the census. The other two are edges the census has no opinion about: a cycle that commits or
137 /// funds moves between census buckets without entering or leaving it, so counting one twice
138 /// would be wrong but counting it out of step with the census is not. That is what
139 /// [`EventScope::EdgesOnly`] rests on.
140 fn is_census_coupled(self) -> bool {
141 matches!(self, Self::Requested | Self::Recovered | Self::Failed)
142 }
143}
144
145/// Why PIX egress stopped, as `hopr_pix_gate_blocks_total` labels it.
146///
147/// The gate's own two reasons plus the one it does not have a verdict for: a poisoned gate refuses
148/// through `Err(GateClosed)` rather than through a `Blocked`, because it is not a stall the Session
149/// will come out of.
150#[derive(Clone, Copy, Debug, PartialEq, Eq, strum::Display)]
151#[strum(serialize_all = "snake_case")]
152pub enum PixGateBlock {
153 /// The unfunded front has spent its whole predeposit allowance.
154 PredepositExhausted,
155 /// Funded service has run its full ceiling ahead of the shares coming back.
156 ShareLag,
157 /// The gate was poisoned: the Session is being torn down and will serve nothing further.
158 Closed,
159}
160
161impl From<GateBlockReason> for PixGateBlock {
162 fn from(reason: GateBlockReason) -> Self {
163 match reason {
164 GateBlockReason::PredepositExhausted => Self::PredepositExhausted,
165 GateBlockReason::ShareLag => Self::ShareLag,
166 }
167 }
168}
169
170/// One episode of PIX egress being blocked, measured from first refusal until it ends.
171///
172/// # Why an episode rather than a refusal
173///
174/// A blocked gate is polled again by whatever is trying to send, so counting refusals would measure
175/// how hard the caller retries rather than how long the Exit was stalled — the issue's "a tight
176/// retry loop must not increment blocked on every failed poll". The entry into the blocked state is
177/// counted once, here, and how long it lasted is measured separately.
178///
179/// # Why `Drop`
180///
181/// The episode ends three ways, and only one of them is a return value: the gate resumes and the
182/// parked writer proceeds, the gate is poisoned and the writer fails, or the whole future is
183/// dropped because the Session was torn down underneath it. `Drop` is the one mechanism that covers
184/// all three, which is what makes "block timers close exactly once on every terminal path" hold
185/// without any of those paths having to remember.
186pub struct PixGateBlockEpisode {
187 reason: GateBlockReason,
188 began: Instant,
189}
190
191impl PixGateBlockEpisode {
192 /// Counts one episode beginning, and starts its clock.
193 pub fn begin(reason: GateBlockReason) -> Self {
194 emit_gate_block(reason.into());
195 Self {
196 reason,
197 began: Instant::now(),
198 }
199 }
200}
201
202impl Drop for PixGateBlockEpisode {
203 fn drop(&mut self) {
204 emit_gate_block_duration(self.reason, self.began.elapsed().as_secs_f64());
205 }
206}
207
208/// Counts one refusal by a poisoned gate.
209///
210/// No episode and no duration: a poisoned gate never resumes, so the only thing a clock on it would
211/// measure is how long the Session's teardown took to reach its last writer.
212pub fn record_gate_closed() {
213 emit_gate_block(PixGateBlock::Closed);
214}
215
216/// Why an incoming PIX Session was refused before it was established.
217///
218/// Bounded by construction, and finer than the wire reason it maps to. `NoSessionSlot` and
219/// `LiveCycleCapacity` are both `StartErrorReason::NoSlotsAvailable` on the wire, so
220/// `hopr_session_sent_error_count{kind}` cannot tell "this Exit is healthy but its reconstructor
221/// budget is full" from "this Exit is at its Session limit" — which are different problems with
222/// different fixes.
223#[derive(Clone, Copy, Debug, PartialEq, Eq, strum::Display)]
224#[strum(serialize_all = "snake_case")]
225pub enum PixAdmissionRejection {
226 /// The peer offered PIX to a node with no `PixToolbox` installed.
227 NoPixSupport,
228 /// The offer could not be decoded, or fell outside the accepted quota range.
229 UnacceptableParams,
230 /// The session server refused this target, or refused it on the offered terms.
231 TargetPolicy,
232 /// The node's `max_live_cycle_bytes` budget could not cover this Session's reconstructor state.
233 LiveCycleCapacity,
234 /// The node is already managing `maximum_sessions`, or this pseudonym already holds a slot.
235 NoSessionSlot,
236 /// This node could not decide right now — the session server did not answer in time, or its
237 /// request channel was full.
238 Busy,
239}
240
241impl PixAdmissionRejection {
242 /// Counts this refusal, if the peer was actually asking for a PIX Session.
243 ///
244 /// The capability check is here rather than at the six call sites because it is the part that
245 /// can silently be wrong: every one of those sites also refuses ordinary non-PIX Sessions, and a
246 /// `hopr_pix_admission_rejections_total` that counted those would report a PIX problem this node
247 /// does not have. `hopr_session_sent_error_count{kind}` already counts refusals of every kind —
248 /// this one exists precisely to be narrower.
249 ///
250 /// Always compiled, so the gate is exercised by the default test configuration even though the
251 /// emission behind it is not. Returns whether the refusal was counted, which is how a test
252 /// without that feature can still assert the gate rather than merely run it.
253 pub fn record_if_pix(self, capabilities: crate::Capabilities) -> bool {
254 if !capabilities.contains(crate::Capability::UsePIX) {
255 return false;
256 }
257 #[cfg(feature = "telemetry")]
258 crate::telemetry::pix::record_admission_rejection(self);
259 true
260 }
261}
262
263// ---------------------------------------------------------------------------
264// Snapshot and event latch
265// ---------------------------------------------------------------------------
266
267/// One PIX Session's contribution to the node-level live-set gauges.
268///
269/// Recomputed from the supervisor's own state on every turn; see the module documentation for why
270/// it is a census rather than a set of increments.
271#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
272pub struct PixSessionSnapshot {
273 /// Which bucket of `hopr_pix_sessions_active` this Session is in.
274 pub gate_mode: PixGateMode,
275 /// Cycles requested, whose commitment has not verified yet.
276 pub awaiting_commitment: u32,
277 /// Cycles with a verified commitment, waiting for a deposit.
278 pub awaiting_deposit: u32,
279 /// Funded cycles still recovering.
280 pub recovering: u32,
281 /// Whether a recovered predecessor is still holding the paid front (0 or 1).
282 pub paid_tail: u32,
283 /// Packets this Session has been served against an unfunded front's predeposit allowance and
284 /// which no deposit has yet converted into paid service.
285 pub predeposit_exposure_packets: u64,
286 /// Whether this Session's fill has fallen back because its cycle stopped advancing (0 or 1).
287 ///
288 /// A census field rather than an edge the worker latches, so that
289 /// [`release`](PixSessionTelemetry::release) returns it like every other gauge — a Session that
290 /// dies while stalled cannot leave the count standing. [`PixStallCause::SurbStarved`] is not
291 /// here because the supervisor cannot see the SURB estimate.
292 pub stalled_share: u32,
293}
294
295impl PixSessionSnapshot {
296 /// Cycles this Session currently holds in a live (non-terminal, non-tombstone) phase.
297 pub fn live_cycles(&self) -> u32 {
298 self.awaiting_commitment
299 .saturating_add(self.awaiting_deposit)
300 .saturating_add(self.recovering)
301 }
302
303 /// The per-phase counts, in label order.
304 fn by_phase(&self) -> [(PixCyclePhase, u32); 4] {
305 [
306 (PixCyclePhase::AwaitingCommitment, self.awaiting_commitment),
307 (PixCyclePhase::AwaitingDeposit, self.awaiting_deposit),
308 (PixCyclePhase::Recovering, self.recovering),
309 (PixCyclePhase::PaidTail, self.paid_tail),
310 ]
311 }
312}
313
314/// Cycle lifecycle transitions latched by the supervisor since the last publish.
315///
316/// The supervisor is a pure state machine and cannot emit a metric, so it latches the edge and the
317/// worker drains it — the same arrangement as
318/// [`take_fill_stall`](super::supervisor::SessionPixSupervisor::take_fill_stall).
319#[derive(Clone, Debug, Default, PartialEq, Eq)]
320pub struct PixTurnEvents {
321 /// Indices allocated into an `SsaRequest`.
322 pub requested: u32,
323 /// Commitments verified.
324 pub committed: u32,
325 /// Deposits confirmed.
326 pub funded: u32,
327 /// Cycles fully reconstructed.
328 pub recovered: u32,
329 /// Cycles retired without recovering.
330 pub failed: u32,
331 /// Newly accepted shares that advanced reconstruction.
332 pub useful_shares: u64,
333 /// Newly accepted shares that did not — the negotiated surplus, and duplicates.
334 pub surplus_shares: u64,
335 /// Deposit value confirmed this turn, in µHOPR. Recovered value is read off
336 /// [`finalized`](Self::finalized) instead, so the two follow the scope rules of the cycle
337 /// counters they mirror.
338 pub deposit_confirmed_uhopr: u64,
339 /// Phases that *ended* this turn, and how long each lasted in milliseconds.
340 ///
341 /// Milliseconds rather than an `f64` of seconds so this struct keeps its `Eq`; the conversion
342 /// happens at the instrument.
343 pub phase_durations: Vec<(PixCyclePhase, u64)>,
344 /// Cycles that reached a terminal state and have a coverage summary to observe.
345 ///
346 /// A `Vec` rather than a running total because each entry becomes one observation in three
347 /// histograms, and a histogram cannot be fed a sum. Bounded by the cycles one supervisor turn
348 /// can finalize, which is at most a batch.
349 pub finalized: Vec<PixCycleSummary>,
350}
351
352/// What one cycle cost and recovered, observed once when it leaves the accounting front.
353///
354/// Three histograms rather than three gauges, and observed at finalization rather than sampled,
355/// because the question is about the distribution across cycles: an Exit whose median cycle
356/// recovers fully but whose tail does not is in a different position from one where every cycle
357/// half-recovers, and an average hides which.
358#[derive(Clone, Copy, Debug, PartialEq, Eq)]
359pub struct PixCycleSummary {
360 /// How the cycle ended.
361 pub outcome: PixCycleOutcome,
362 /// Packets this Session's gate served while this cycle held the accounting front.
363 pub egress_packets: u64,
364 /// Shares accepted for it, useful or not.
365 pub accepted_shares: u64,
366 /// Of those, the ones that advanced reconstruction.
367 pub useful_shares: u64,
368 /// Useful shares that would have constituted full recovery.
369 pub target_useful_shares: u64,
370 /// What the Entry deposited for it, in µHOPR. Zero for a cycle that never funded.
371 pub deposit_uhopr: u64,
372}
373
374impl PixCycleSummary {
375 /// Accepted shares as a fraction of the cycle's payment target.
376 ///
377 /// Can exceed one, and that is meaningful rather than a defect: a conforming Entry emits
378 /// `threshold + surplus` shares per polynomial, so a fully served cycle accepts more shares than
379 /// it needed useful ones. A fraction far *below* one on a recovered cycle would be the anomaly.
380 pub fn accepted_fraction(&self) -> Option<f64> {
381 (self.target_useful_shares > 0).then(|| self.accepted_shares as f64 / self.target_useful_shares as f64)
382 }
383
384 /// Useful shares as a fraction of the payment target — how far recovery actually got.
385 ///
386 /// Exactly one for a recovered cycle by construction, which is why the histogram is labelled by
387 /// outcome: without that split the recovered population would swamp the failed one, and the
388 /// failed one is the distribution an operator wants.
389 pub fn useful_fraction(&self) -> Option<f64> {
390 (self.target_useful_shares > 0).then(|| self.useful_shares as f64 / self.target_useful_shares as f64)
391 }
392}
393
394/// How a cycle's accounting ended.
395#[derive(Clone, Copy, Debug, PartialEq, Eq, strum::Display)]
396#[strum(serialize_all = "snake_case")]
397pub enum PixCycleOutcome {
398 /// It reconstructed fully.
399 Recovered,
400 /// It did not.
401 Failed,
402}
403
404/// Which kind of accepted share a delta was.
405#[derive(Clone, Copy, Debug, PartialEq, Eq, strum::Display)]
406#[strum(serialize_all = "snake_case")]
407pub enum PixShareKind {
408 /// Advanced reconstruction: the payment counter.
409 Useful,
410 /// Did not — the negotiated surplus a conforming Entry sends, and duplicates. Evidence the
411 /// Entry is still serving, which is what the gate and the idle deadline are asking about.
412 Surplus,
413}
414
415impl PixTurnEvents {
416 /// True when nothing happened, so the publish can skip the counters entirely.
417 pub fn is_empty(&self) -> bool {
418 *self == Self::default()
419 }
420
421 /// The non-zero counts, in label order.
422 fn counts(&self) -> [(PixCycleEvent, u32); 5] {
423 [
424 (PixCycleEvent::Requested, self.requested),
425 (PixCycleEvent::Committed, self.committed),
426 (PixCycleEvent::Funded, self.funded),
427 (PixCycleEvent::Recovered, self.recovered),
428 (PixCycleEvent::Failed, self.failed),
429 ]
430 }
431
432 /// The share deltas, in label order.
433 fn shares(&self) -> [(PixShareKind, u64); 2] {
434 [
435 (PixShareKind::Useful, self.useful_shares),
436 (PixShareKind::Surplus, self.surplus_shares),
437 ]
438 }
439}
440
441/// How much of a latched batch a publish may emit.
442///
443/// The worker drains [`PixTurnEvents`] from the supervisor and *then* calls
444/// [`publish`](PixSessionTelemetry::publish), so a `release` from `close_session` — which runs on
445/// the manager's task, not the worker's — can land between the two. The batch is in the worker's
446/// hand at that point and the supervisor's latch is already empty, so whatever the publish declines
447/// to emit is gone for good. This decides what "declines to emit" means.
448#[derive(Clone, Copy, Debug, PartialEq, Eq)]
449enum EventScope {
450 /// All of it. The census is live and moves with the batch, so the two agree by construction.
451 Everything,
452 /// Only the parts nothing reads back.
453 ///
454 /// `release` has already charged this Session's outstanding cycles from its last census, so
455 /// anything that speaks about *which* cycles ended and how must be left to it or the same cycle
456 /// is accounted twice. That rules out the [census-coupled](PixCycleEvent::is_census_coupled)
457 /// counts and the per-cycle summaries, whose outcome would otherwise be free to contradict the
458 /// count that retired the cycle.
459 ///
460 /// What survives is everything that is only ever a rate: `committed` and `funded`, which are
461 /// edges between census buckets rather than entries or exits, and the share arrivals. Dropping
462 /// those would understate throughput at teardown for nothing — no gauge and no invariant reads
463 /// them, so there is no second copy for them to disagree with.
464 EdgesOnly,
465}
466
467// ---------------------------------------------------------------------------
468// PixSessionTelemetry
469// ---------------------------------------------------------------------------
470
471/// One PIX Session's share of the node-level aggregates, and the bookkeeping that keeps it exact.
472///
473/// Created with its supervisor worker and carried on
474/// [`SessionPixSupervisorHandle`](super::worker::SessionPixSupervisorHandle), so it reaches both the
475/// worker (which publishes) and `close_session` (which releases) without a new `SessionSlot` field.
476///
477/// # Why `release` is explicit rather than only `Drop`
478///
479/// The same reason `CycleBudgetReservation` gives: a `SessionSlot` lives in a `moka` cache that
480/// drops its own clone during a later maintenance pass, and the supervisor worker is spawned
481/// detached and exits only when the last `cmd_tx` goes with it. A purely refcount-driven release
482/// would therefore zero these gauges at an unpredictable time, and until then the node would report
483/// Sessions and cycles that no longer exist. The flag is what lets both the explicit call and `Drop`
484/// run without decrementing twice.
485pub struct PixSessionTelemetry {
486 /// The gate this Session's egress passes through, read for its served split.
487 ///
488 /// Held rather than passed in, because the two callers do not both have it at the moment they
489 /// need it: `release` runs from `close_session`, which does have the gate, but `Drop` runs from
490 /// wherever the last `Arc` happens to go.
491 gate: Arc<ServiceGate>,
492 state: parking_lot::Mutex<Published>,
493 released: AtomicBool,
494}
495
496/// What this handle has already accounted for.
497#[derive(Clone, Copy, Debug, Default)]
498struct Published {
499 /// The last census, or `None` before the first publish and after a release.
500 census: Option<PixSessionSnapshot>,
501 /// The gate's `(predeposit, funded)` split as of the last flush, so the next one emits the
502 /// difference. Cumulative counters, unlike the census, are never returned on release.
503 egress: (u64, u64),
504}
505
506impl PixSessionTelemetry {
507 /// A handle that has published nothing yet, reading egress from `gate`.
508 pub fn new(gate: Arc<ServiceGate>) -> Self {
509 Self {
510 gate,
511 state: parking_lot::Mutex::new(Published::default()),
512 released: AtomicBool::new(false),
513 }
514 }
515
516 /// Publishes the difference between `snapshot` and what was last published, and drains `events`.
517 ///
518 /// The census half is a no-op once [`release`](Self::release) has run, so a worker still
519 /// draining its command channel after its Session was torn down cannot re-inflate a gauge that
520 /// has already been returned to zero. The events are a different matter: the worker took them
521 /// out of the supervisor's latch before calling this, so they exist nowhere else, and a
522 /// released handle emits what it safely can of them rather than dropping the batch on the floor.
523 /// [`EventScope`] draws that line.
524 pub fn publish(&self, snapshot: PixSessionSnapshot, events: PixTurnEvents) {
525 if self.released.load(Ordering::Acquire) {
526 record_events(events, EventScope::EdgesOnly);
527 return;
528 }
529
530 let mut state = self.state.lock();
531 // Re-checked under the lock: `release` takes it to apply its own decrements, so without this
532 // a publish that passed the check above could otherwise be applied after them.
533 if self.released.load(Ordering::Acquire) {
534 drop(state);
535 record_events(events, EventScope::EdgesOnly);
536 return;
537 }
538
539 apply_delta(state.census.as_ref(), Some(&snapshot));
540 state.census = Some(snapshot);
541 let egress = self.flush_egress(&mut state);
542 drop(state);
543
544 record_egress(egress);
545 record_events(events, EventScope::Everything);
546 }
547
548 /// Returns every gauge this Session holds to zero, and flushes its last egress. Idempotent.
549 ///
550 /// Cycles still live in the last published census are counted as
551 /// [`Failed`](PixCycleEvent::Failed), which is what keeps `requested = recovered + failed` exact
552 /// for a Session that ends with cycles in flight. `on_unverifiable_shares` does exactly that on
553 /// purpose — it closes the Session outright rather than retiring each cycle — and so does any
554 /// close that arrives from outside the supervisor.
555 ///
556 /// The egress flush is why `close_session` poisons the gate before calling this: a poisoned gate
557 /// admits nothing further, so the split read here is final rather than a moving target.
558 ///
559 /// If a publish is racing this, one census applies and the other is suppressed, and either
560 /// ordering counts each live cycle exactly once: a publish that wins first shrinks the census
561 /// this then charges, and a publish that loses leaves the older census for this to charge
562 /// instead. What the losing publish carried is not thrown away with it — it emits the part of
563 /// its batch this does not account for, which [`EventScope`] defines.
564 pub fn release(&self) {
565 if self.released.swap(true, Ordering::AcqRel) {
566 return;
567 }
568
569 let mut state = self.state.lock();
570 let last = state.census.take();
571 apply_delta(last.as_ref(), None);
572 let egress = self.flush_egress(&mut state);
573 drop(state);
574
575 record_egress(egress);
576
577 let live = last.map(|census| census.live_cycles()).unwrap_or(0);
578 if live > 0 {
579 record_events(
580 PixTurnEvents {
581 failed: live,
582 ..Default::default()
583 },
584 EventScope::Everything,
585 );
586 }
587 }
588
589 /// Packets admitted since the last flush, and advances the watermark.
590 ///
591 /// Returned rather than emitted so the caller can release the lock first: these are cumulative
592 /// counters, and holding this Session's lock across a global instrument would serialize every
593 /// other Session's publish behind it for no benefit.
594 fn flush_egress(&self, state: &mut Published) -> (u64, u64) {
595 let (predeposit, funded) = self.gate.served_split();
596 // `served_split`'s two loads are not atomic with respect to each other, so a predeposit
597 // permit landing between them is seen by one and not the other. Its publication order rules
598 // out the direction that would misattribute the packet; what remains is a `served_predeposit`
599 // from after the permit against a `served` from before it, which makes the *derived* funded
600 // figure come out one lower than the truth.
601 //
602 // Saturating the subtraction keeps that from wrapping a counter to 2^64. Clamping the stored
603 // watermark is what keeps it from double-counting: without the `max`, the regressed value
604 // would be stored, and every funded packet between it and the real figure would be reported
605 // a second time when the next flush crossed that ground again. The reading corrects itself
606 // on the following flush either way — this only ensures the correction is not paid for
607 // twice.
608 let delta = (
609 predeposit.saturating_sub(state.egress.0),
610 funded.saturating_sub(state.egress.1),
611 );
612 state.egress = (predeposit.max(state.egress.0), funded.max(state.egress.1));
613 delta
614 }
615
616 /// The snapshot this handle last published, for tests that assert its bookkeeping without
617 /// reading the process-wide metric registry.
618 #[cfg(test)]
619 pub fn published(&self) -> Option<PixSessionSnapshot> {
620 self.state.lock().census
621 }
622}
623
624impl Drop for PixSessionTelemetry {
625 fn drop(&mut self) {
626 self.release();
627 }
628}
629
630// ---------------------------------------------------------------------------
631// Emission
632// ---------------------------------------------------------------------------
633
634/// Moves the live-set gauges from `from` to `to`, either of which may be "no Session at all".
635///
636/// Signed on purpose. A gauge is only ever nudged by the difference between two censuses, so an
637/// arithmetic slip cannot make it drift: the next publish computes its delta from the same stored
638/// snapshot and lands on the correct absolute value regardless.
639fn apply_delta(from: Option<&PixSessionSnapshot>, to: Option<&PixSessionSnapshot>) {
640 for mode in [PixGateMode::Predeposit, PixGateMode::Funded] {
641 let delta = sessions_in(to, mode) - sessions_in(from, mode);
642 if delta != 0 {
643 emit_sessions_active(mode, delta);
644 }
645 }
646
647 let before = from.copied().unwrap_or_default().by_phase();
648 let after = to.copied().unwrap_or_default().by_phase();
649 for ((phase, after), (_, before)) in after.into_iter().zip(before) {
650 let delta = i64::from(after) - i64::from(before);
651 if delta != 0 {
652 emit_cycles_active(phase, delta);
653 }
654 }
655
656 // Widened before subtracting, because both sides are `u64` and either may be the larger: a
657 // Session whose front rotates goes from a spent allowance to a restored one in a single turn.
658 let exposure = i128::from(to.map(|s| s.predeposit_exposure_packets).unwrap_or(0))
659 - i128::from(from.map(|s| s.predeposit_exposure_packets).unwrap_or(0));
660 if exposure != 0 {
661 emit_predeposit_exposure(exposure);
662 }
663
664 let stalled =
665 i64::from(to.map(|s| s.stalled_share).unwrap_or(0)) - i64::from(from.map(|s| s.stalled_share).unwrap_or(0));
666 if stalled != 0 {
667 emit_sessions_stalled(PixStallCause::ShareStarved, stalled);
668 }
669}
670
671/// 1 if `snapshot` is a live Session in `mode`, else 0.
672fn sessions_in(snapshot: Option<&PixSessionSnapshot>, mode: PixGateMode) -> i64 {
673 i64::from(snapshot.is_some_and(|s| s.gate_mode == mode))
674}
675
676fn record_events(events: PixTurnEvents, scope: EventScope) {
677 if events.is_empty() {
678 return;
679 }
680 for (event, count) in events.counts() {
681 if count > 0 && (scope == EventScope::Everything || !event.is_census_coupled()) {
682 emit_cycles_total(event, u64::from(count));
683 }
684 }
685 // Share arrivals are counted whatever the scope. Nothing reads them back — no gauge, no
686 // invariant, no other series — so a released handle dropping them would be pure loss, and this
687 // is the only copy in existence by the time it gets here.
688 for (kind, count) in events.shares() {
689 if count > 0 {
690 emit_shares_total(kind, count);
691 }
692 }
693 // Confirmed deposit value pairs with `Funded`, which is not census-coupled, so it is emitted in
694 // both scopes for the same reason that count is.
695 if events.deposit_confirmed_uhopr > 0 {
696 emit_deposits_confirmed(events.deposit_confirmed_uhopr);
697 }
698 // Phase durations are emitted whatever the scope, on the same argument as share arrivals: each
699 // states how long a phase lasted and asserts nothing about how the cycle ended, so none of them
700 // can contradict a census charge `release` has already made.
701 for (phase, millis) in events.phase_durations.iter().copied() {
702 emit_cycle_phase_duration(phase, millis as f64 / 1_000.0);
703 }
704 // The summaries are not, because each carries an outcome that must agree with the count that
705 // retired the same cycle. Under `EdgesOnly` that count came from `release`'s census charge —
706 // `failed`, uniformly — so observing a summary here could assert `recovered` for a cycle the
707 // counters have already given up on. `sum(cycle_summaries) <= recovered + failed` is documented
708 // as the expected relation precisely because a Session can end without a verdict per cycle.
709 //
710 // Recovered deposit value rides with them rather than being latched separately, so it inherits
711 // that rule from `Recovered`, which is census-coupled. The consequence is deliberate: a Session
712 // released mid-flight under-reports recovered value while still reporting confirmed, so the gap
713 // between the two counters over-states stranded value rather than hiding it.
714 if scope == EventScope::Everything {
715 let recovered_uhopr = events
716 .finalized
717 .iter()
718 .filter(|summary| summary.outcome == PixCycleOutcome::Recovered)
719 .fold(0u64, |total, summary| total.saturating_add(summary.deposit_uhopr));
720 if recovered_uhopr > 0 {
721 emit_deposits_recovered(recovered_uhopr);
722 }
723 for summary in events.finalized {
724 emit_cycle_summary(summary);
725 }
726 }
727}
728
729/// Counts packets admitted since the last flush, by what paid for them.
730fn record_egress((predeposit, funded): (u64, u64)) {
731 if predeposit > 0 {
732 emit_egress_packets(PixGateMode::Predeposit, predeposit);
733 }
734 if funded > 0 {
735 emit_egress_packets(PixGateMode::Funded, funded);
736 }
737}
738
739// The four shims below are the whole of this module's dependence on the `telemetry` feature. Keeping
740// the `cfg` here rather than around the arithmetic above means that arithmetic is compiled, linted
741// and tested in both configurations — with the feature off and outside a test build, each of these
742// is an empty function the optimizer removes along with the loop that calls it.
743
744fn emit_sessions_active(mode: PixGateMode, delta: i64) {
745 #[cfg(feature = "telemetry")]
746 crate::telemetry::pix::add_sessions_active(mode, delta);
747 #[cfg(test)]
748 probe::add(&format!("sessions_active/{mode}"), delta);
749 #[cfg(not(any(feature = "telemetry", test)))]
750 let _ = (mode, delta);
751}
752
753fn emit_cycles_active(phase: PixCyclePhase, delta: i64) {
754 #[cfg(feature = "telemetry")]
755 crate::telemetry::pix::add_cycles_active(phase, delta);
756 #[cfg(test)]
757 probe::add(&format!("cycles_active/{phase}"), delta);
758 #[cfg(not(any(feature = "telemetry", test)))]
759 let _ = (phase, delta);
760}
761
762fn emit_predeposit_exposure(delta: i128) {
763 #[cfg(feature = "telemetry")]
764 crate::telemetry::pix::add_predeposit_exposure(delta);
765 #[cfg(test)]
766 probe::add("predeposit_exposure", delta as i64);
767 #[cfg(not(any(feature = "telemetry", test)))]
768 let _ = delta;
769}
770
771fn emit_cycles_total(event: PixCycleEvent, count: u64) {
772 #[cfg(feature = "telemetry")]
773 crate::telemetry::pix::add_cycles_total(event, count);
774 #[cfg(test)]
775 probe::add(&format!("cycles_total/{event}"), count as i64);
776 #[cfg(not(any(feature = "telemetry", test)))]
777 let _ = (event, count);
778}
779
780fn emit_egress_packets(mode: PixGateMode, count: u64) {
781 #[cfg(feature = "telemetry")]
782 crate::telemetry::pix::add_egress_packets(mode, count);
783 #[cfg(test)]
784 probe::add(&format!("egress_packets/{mode}"), count as i64);
785 #[cfg(not(any(feature = "telemetry", test)))]
786 let _ = (mode, count);
787}
788
789fn emit_gate_block(reason: PixGateBlock) {
790 #[cfg(feature = "telemetry")]
791 crate::telemetry::pix::record_gate_block(reason);
792 #[cfg(test)]
793 probe::add(&format!("gate_blocks/{reason}"), 1);
794 #[cfg(not(any(feature = "telemetry", test)))]
795 let _ = reason;
796}
797
798fn emit_shares_total(kind: PixShareKind, count: u64) {
799 #[cfg(feature = "telemetry")]
800 crate::telemetry::pix::add_shares_total(kind, count);
801 #[cfg(test)]
802 probe::add(&format!("shares_total/{kind}"), count as i64);
803 #[cfg(not(any(feature = "telemetry", test)))]
804 let _ = (kind, count);
805}
806
807/// `pub(crate)` unlike its siblings, because this gauge has a second producer: the manager's
808/// `PixFillControl` raises [`PixStallCause::SurbStarved`], which the supervisor cannot see. Routing
809/// it here rather than straight at the instrument keeps the metric to one emission point and one
810/// `cfg`, and lets both causes be asserted through the `probe` below.
811pub(crate) fn emit_sessions_stalled(cause: PixStallCause, delta: i64) {
812 #[cfg(feature = "telemetry")]
813 crate::telemetry::pix::add_sessions_stalled(cause, delta);
814 #[cfg(test)]
815 probe::add(&format!("sessions_stalled/{cause}"), delta);
816 #[cfg(not(any(feature = "telemetry", test)))]
817 let _ = (cause, delta);
818}
819
820fn emit_cycle_phase_duration(phase: PixCyclePhase, seconds: f64) {
821 #[cfg(feature = "telemetry")]
822 crate::telemetry::pix::record_cycle_phase_duration(phase, seconds);
823 // One observation rather than the value, for the reason `emit_cycle_summary` gives: what a test
824 // can assert without depending on bucket boundaries is that a phase is timed exactly once.
825 #[cfg(test)]
826 probe::add(&format!("cycle_phase_seconds/{phase}"), 1);
827 #[cfg(not(feature = "telemetry"))]
828 let _ = seconds;
829 #[cfg(not(any(feature = "telemetry", test)))]
830 let _ = phase;
831}
832
833fn emit_deposits_confirmed(uhopr: u64) {
834 #[cfg(feature = "telemetry")]
835 crate::telemetry::pix::add_deposits_confirmed(uhopr);
836 #[cfg(test)]
837 probe::add("deposits_confirmed_uhopr", uhopr as i64);
838 #[cfg(not(any(feature = "telemetry", test)))]
839 let _ = uhopr;
840}
841
842fn emit_deposits_recovered(uhopr: u64) {
843 #[cfg(feature = "telemetry")]
844 crate::telemetry::pix::add_deposits_recovered(uhopr);
845 #[cfg(test)]
846 probe::add("deposits_recovered_uhopr", uhopr as i64);
847 #[cfg(not(any(feature = "telemetry", test)))]
848 let _ = uhopr;
849}
850
851fn emit_cycle_summary(summary: PixCycleSummary) {
852 // Derived here rather than inside the instrument module so the two ratios are computed — and so
853 // their `None` case is decided — in the half of this crate that is always compiled and always
854 // tested. `None` means the ratio is undefined for these dimensions, and is skipped rather than
855 // observed as zero: a zero would be read as a cycle that recovered nothing.
856 let useful_fraction = summary.useful_fraction();
857 let accepted_fraction = summary.accepted_fraction();
858
859 #[cfg(feature = "telemetry")]
860 crate::telemetry::pix::record_cycle_summary(
861 summary.outcome,
862 summary.egress_packets,
863 useful_fraction,
864 accepted_fraction,
865 );
866 #[cfg(not(feature = "telemetry"))]
867 let _ = (useful_fraction, accepted_fraction);
868
869 // Recorded as one observation rather than as the values themselves: the shape a test can assert
870 // without depending on bucket boundaries is that a cycle is summarized exactly once, on the
871 // right outcome. The values are asserted against the summary directly.
872 #[cfg(test)]
873 probe::add(&format!("cycle_summaries/{}", summary.outcome), 1);
874 #[cfg(not(any(feature = "telemetry", test)))]
875 let _ = summary;
876}
877
878fn emit_gate_block_duration(reason: GateBlockReason, seconds: f64) {
879 #[cfg(feature = "telemetry")]
880 crate::telemetry::pix::record_gate_block_duration(reason, seconds);
881 // The one argument the probe does not consume: a test can assert that an episode was closed,
882 // but not how long a wall clock said it took.
883 #[cfg(not(feature = "telemetry"))]
884 let _ = seconds;
885
886 #[cfg(test)]
887 probe::add(&format!("gate_block_observations/{reason}"), 1);
888 #[cfg(not(any(feature = "telemetry", test)))]
889 let _ = reason;
890}
891
892/// An in-process mirror of the aggregates, so tests can assert what was emitted.
893///
894/// Exists because the instruments themselves are behind the `telemetry` feature, which no CI job
895/// enables — a test written against them would never run. What it makes testable is the part that
896/// most needs it: whether a gauge comes back to zero. That is invisible in production until an
897/// operator compares the metric against a reality they have no access to.
898///
899/// Not a general mocking layer. It mirrors deltas exactly as the shims above pass them, so a test
900/// asserting zero here is asserting that the same sequence of calls reached the real instrument.
901#[cfg(test)]
902pub(crate) mod probe {
903 use std::{cell::RefCell, collections::BTreeMap};
904
905 thread_local! {
906 static STATE: RefCell<BTreeMap<String, i64>> = const { RefCell::new(BTreeMap::new()) };
907 }
908
909 /// Clears the probe for this thread.
910 ///
911 /// Thread-local rather than global, and that is what makes these tests independent rather than
912 /// merely serialized. Every `publish` and `release` anywhere in the crate reaches `add` through
913 /// the emission shims, so a shared map would let a test that never reads the probe still
914 /// perturb one that asserts an exact value — and `cargo test` runs tests concurrently on
915 /// threads, so a lock would have to be taken by every test rather than only the asserting ones.
916 /// A thread-local needs no discipline from either.
917 ///
918 /// The consequence to know about: a test whose emissions happen on a *different* thread — a
919 /// multi-thread tokio runtime, or a `std::thread::spawn` — will not see them here. Every probe
920 /// test drives the code directly or through a current-thread runtime, which is the same thread
921 /// throughout. A future test that needs otherwise should assert through `gather_all_metrics`
922 /// under `--features telemetry` instead.
923 pub(crate) fn reset() {
924 STATE.with_borrow_mut(|state| state.clear());
925 }
926
927 pub(crate) fn add(series: &str, delta: i64) {
928 STATE.with_borrow_mut(|state| *state.entry(series.to_string()).or_default() += delta);
929 }
930
931 pub(crate) fn get(series: &str) -> i64 {
932 STATE.with_borrow(|state| state.get(series).copied().unwrap_or_default())
933 }
934
935 /// Every series that is not currently zero, for an assertion that wants to name the offenders.
936 pub(crate) fn non_zero() -> BTreeMap<String, i64> {
937 STATE.with_borrow(|state| {
938 state
939 .iter()
940 .filter(|(_, value)| **value != 0)
941 .map(|(name, value)| (name.clone(), *value))
942 .collect()
943 })
944 }
945}
946
947#[cfg(test)]
948mod tests {
949 use super::{super::gate::GateVerdict, *};
950
951 /// A handle over a gate that serves nothing, for tests about the census alone.
952 ///
953 /// `ServiceGate::new(0, 0)` is a strict-prepay gate with a zero ceiling: it admits nothing on
954 /// either branch, so `served_split` stays `(0, 0)` and the egress flush contributes nothing to
955 /// what these tests assert.
956 fn handle() -> PixSessionTelemetry {
957 PixSessionTelemetry::new(ServiceGate::new(0, 0))
958 }
959
960 fn snapshot(mode: PixGateMode, recovering: u32) -> PixSessionSnapshot {
961 PixSessionSnapshot {
962 gate_mode: mode,
963 recovering,
964 ..Default::default()
965 }
966 }
967
968 #[test]
969 fn a_fresh_handle_has_published_nothing() {
970 assert_eq!(None, handle().published());
971 }
972
973 /// A Session that dies mid-stall returns its gauge, rather than leaving the count standing.
974 ///
975 /// This is why `share_starved` is a census field and not an edge the worker latches: the worker
976 /// loop has five separate `return` points, and `release` already handles the whole problem for
977 /// every other gauge.
978 #[test]
979 fn a_session_that_dies_while_stalled_returns_its_stall_gauge() {
980 let telemetry = handle();
981 probe::reset();
982
983 let stalled = PixSessionSnapshot {
984 stalled_share: 1,
985 ..snapshot(PixGateMode::Funded, 1)
986 };
987 telemetry.publish(stalled, PixTurnEvents::default());
988 assert_eq!(1, probe::get("sessions_stalled/share_starved"));
989
990 telemetry.release();
991 assert_eq!(
992 0,
993 probe::get("sessions_stalled/share_starved"),
994 "release must return the stall gauge like every other census field"
995 );
996 }
997
998 /// A stall that ends while the Session lives is returned too, without waiting for teardown.
999 #[test]
1000 fn a_stall_that_clears_returns_its_gauge() {
1001 let telemetry = handle();
1002 probe::reset();
1003
1004 telemetry.publish(
1005 PixSessionSnapshot {
1006 stalled_share: 1,
1007 ..snapshot(PixGateMode::Funded, 1)
1008 },
1009 PixTurnEvents::default(),
1010 );
1011 telemetry.publish(snapshot(PixGateMode::Funded, 1), PixTurnEvents::default());
1012
1013 assert_eq!(0, probe::get("sessions_stalled/share_starved"));
1014 }
1015
1016 /// Only a cycle that recovered books recovered value, so the gap between the two counters is
1017 /// what the Exit was paid for and did not unlock.
1018 #[test]
1019 fn a_failed_cycle_books_its_deposit_as_confirmed_but_never_as_recovered() {
1020 let telemetry = handle();
1021 probe::reset();
1022
1023 let summary = |outcome, deposit_uhopr| PixCycleSummary {
1024 outcome,
1025 egress_packets: 0,
1026 accepted_shares: 5,
1027 useful_shares: 5,
1028 target_useful_shares: 10,
1029 deposit_uhopr,
1030 };
1031
1032 telemetry.publish(
1033 snapshot(PixGateMode::Funded, 0),
1034 PixTurnEvents {
1035 funded: 2,
1036 recovered: 1,
1037 failed: 1,
1038 deposit_confirmed_uhopr: 900_000,
1039 finalized: vec![
1040 summary(PixCycleOutcome::Recovered, 400_000),
1041 summary(PixCycleOutcome::Failed, 500_000),
1042 ],
1043 ..Default::default()
1044 },
1045 );
1046
1047 assert_eq!(900_000, probe::get("deposits_confirmed_uhopr"));
1048 assert_eq!(
1049 400_000,
1050 probe::get("deposits_recovered_uhopr"),
1051 "the failed cycle's 500_000 is the stranded half and must not be counted as recovered"
1052 );
1053 }
1054
1055 #[test]
1056 fn publish_stores_the_latest_census() {
1057 let telemetry = handle();
1058
1059 telemetry.publish(snapshot(PixGateMode::Predeposit, 0), PixTurnEvents::default());
1060 assert_eq!(Some(snapshot(PixGateMode::Predeposit, 0)), telemetry.published());
1061
1062 let funded = snapshot(PixGateMode::Funded, 2);
1063 telemetry.publish(funded, PixTurnEvents::default());
1064 assert_eq!(Some(funded), telemetry.published());
1065 }
1066
1067 #[test]
1068 fn release_clears_the_census_and_is_idempotent() {
1069 let telemetry = handle();
1070 telemetry.publish(snapshot(PixGateMode::Funded, 3), PixTurnEvents::default());
1071
1072 telemetry.release();
1073 assert_eq!(None, telemetry.published());
1074
1075 // A second release must not decrement anything a second time; there is nothing left to
1076 // assert on this handle, so the guarantee is that it neither panics nor resurrects state.
1077 telemetry.release();
1078 assert_eq!(None, telemetry.published());
1079 }
1080
1081 #[test]
1082 fn publishing_after_release_is_ignored() {
1083 let telemetry = handle();
1084 telemetry.publish(snapshot(PixGateMode::Funded, 1), PixTurnEvents::default());
1085 telemetry.release();
1086
1087 telemetry.publish(snapshot(PixGateMode::Funded, 9), PixTurnEvents::default());
1088 assert_eq!(
1089 None,
1090 telemetry.published(),
1091 "a worker still draining its channel must not re-inflate a released Session"
1092 );
1093 }
1094
1095 /// A publish that loses the race to `release` keeps the counts `release` cannot rebuild.
1096 ///
1097 /// The worker takes the batch out of the supervisor's latch *before* it calls `publish`, so a
1098 /// `release` from `close_session` landing in between leaves the worker holding the only copy.
1099 /// Dropping the batch whole would lose the phase-transition counts for good; emitting it whole
1100 /// would let `release`'s census charge and the batch's own terms account for the same cycle
1101 /// twice.
1102 #[test]
1103 fn a_publish_that_loses_to_release_still_books_the_uncoupled_counts() {
1104 let telemetry = handle();
1105 telemetry.publish(snapshot(PixGateMode::Funded, 1), PixTurnEvents::default());
1106 telemetry.release();
1107 probe::reset();
1108
1109 telemetry.publish(
1110 snapshot(PixGateMode::Funded, 1),
1111 PixTurnEvents {
1112 requested: 3,
1113 committed: 2,
1114 funded: 1,
1115 recovered: 1,
1116 failed: 1,
1117 useful_shares: 7,
1118 surplus_shares: 4,
1119 deposit_confirmed_uhopr: 500_000,
1120 phase_durations: vec![(PixCyclePhase::Recovering, 90_000)],
1121 finalized: vec![PixCycleSummary {
1122 outcome: PixCycleOutcome::Recovered,
1123 egress_packets: 80,
1124 accepted_shares: 12,
1125 useful_shares: 10,
1126 target_useful_shares: 10,
1127 deposit_uhopr: 400_000,
1128 }],
1129 },
1130 );
1131
1132 assert_eq!(2, probe::get("cycles_total/committed"));
1133 assert_eq!(1, probe::get("cycles_total/funded"));
1134 assert_eq!(
1135 7,
1136 probe::get("shares_total/useful"),
1137 "a share arrival is only ever a rate"
1138 );
1139 assert_eq!(4, probe::get("shares_total/surplus"));
1140 for coupled in ["requested", "recovered", "failed"] {
1141 assert_eq!(
1142 0,
1143 probe::get(&format!("cycles_total/{coupled}")),
1144 "{coupled} is settled against the census, which `release` has already charged"
1145 );
1146 }
1147 assert_eq!(
1148 0,
1149 probe::get("cycle_summaries/recovered"),
1150 "a summary asserting `recovered` must not outlive the count that gave the cycle up"
1151 );
1152 assert_eq!(
1153 500_000,
1154 probe::get("deposits_confirmed_uhopr"),
1155 "confirmed value pairs with `funded`, which this scope still books"
1156 );
1157 assert_eq!(
1158 0,
1159 probe::get("deposits_recovered_uhopr"),
1160 "recovered value rides the summaries, so it is dropped with them — the gap between the two counters \
1161 over-states stranded value rather than hiding it"
1162 );
1163 assert_eq!(
1164 1,
1165 probe::get("cycle_phase_seconds/recovering"),
1166 "a phase duration asserts nothing about how the cycle ended, so this scope still books it"
1167 );
1168 assert_eq!(
1169 None,
1170 telemetry.published(),
1171 "a worker still draining its channel must not re-inflate a released Session"
1172 );
1173 }
1174
1175 /// `requested = recovered + failed + live` survives `release` beating the final publish.
1176 ///
1177 /// This is the property the split in [`EventScope`] exists to protect: the losing batch's
1178 /// `recovered` is not counted, because `release` has already charged that same cycle from the
1179 /// census it was still in.
1180 #[test]
1181 fn the_lifecycle_invariant_holds_when_release_beats_the_final_publish() {
1182 probe::reset();
1183 let telemetry = handle();
1184 telemetry.publish(
1185 snapshot(PixGateMode::Funded, 2),
1186 PixTurnEvents {
1187 requested: 2,
1188 ..Default::default()
1189 },
1190 );
1191
1192 // The worker has drained a turn in which one of the two recovered — and loses the race.
1193 telemetry.release();
1194 telemetry.publish(
1195 PixSessionSnapshot {
1196 gate_mode: PixGateMode::Funded,
1197 recovering: 1,
1198 paid_tail: 1,
1199 ..Default::default()
1200 },
1201 PixTurnEvents {
1202 committed: 1,
1203 recovered: 1,
1204 ..Default::default()
1205 },
1206 );
1207
1208 let requested = probe::get("cycles_total/requested");
1209 let recovered = probe::get("cycles_total/recovered");
1210 let failed = probe::get("cycles_total/failed");
1211 assert_eq!(2, requested);
1212 assert_eq!(
1213 requested,
1214 recovered + failed,
1215 "every requested cycle must be accounted exactly once, whichever side of the race it landed on \
1216 (recovered={recovered}, failed={failed})"
1217 );
1218 assert_eq!(1, probe::get("cycles_total/committed"), "the edge count still lands");
1219 assert!(
1220 probe::non_zero()
1221 .keys()
1222 .all(|series| !series.starts_with("cycles_active/"))
1223 );
1224 }
1225
1226 #[test]
1227 fn releasing_a_handle_that_published_nothing_is_a_no_op() {
1228 let telemetry = handle();
1229 telemetry.release();
1230 assert_eq!(None, telemetry.published());
1231 }
1232
1233 /// The live-cycle count that `release` charges as `failed` excludes the paid tail.
1234 ///
1235 /// A paid recovered predecessor has already been counted `recovered`; charging it again on the
1236 /// way out would make `requested = recovered + failed` overshoot by one per rollover.
1237 #[test]
1238 fn live_cycles_counts_the_three_unfinished_phases_only() {
1239 let snapshot = PixSessionSnapshot {
1240 gate_mode: PixGateMode::Funded,
1241 awaiting_commitment: 2,
1242 awaiting_deposit: 3,
1243 recovering: 4,
1244 paid_tail: 1,
1245 predeposit_exposure_packets: 17,
1246 stalled_share: 0,
1247 };
1248 assert_eq!(9, snapshot.live_cycles());
1249 }
1250
1251 #[test]
1252 fn label_values_are_lowercase_and_stable() {
1253 assert_eq!("predeposit", PixGateMode::Predeposit.to_string());
1254 assert_eq!("funded", PixGateMode::Funded.to_string());
1255 assert_eq!("awaiting_commitment", PixCyclePhase::AwaitingCommitment.to_string());
1256 assert_eq!("awaiting_deposit", PixCyclePhase::AwaitingDeposit.to_string());
1257 assert_eq!("recovering", PixCyclePhase::Recovering.to_string());
1258 assert_eq!("paid_tail", PixCyclePhase::PaidTail.to_string());
1259 assert_eq!("requested", PixCycleEvent::Requested.to_string());
1260 assert_eq!("failed", PixCycleEvent::Failed.to_string());
1261 assert_eq!(
1262 "live_cycle_capacity",
1263 PixAdmissionRejection::LiveCycleCapacity.to_string()
1264 );
1265 assert_eq!("no_pix_support", PixAdmissionRejection::NoPixSupport.to_string());
1266 }
1267
1268 /// A refusal is only a PIX admission refusal if the peer was asking for PIX.
1269 ///
1270 /// The six call sites all sit on paths that refuse ordinary Sessions too, so without this gate
1271 /// `hopr_pix_admission_rejections_total` would report a PIX problem on a node serving no PIX at
1272 /// all. Exercised here rather than at the instruments because this is the part that is compiled
1273 /// — and therefore tested — whether or not the `telemetry` feature is on.
1274 #[test]
1275 fn only_a_pix_offer_counts_as_a_pix_admission_refusal() {
1276 let pix: crate::Capabilities = crate::Capability::UsePIX | crate::Capability::Segmentation;
1277 let plain: crate::Capabilities = crate::Capability::Segmentation.into();
1278
1279 for reason in [
1280 PixAdmissionRejection::NoPixSupport,
1281 PixAdmissionRejection::UnacceptableParams,
1282 PixAdmissionRejection::TargetPolicy,
1283 PixAdmissionRejection::LiveCycleCapacity,
1284 PixAdmissionRejection::NoSessionSlot,
1285 PixAdmissionRejection::Busy,
1286 ] {
1287 assert!(reason.record_if_pix(pix), "{reason} refused a PIX offer");
1288 assert!(
1289 !reason.record_if_pix(plain),
1290 "{reason} refused an ordinary Session and must not be counted as a PIX refusal"
1291 );
1292 assert!(
1293 !reason.record_if_pix(crate::Capabilities::default()),
1294 "{reason} refused a Session offering nothing at all"
1295 );
1296 }
1297 }
1298
1299 #[test]
1300 fn an_empty_event_latch_is_recognised() {
1301 assert!(PixTurnEvents::default().is_empty());
1302 assert!(
1303 !PixTurnEvents {
1304 requested: 1,
1305 ..Default::default()
1306 }
1307 .is_empty()
1308 );
1309 }
1310
1311 // -----------------------------------------------------------------------
1312 // What actually reaches the aggregates
1313 // -----------------------------------------------------------------------
1314
1315 /// A publish moves each gauge by the difference between two censuses, never by the census.
1316 #[test]
1317 fn successive_publishes_emit_differences_not_totals() {
1318 probe::reset();
1319 let telemetry = handle();
1320
1321 telemetry.publish(
1322 PixSessionSnapshot {
1323 gate_mode: PixGateMode::Predeposit,
1324 awaiting_commitment: 3,
1325 predeposit_exposure_packets: 100,
1326 ..Default::default()
1327 },
1328 PixTurnEvents::default(),
1329 );
1330 assert_eq!(1, probe::get("sessions_active/predeposit"));
1331 assert_eq!(3, probe::get("cycles_active/awaiting_commitment"));
1332 assert_eq!(100, probe::get("predeposit_exposure"));
1333
1334 // Two of the three commitments verify and the third is still waiting; the exposure grows.
1335 telemetry.publish(
1336 PixSessionSnapshot {
1337 gate_mode: PixGateMode::Predeposit,
1338 awaiting_commitment: 1,
1339 awaiting_deposit: 2,
1340 predeposit_exposure_packets: 250,
1341 ..Default::default()
1342 },
1343 PixTurnEvents {
1344 committed: 2,
1345 ..Default::default()
1346 },
1347 );
1348 assert_eq!(1, probe::get("cycles_active/awaiting_commitment"));
1349 assert_eq!(2, probe::get("cycles_active/awaiting_deposit"));
1350 assert_eq!(250, probe::get("predeposit_exposure"), "absolute, not 100 + 250");
1351 assert_eq!(
1352 1,
1353 probe::get("sessions_active/predeposit"),
1354 "a Session that stayed in one bucket must not be counted twice"
1355 );
1356 assert_eq!(2, probe::get("cycles_total/committed"));
1357 }
1358
1359 /// A Session moving between gate modes leaves one bucket and enters the other.
1360 #[test]
1361 fn a_funding_session_moves_between_the_two_buckets() {
1362 probe::reset();
1363 let telemetry = handle();
1364
1365 telemetry.publish(snapshot(PixGateMode::Predeposit, 1), PixTurnEvents::default());
1366 assert_eq!(1, probe::get("sessions_active/predeposit"));
1367 assert_eq!(0, probe::get("sessions_active/funded"));
1368
1369 telemetry.publish(snapshot(PixGateMode::Funded, 1), PixTurnEvents::default());
1370 assert_eq!(0, probe::get("sessions_active/predeposit"), "the old bucket must drain");
1371 assert_eq!(1, probe::get("sessions_active/funded"));
1372
1373 // Rotating back to an unfunded successor moves it back, rather than counting a new Session.
1374 telemetry.publish(snapshot(PixGateMode::Predeposit, 1), PixTurnEvents::default());
1375 assert_eq!(1, probe::get("sessions_active/predeposit"));
1376 assert_eq!(0, probe::get("sessions_active/funded"));
1377 }
1378
1379 /// `release` returns every gauge to zero and charges what was still live.
1380 #[test]
1381 fn release_zeroes_the_gauges_and_charges_the_live_cycles() {
1382 probe::reset();
1383 let telemetry = handle();
1384
1385 telemetry.publish(
1386 PixSessionSnapshot {
1387 gate_mode: PixGateMode::Funded,
1388 awaiting_commitment: 1,
1389 awaiting_deposit: 2,
1390 recovering: 3,
1391 paid_tail: 1,
1392 predeposit_exposure_packets: 64,
1393 stalled_share: 1,
1394 },
1395 PixTurnEvents {
1396 requested: 6,
1397 ..Default::default()
1398 },
1399 );
1400
1401 telemetry.release();
1402
1403 assert_eq!(
1404 std::collections::BTreeMap::from([
1405 ("cycles_total/requested".to_string(), 6),
1406 ("cycles_total/failed".to_string(), 6),
1407 ]),
1408 probe::non_zero(),
1409 "only the cumulative counters may survive a release"
1410 );
1411 assert_eq!(
1412 6,
1413 probe::get("cycles_total/failed"),
1414 "the paid tail has already been counted as recovered and must not be charged again"
1415 );
1416 }
1417
1418 /// A Session that ends with nothing live charges nothing.
1419 #[test]
1420 fn releasing_a_drained_session_charges_no_failure() {
1421 probe::reset();
1422 let telemetry = handle();
1423
1424 telemetry.publish(
1425 PixSessionSnapshot {
1426 gate_mode: PixGateMode::Funded,
1427 paid_tail: 1,
1428 ..Default::default()
1429 },
1430 PixTurnEvents {
1431 requested: 1,
1432 recovered: 1,
1433 ..Default::default()
1434 },
1435 );
1436 telemetry.release();
1437
1438 assert_eq!(0, probe::get("cycles_total/failed"));
1439 assert_eq!(1, probe::get("cycles_total/recovered"));
1440 assert!(probe::non_zero().keys().all(|k| k.starts_with("cycles_total/")));
1441 }
1442
1443 /// Dropping a handle that was never released still returns its gauges.
1444 #[test]
1445 fn drop_is_a_backstop_for_a_handle_nobody_released() {
1446 probe::reset();
1447
1448 {
1449 let telemetry = handle();
1450 telemetry.publish(snapshot(PixGateMode::Funded, 2), PixTurnEvents::default());
1451 assert_eq!(1, probe::get("sessions_active/funded"));
1452 }
1453
1454 assert_eq!(0, probe::get("sessions_active/funded"));
1455 assert_eq!(0, probe::get("cycles_active/recovering"));
1456 }
1457
1458 /// A double release decrements exactly once.
1459 #[test]
1460 fn a_second_release_does_not_decrement_again() {
1461 probe::reset();
1462 let telemetry = handle();
1463
1464 telemetry.publish(snapshot(PixGateMode::Funded, 1), PixTurnEvents::default());
1465 telemetry.release();
1466 telemetry.release();
1467 // And the `Drop` backstop on top of the two explicit calls.
1468 drop(telemetry);
1469
1470 assert_eq!(
1471 0,
1472 probe::get("sessions_active/funded"),
1473 "a repeated release must not drive the gauge negative"
1474 );
1475 assert_eq!(1, probe::get("cycles_total/failed"), "the loss is charged once");
1476 }
1477
1478 // -----------------------------------------------------------------------
1479 // Gate pressure
1480 // -----------------------------------------------------------------------
1481
1482 /// An episode is counted once when it begins and closed once when it ends.
1483 #[test]
1484 fn a_block_episode_is_counted_once_and_closed_once() {
1485 probe::reset();
1486
1487 {
1488 let _episode = PixGateBlockEpisode::begin(GateBlockReason::PredepositExhausted);
1489 assert_eq!(1, probe::get("gate_blocks/predeposit_exhausted"));
1490 assert_eq!(
1491 0,
1492 probe::get("gate_block_observations/predeposit_exhausted"),
1493 "the duration must not be observed while the episode is still open"
1494 );
1495 }
1496
1497 assert_eq!(1, probe::get("gate_block_observations/predeposit_exhausted"));
1498 assert_eq!(1, probe::get("gate_blocks/predeposit_exhausted"), "still one episode");
1499 }
1500
1501 /// Successive episodes are separate, and each reason is counted under its own label.
1502 #[test]
1503 fn each_reason_is_counted_separately() {
1504 probe::reset();
1505
1506 drop(PixGateBlockEpisode::begin(GateBlockReason::PredepositExhausted));
1507 drop(PixGateBlockEpisode::begin(GateBlockReason::ShareLag));
1508 drop(PixGateBlockEpisode::begin(GateBlockReason::ShareLag));
1509
1510 assert_eq!(1, probe::get("gate_blocks/predeposit_exhausted"));
1511 assert_eq!(2, probe::get("gate_blocks/share_lag"));
1512 assert_eq!(1, probe::get("gate_block_observations/predeposit_exhausted"));
1513 assert_eq!(2, probe::get("gate_block_observations/share_lag"));
1514 }
1515
1516 /// A poisoned gate is counted but never timed.
1517 ///
1518 /// It is not a stall the Session recovers from, so a duration on it would measure how long the
1519 /// teardown took to reach the last writer — which is not egress pressure and would drag the
1520 /// histogram's tail for a reason that is not one.
1521 #[test]
1522 fn a_closed_gate_is_counted_without_a_duration() {
1523 probe::reset();
1524
1525 record_gate_closed();
1526 record_gate_closed();
1527
1528 assert_eq!(2, probe::get("gate_blocks/closed"));
1529 assert_eq!(
1530 std::collections::BTreeMap::from([("gate_blocks/closed".to_string(), 2)]),
1531 probe::non_zero(),
1532 "a closed gate must observe no duration under any reason"
1533 );
1534 }
1535
1536 /// Egress is flushed as a delta from the gate, never as its running total.
1537 #[test]
1538 fn egress_is_flushed_as_a_delta_from_the_gate() -> anyhow::Result<()> {
1539 probe::reset();
1540 let gate = ServiceGate::new(2, 10);
1541 let telemetry = PixSessionTelemetry::new(gate.clone());
1542
1543 // Two packets on the allowance, then a publish.
1544 assert_eq!(GateVerdict::Admitted, gate.try_acquire_sync()?);
1545 assert_eq!(GateVerdict::Admitted, gate.try_acquire_sync()?);
1546 telemetry.publish(snapshot(PixGateMode::Predeposit, 1), PixTurnEvents::default());
1547 assert_eq!(2, probe::get("egress_packets/predeposit"));
1548 assert_eq!(0, probe::get("egress_packets/funded"));
1549
1550 // A publish with nothing served in between adds nothing.
1551 telemetry.publish(snapshot(PixGateMode::Predeposit, 1), PixTurnEvents::default());
1552 assert_eq!(
1553 2,
1554 probe::get("egress_packets/predeposit"),
1555 "totals must not be re-added"
1556 );
1557
1558 // Funded service lands in the other bucket, and the allowance total is untouched.
1559 gate.release_service();
1560 for _ in 0..3 {
1561 assert_eq!(GateVerdict::Admitted, gate.try_acquire_sync()?);
1562 }
1563 telemetry.publish(snapshot(PixGateMode::Funded, 1), PixTurnEvents::default());
1564 assert_eq!(2, probe::get("egress_packets/predeposit"));
1565 assert_eq!(3, probe::get("egress_packets/funded"));
1566 Ok(())
1567 }
1568
1569 /// `release` flushes the packets served since the last publish rather than losing them.
1570 ///
1571 /// This is the tail between a Session's final supervisor turn and its teardown. It is not
1572 /// hypothetical: the worker publishes per turn, and a Session passing data serves thousands of
1573 /// packets between turns.
1574 #[test]
1575 fn release_flushes_the_final_egress_tail() -> anyhow::Result<()> {
1576 probe::reset();
1577 let gate = ServiceGate::new(10, 10);
1578 let telemetry = PixSessionTelemetry::new(gate.clone());
1579
1580 assert_eq!(GateVerdict::Admitted, gate.try_acquire_sync()?);
1581 telemetry.publish(snapshot(PixGateMode::Predeposit, 1), PixTurnEvents::default());
1582 assert_eq!(1, probe::get("egress_packets/predeposit"));
1583
1584 // Four more packets with no publish in between — the window `release` has to cover.
1585 for _ in 0..4 {
1586 assert_eq!(GateVerdict::Admitted, gate.try_acquire_sync()?);
1587 }
1588 telemetry.release();
1589
1590 assert_eq!(
1591 5,
1592 probe::get("egress_packets/predeposit"),
1593 "packets served after the last publish must still be counted"
1594 );
1595 assert_eq!(
1596 0,
1597 probe::get("sessions_active/predeposit"),
1598 "the gauges still come back to zero"
1599 );
1600 Ok(())
1601 }
1602
1603 /// A flush never lowers the egress watermark, so ground already counted is not counted again.
1604 ///
1605 /// The rule rather than the race. `served_split` derives its funded component by subtraction
1606 /// from two non-atomic loads, and a predeposit permit increments `served_predeposit` before it
1607 /// publishes `served` — so a permit landing between the loads is counted in the split but not
1608 /// yet in the total, and yields a funded figure *below* the previous one. That is the tear the
1609 /// publication order deliberately leaves reachable, because it emits nothing; reproducing the
1610 /// interleaving would be a timing test, and what has to hold is simply that a reading below the
1611 /// watermark leaves the watermark where it is. The watermark is put ahead of the gate here to
1612 /// reach the same state deterministically.
1613 #[test]
1614 fn a_flush_never_rewinds_the_egress_watermark() -> anyhow::Result<()> {
1615 probe::reset();
1616 let gate = ServiceGate::new(0, 100);
1617 let telemetry = PixSessionTelemetry::new(gate.clone());
1618 gate.release_service();
1619
1620 for _ in 0..6 {
1621 assert_eq!(GateVerdict::Admitted, gate.try_acquire_sync()?);
1622 }
1623 telemetry.publish(snapshot(PixGateMode::Funded, 1), PixTurnEvents::default());
1624 assert_eq!(6, probe::get("egress_packets/funded"));
1625
1626 // The watermark now claims more than the gate will report — the shape a torn read leaves.
1627 telemetry.state.lock().egress.1 = 9;
1628
1629 telemetry.publish(snapshot(PixGateMode::Funded, 1), PixTurnEvents::default());
1630 assert_eq!(
1631 6,
1632 probe::get("egress_packets/funded"),
1633 "a reading below the watermark adds nothing"
1634 );
1635 assert_eq!(
1636 9,
1637 telemetry.state.lock().egress.1,
1638 "and must not drag the watermark back down to it"
1639 );
1640
1641 // Four more packets take the gate to 10, of which exactly one is above the watermark.
1642 for _ in 0..4 {
1643 assert_eq!(GateVerdict::Admitted, gate.try_acquire_sync()?);
1644 }
1645 telemetry.publish(snapshot(PixGateMode::Funded, 1), PixTurnEvents::default());
1646 assert_eq!(
1647 7,
1648 probe::get("egress_packets/funded"),
1649 "only the packet past the watermark is new; a rewound one would report four"
1650 );
1651 Ok(())
1652 }
1653
1654 /// Cumulative counters are not returned on release, only gauges are.
1655 #[test]
1656 fn release_does_not_decrement_the_cumulative_counters() -> anyhow::Result<()> {
1657 probe::reset();
1658 let gate = ServiceGate::new(4, 10);
1659 let telemetry = PixSessionTelemetry::new(gate.clone());
1660
1661 for _ in 0..4 {
1662 assert_eq!(GateVerdict::Admitted, gate.try_acquire_sync()?);
1663 }
1664 telemetry.publish(snapshot(PixGateMode::Predeposit, 1), PixTurnEvents::default());
1665 telemetry.release();
1666 drop(telemetry);
1667
1668 assert_eq!(
1669 4,
1670 probe::get("egress_packets/predeposit"),
1671 "a monotonic counter must never be walked back"
1672 );
1673 Ok(())
1674 }
1675
1676 // -----------------------------------------------------------------------
1677 // Share and cycle coverage
1678 // -----------------------------------------------------------------------
1679
1680 /// Share deltas and cycle summaries reach the instruments, and a zero delta emits nothing.
1681 #[test]
1682 fn share_deltas_and_summaries_are_emitted_by_publish() {
1683 probe::reset();
1684 let telemetry = handle();
1685
1686 telemetry.publish(
1687 snapshot(PixGateMode::Funded, 1),
1688 PixTurnEvents {
1689 useful_shares: 40,
1690 surplus_shares: 12,
1691 ..Default::default()
1692 },
1693 );
1694 assert_eq!(40, probe::get("shares_total/useful"));
1695 assert_eq!(12, probe::get("shares_total/surplus"));
1696
1697 // A turn with only useful progress must not mint a surplus series of zero.
1698 telemetry.publish(
1699 snapshot(PixGateMode::Funded, 1),
1700 PixTurnEvents {
1701 useful_shares: 5,
1702 ..Default::default()
1703 },
1704 );
1705 assert_eq!(45, probe::get("shares_total/useful"));
1706 assert_eq!(12, probe::get("shares_total/surplus"), "a zero delta adds nothing");
1707
1708 telemetry.publish(
1709 snapshot(PixGateMode::Funded, 1),
1710 PixTurnEvents {
1711 recovered: 1,
1712 finalized: vec![PixCycleSummary {
1713 outcome: PixCycleOutcome::Recovered,
1714 egress_packets: 8192,
1715 accepted_shares: 768,
1716 useful_shares: 512,
1717 target_useful_shares: 512,
1718 deposit_uhopr: 0,
1719 }],
1720 ..Default::default()
1721 },
1722 );
1723 assert_eq!(1, probe::get("cycle_summaries/recovered"));
1724 assert_eq!(0, probe::get("cycle_summaries/failed"));
1725 }
1726
1727 /// Several cycles finalized in one turn are each observed once.
1728 ///
1729 /// A batch can lose more than one member to the same deadline sweep, and a histogram cannot be
1730 /// fed a sum — which is why the latch carries a list rather than a count.
1731 #[test]
1732 fn every_cycle_finalized_in_one_turn_is_observed() {
1733 probe::reset();
1734 let telemetry = handle();
1735
1736 let summary = |outcome| PixCycleSummary {
1737 outcome,
1738 egress_packets: 1024,
1739 accepted_shares: 100,
1740 useful_shares: 50,
1741 target_useful_shares: 512,
1742 deposit_uhopr: 0,
1743 };
1744 telemetry.publish(
1745 snapshot(PixGateMode::Funded, 0),
1746 PixTurnEvents {
1747 failed: 3,
1748 finalized: vec![
1749 summary(PixCycleOutcome::Failed),
1750 summary(PixCycleOutcome::Failed),
1751 summary(PixCycleOutcome::Recovered),
1752 ],
1753 ..Default::default()
1754 },
1755 );
1756
1757 assert_eq!(2, probe::get("cycle_summaries/failed"));
1758 assert_eq!(1, probe::get("cycle_summaries/recovered"));
1759 }
1760
1761 /// An empty latch touches nothing at all.
1762 #[test]
1763 fn an_empty_turn_emits_no_counter() {
1764 probe::reset();
1765 let telemetry = handle();
1766
1767 telemetry.publish(snapshot(PixGateMode::Predeposit, 1), PixTurnEvents::default());
1768
1769 assert_eq!(
1770 std::collections::BTreeMap::from([
1771 ("sessions_active/predeposit".to_string(), 1),
1772 ("cycles_active/recovering".to_string(), 1),
1773 ]),
1774 probe::non_zero(),
1775 "a turn in which nothing happened moves only the census"
1776 );
1777 }
1778
1779 /// More Sessions than the OpenTelemetry per-instrument cardinality limit, opened and closed.
1780 ///
1781 /// This is the shape that breaks the per-Session instruments (#8305): past ~2000 distinct label
1782 /// tuples the SDK folds everything further into one `otel.metric.overflow` bucket and quietly
1783 /// stops describing reality. These aggregates carry no per-Session label at all, so the property
1784 /// to prove is the other half — that the *values* stay exact over that many lifecycles, with
1785 /// every gauge back at zero and every cycle accounted for exactly once.
1786 #[test]
1787 fn two_thousand_session_lifecycles_leave_the_aggregates_exact() {
1788 const SESSIONS: usize = 2_001;
1789 const CYCLES_PER_SESSION: u32 = 3;
1790
1791 probe::reset();
1792
1793 for i in 0..SESSIONS {
1794 let telemetry = handle();
1795
1796 // Request three cycles, fund them, and let two of the three recover.
1797 telemetry.publish(
1798 PixSessionSnapshot {
1799 gate_mode: PixGateMode::Predeposit,
1800 awaiting_commitment: CYCLES_PER_SESSION,
1801 predeposit_exposure_packets: 512,
1802 ..Default::default()
1803 },
1804 PixTurnEvents {
1805 requested: CYCLES_PER_SESSION,
1806 ..Default::default()
1807 },
1808 );
1809 telemetry.publish(
1810 PixSessionSnapshot {
1811 gate_mode: PixGateMode::Funded,
1812 recovering: CYCLES_PER_SESSION,
1813 ..Default::default()
1814 },
1815 PixTurnEvents {
1816 committed: CYCLES_PER_SESSION,
1817 funded: CYCLES_PER_SESSION,
1818 ..Default::default()
1819 },
1820 );
1821 telemetry.publish(
1822 PixSessionSnapshot {
1823 gate_mode: PixGateMode::Funded,
1824 recovering: 1,
1825 paid_tail: 1,
1826 ..Default::default()
1827 },
1828 PixTurnEvents {
1829 recovered: 2,
1830 ..Default::default()
1831 },
1832 );
1833
1834 // Half the Sessions are closed cleanly and half die with that last cycle still live, so
1835 // both the retire-then-release and the release-charges-it paths are exercised.
1836 if i % 2 == 0 {
1837 telemetry.publish(
1838 PixSessionSnapshot {
1839 gate_mode: PixGateMode::Funded,
1840 ..Default::default()
1841 },
1842 PixTurnEvents {
1843 recovered: 1,
1844 ..Default::default()
1845 },
1846 );
1847 }
1848 telemetry.release();
1849 }
1850
1851 assert_eq!(
1852 std::collections::BTreeMap::new(),
1853 probe::non_zero()
1854 .into_iter()
1855 .filter(|(name, _)| !name.starts_with("cycles_total/"))
1856 .collect::<std::collections::BTreeMap<_, _>>(),
1857 "every live-set gauge must be back at zero after {SESSIONS} lifecycles"
1858 );
1859
1860 let requested = probe::get("cycles_total/requested");
1861 let recovered = probe::get("cycles_total/recovered");
1862 let failed = probe::get("cycles_total/failed");
1863 assert_eq!(SESSIONS as i64 * i64::from(CYCLES_PER_SESSION), requested);
1864 assert_eq!(
1865 requested,
1866 recovered + failed,
1867 "every requested cycle must end as exactly one of recovered or failed, got {recovered} recovered and \
1868 {failed} failed against {requested} requested"
1869 );
1870 assert_eq!(
1871 SESSIONS as i64 / 2,
1872 failed,
1873 "exactly the odd-numbered Sessions died with a cycle in flight"
1874 );
1875 }
1876}