Skip to main content

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}