Skip to main content

spate_core/metrics/
coordination.rs

1//! Coordination handles (`spate_coordination_*`).
2
3use super::MetricsError;
4use super::labels::{ComponentLabels, OwnedGauge};
5use super::names;
6use super::ownership::{SeriesClaim, series_key};
7use metrics::{Counter, Histogram};
8use std::sync::Arc;
9use std::time::Duration;
10
11/// Why a split lease was acquired (the `reason` label on
12/// `spate_coordination_acquisitions_total`).
13#[derive(Clone, Copy, Debug, PartialEq, Eq)]
14#[non_exhaustive]
15pub enum AcquireReason {
16    /// First claim of a runnable split no one has held.
17    Create,
18    /// Fast reclaim of a split this worker (by stable id) still held.
19    Reclaimed,
20    /// Takeover of a split whose lease expired unrenewed.
21    Expired,
22    /// Claim of a split whose previous owner released it cleanly — a
23    /// drained revocation, or a shutdown/scale-down hand-back. Either way
24    /// the owner cleared the record before letting go, so the resume point
25    /// covers everything it emitted and the claim is replay-free; a drained
26    /// revocation additionally counts [`RevocationOutcome::Drained`] on the
27    /// releasing side. Contrast [`Expired`](AcquireReason::Expired), where
28    /// a dead owner's uncommitted tail replays.
29    ///
30    /// One clean release counts here with *no* matching `drained`: the drain
31    /// left behind by a [`RevocationOutcome::Cancelled`] revocation, which
32    /// hands the split back to the worker that already had it. Counting it
33    /// `drained` would claim a move that never happened.
34    ///
35    /// The two clean cases are one reason on purpose: a claiming worker
36    /// cannot tell them apart (both present as a cleared owner and a
37    /// vanished lease), and a label it cannot populate correctly is a
38    /// series that reads zero forever.
39    Reassigned,
40}
41
42/// Outcome of one split revocation (the `outcome` label on
43/// `spate_coordination_revocations_total`) — the leader moving a split away
44/// from a live owner by dropping it from that owner's assignment.
45///
46/// All four count on the **releasing** worker, so they read as one
47/// lifecycle rather than as two sides of a negotiation: `Requested` is the
48/// denominator, and every revocation that leaves it terminates in exactly
49/// one of `Drained`, `Forced`, or `Cancelled` — including the paths that do
50/// not look like a revocation ending at all, where the split completes or is
51/// `fail`ed mid-drain, or the process departs while draining.
52/// `requested - drained - forced - cancelled` is therefore the **revocations**
53/// still in flight.
54///
55/// That is not the same number as `spate_coordination_splits_draining`, which
56/// counts **drains**. `Cancelled` ends a revocation while leaving its drain
57/// running, so the gauge sits one higher than the counter arithmetic for as
58/// long as that drain takes. Two questions, two series: "how much is the
59/// leader still trying to move" and "how many splits are winding down".
60#[derive(Clone, Copy, Debug, PartialEq, Eq)]
61#[non_exhaustive]
62pub enum RevocationOutcome {
63    /// The leader stopped naming this split in the worker's assignment, so
64    /// the cooperative drain began: stop intake at a safe boundary, chase
65    /// the tail to a final fenced commit, release.
66    Requested,
67    /// The drain finished cooperatively: the tail committed and the release
68    /// landed, so the next owner resumes past everything this worker
69    /// emitted and replays nothing. The outcome the cooperative path exists
70    /// to produce; the gaining side counts
71    /// [`AcquireReason::Reassigned`]. A split that *completes* mid-drain
72    /// counts here too — its tail is committed and nothing replays, which
73    /// is the same outcome even though nobody took it over.
74    Drained,
75    /// The cooperative path did not finish, so the release was forced: the
76    /// source declined to stop at a safe boundary, the drain outran
77    /// `drain_deadline`, or the split was fenced away before the release
78    /// landed. The uncommitted tail replays under the next owner. A decline
79    /// and an elapsed deadline are one outcome on purpose — the leader's
80    /// revocation is a decision, not a request, so both end the same way
81    /// and differ only in how long the fleet waited to find out.
82    Forced,
83    /// The leader took the revocation back: it named the split for this
84    /// worker again while this worker still held it, so the pending forced
85    /// release was dropped. Nothing is waiting for the split any more, so a
86    /// drain that is merely slower than `drain_deadline` gets to finish
87    /// cleanly instead of being charged a replay for a move nobody wants.
88    /// That is the whole of what cancelling buys — a drain that finishes
89    /// inside the deadline was never going to be forced anyway.
90    ///
91    /// This counts the *revocation* ending, not the drain, and the two then
92    /// diverge:
93    ///
94    /// - If the source had already stopped intake, the drain runs on
95    ///   (resuming stopped intake is a seam sources do not have). It ends by
96    ///   handing the split back, and this worker re-claims it
97    ///   ([`AcquireReason::Reassigned`]) replay-free — one lane teardown and
98    ///   re-open, counted under neither `drained` nor
99    ///   `drain_duration_seconds`, because by then it is not a revocation
100    ///   ending. `splits_draining` stays up until it lands.
101    /// - If the source declined, or was never asked, nothing stopped and
102    ///   nothing leaves: the split simply stays, still being read.
103    ///
104    /// A cancelled drain is still bounded, just by silence rather than by
105    /// the deadline: if it commits nothing at all for `drain_deadline` the
106    /// split is released anyway and re-claimed with a fresh lane, because a
107    /// drain that never finishes would otherwise leave it owned, leased,
108    /// and read by nobody. That release counts a
109    /// [`SplitLossReason::Revoked`] and no second revocation outcome.
110    ///
111    /// Sustained `cancelled` means the fleet's membership is flapping faster
112    /// than a drain takes: look at pod churn and at `drain_deadline`.
113    Cancelled,
114}
115
116/// Why a split lease was lost involuntarily (the `reason` label on
117/// `spate_coordination_split_losses_total`).
118#[derive(Clone, Copy, Debug, PartialEq, Eq)]
119#[non_exhaustive]
120pub enum SplitLossReason {
121    /// A write was rejected — a peer holds a higher lease epoch.
122    Fenced,
123    /// Self-fenced: no successful lease write for a full lease duration.
124    Starved,
125    /// A cooperative drain that never completed, so this worker forced its
126    /// own release. Either the leader had stopped assigning the split and
127    /// the source declined or outran `drain_deadline`, or the leader took
128    /// the revocation back ([`RevocationOutcome::Cancelled`]) and the drain
129    /// it left behind then went a full `drain_deadline` without committing
130    /// anything — a stalled drain releases too, or the split would stay
131    /// owned with nothing reading it.
132    ///
133    /// The split's uncommitted tail replays under its next owner (for the
134    /// cancelled case, usually this same worker): the bounded-replay
135    /// outcome the cooperative path exists to avoid, and therefore a signal
136    /// that `drain_deadline` is too tight for this source's commit
137    /// interval, or that a lane is wedged.
138    ///
139    /// Narrower than [`RevocationOutcome::Forced`], which also covers a
140    /// revocation whose split was fenced away mid-drain; that one is lost
141    /// as [`Fenced`](SplitLossReason::Fenced), because a peer — not this
142    /// worker — ended the tenancy.
143    Revoked,
144}
145
146/// Outcome of one split-record write (the `outcome` label on
147/// `spate_coordination_writes_total`).
148#[derive(Clone, Copy, Debug, PartialEq, Eq)]
149#[non_exhaustive]
150pub enum WriteOutcome {
151    /// Durable.
152    Ok,
153    /// Lost a compare-and-swap race (fencing or claim contention).
154    Conflict,
155    /// Failed for any other reason (timeout, transport, service error).
156    Error,
157}
158
159/// Outcome of one planner run while leader (the `outcome` label on
160/// `spate_coordination_replans_total`).
161#[derive(Clone, Copy, Debug, PartialEq, Eq)]
162#[non_exhaustive]
163pub enum ReplanOutcome {
164    /// The plan advanced: new splits were written or finality changed.
165    Ok,
166    /// The planner or the plan write failed.
167    Error,
168    /// The enumeration produced nothing new.
169    Noop,
170}
171
172/// Store primitive (the `op` label on
173/// `spate_coordination_store_op_duration_seconds`).
174#[derive(Clone, Copy, Debug, PartialEq, Eq)]
175#[non_exhaustive]
176pub enum StoreOp {
177    /// Point read.
178    Get,
179    /// Create or CAS update.
180    Put,
181    /// Delete (graceful release).
182    Delete,
183    /// Reconcile listing.
184    List,
185    /// Watch (re)establishment.
186    Watch,
187}
188
189/// Coordination handles (`spate_coordination_*`), pre-registered at build
190/// time and handed to the coordination backend at construction.
191/// Cloning is cheap — the fields are shared recorder handles.
192#[derive(Clone, Debug)]
193pub struct CoordinationMetrics {
194    splits_owned: OwnedGauge,
195    splits_completed: OwnedGauge,
196    splits_quarantined: OwnedGauge,
197    live_workers: OwnedGauge,
198    leader: OwnedGauge,
199    idle: OwnedGauge,
200    splits_draining: OwnedGauge,
201    acquired_create: Counter,
202    acquired_reclaimed: Counter,
203    acquired_expired: Counter,
204    acquired_reassigned: Counter,
205    lost_fenced: Counter,
206    lost_starved: Counter,
207    lost_revoked: Counter,
208    releases: Counter,
209    revocations_requested: Counter,
210    revocations_drained: Counter,
211    revocations_forced: Counter,
212    revocations_cancelled: Counter,
213    splits_planned: Counter,
214    replans_ok: Counter,
215    replans_error: Counter,
216    replans_noop: Counter,
217    split_failures: Counter,
218    quarantines: Counter,
219    writes_ok: Counter,
220    writes_conflict: Counter,
221    writes_error: Counter,
222    write_duration: Histogram,
223    replan_duration: Histogram,
224    reconcile_duration: Histogram,
225    store_op_get: Histogram,
226    store_op_put: Histogram,
227    store_op_delete: Histogram,
228    store_op_list: Histogram,
229    store_op_watch: Histogram,
230    drain_duration: Histogram,
231    assignment_latency: Histogram,
232    /// Shared so `Clone` hands out co-owners rather than duplicate claimants:
233    /// the series is released when the last clone drops.
234    _claim: Option<Arc<SeriesClaim>>,
235}
236
237impl CoordinationMetrics {
238    /// Resolve all coordination handles.
239    ///
240    /// Claims the `spate_coordination_*` series for these labels; a second live
241    /// handle set logs and becomes a shadow, counting but publishing no gauge
242    /// (see "Series ownership" in `docs/METRICS.md`). Cloning this struct
243    /// shares the claim — clones are co-owners, not competitors.
244    pub fn new(labels: &ComponentLabels) -> Self {
245        let claim = SeriesClaim::claim_or_shadow(Self::key(labels));
246        Self::build(labels, claim.map(Arc::new))
247    }
248
249    /// Resolve all coordination handles, failing when another live handle set
250    /// already owns the series.
251    ///
252    /// # Errors
253    ///
254    /// [`MetricsError::DuplicateSeries`] on a collision.
255    pub fn try_new(labels: &ComponentLabels) -> Result<Self, MetricsError> {
256        let claim = SeriesClaim::try_claim(Self::key(labels))?;
257        Ok(Self::build(labels, Some(Arc::new(claim))))
258    }
259
260    fn key(labels: &ComponentLabels) -> String {
261        series_key("coordination", labels, "")
262    }
263
264    fn build(labels: &ComponentLabels, claim: Option<Arc<SeriesClaim>>) -> Self {
265        let owned = claim.is_some();
266        let gauge = |name| OwnedGauge::new(labels.gauge(name), owned);
267        let acquired = |reason| {
268            labels.counter1(
269                names::COORDINATION_ACQUISITIONS_TOTAL,
270                names::L_REASON,
271                reason,
272            )
273        };
274        let lost = |reason| {
275            labels.counter1(
276                names::COORDINATION_SPLIT_LOSSES_TOTAL,
277                names::L_REASON,
278                reason,
279            )
280        };
281        let replans =
282            |outcome| labels.counter1(names::COORDINATION_REPLANS_TOTAL, names::L_OUTCOME, outcome);
283        let revocations = |outcome| {
284            labels.counter1(
285                names::COORDINATION_REVOCATIONS_TOTAL,
286                names::L_OUTCOME,
287                outcome,
288            )
289        };
290        let writes =
291            |outcome| labels.counter1(names::COORDINATION_WRITES_TOTAL, names::L_OUTCOME, outcome);
292        let store_op = |op| {
293            labels.histogram1(
294                names::COORDINATION_STORE_OP_DURATION_SECONDS,
295                names::L_OP,
296                op,
297            )
298        };
299        CoordinationMetrics {
300            splits_owned: gauge(names::COORDINATION_SPLITS_OWNED),
301            splits_completed: gauge(names::COORDINATION_SPLITS_COMPLETED),
302            splits_quarantined: gauge(names::COORDINATION_SPLITS_QUARANTINED),
303            live_workers: gauge(names::COORDINATION_LIVE_WORKERS),
304            leader: gauge(names::COORDINATION_LEADER),
305            idle: gauge(names::COORDINATION_IDLE),
306            splits_draining: gauge(names::COORDINATION_SPLITS_DRAINING),
307            acquired_create: acquired("create"),
308            acquired_reclaimed: acquired("reclaimed"),
309            acquired_expired: acquired("expired"),
310            acquired_reassigned: acquired("reassigned"),
311            lost_fenced: lost("fenced"),
312            lost_starved: lost("starved"),
313            lost_revoked: lost("revoked"),
314            releases: labels.counter(names::COORDINATION_RELEASES_TOTAL),
315            revocations_requested: revocations("requested"),
316            revocations_drained: revocations("drained"),
317            revocations_forced: revocations("forced"),
318            revocations_cancelled: revocations("cancelled"),
319            splits_planned: labels.counter(names::COORDINATION_SPLITS_PLANNED_TOTAL),
320            replans_ok: replans("ok"),
321            replans_error: replans("error"),
322            replans_noop: replans("noop"),
323            split_failures: labels.counter(names::COORDINATION_SPLIT_FAILURES_TOTAL),
324            quarantines: labels.counter(names::COORDINATION_QUARANTINES_TOTAL),
325            writes_ok: writes("ok"),
326            writes_conflict: writes("conflict"),
327            writes_error: writes("error"),
328            write_duration: labels.histogram(names::COORDINATION_WRITE_DURATION_SECONDS),
329            replan_duration: labels.histogram(names::COORDINATION_REPLAN_DURATION_SECONDS),
330            reconcile_duration: labels.histogram(names::COORDINATION_RECONCILE_DURATION_SECONDS),
331            store_op_get: store_op("get"),
332            store_op_put: store_op("put"),
333            store_op_delete: store_op("delete"),
334            store_op_list: store_op("list"),
335            store_op_watch: store_op("watch"),
336            drain_duration: labels.histogram(names::COORDINATION_DRAIN_DURATION_SECONDS),
337            assignment_latency: labels.histogram(names::COORDINATION_ASSIGNMENT_LATENCY_SECONDS),
338            _claim: claim,
339        }
340    }
341
342    /// Set the number of splits this worker currently leases.
343    pub fn set_splits_owned(&self, owned: usize) {
344        self.splits_owned.set(owned as f64);
345    }
346
347    /// Set the observed count of completed splits across the fleet.
348    pub fn set_splits_completed(&self, completed: usize) {
349        self.splits_completed.set(completed as f64);
350    }
351
352    /// Set the observed count of quarantined splits across the fleet.
353    pub fn set_splits_quarantined(&self, quarantined: usize) {
354        self.splits_quarantined.set(quarantined as f64);
355    }
356
357    /// Set the observed count of distinct live workers, including self.
358    pub fn set_live_workers(&self, workers: usize) {
359        self.live_workers.set(workers as f64);
360    }
361
362    /// Flag whether this worker currently holds planner leadership.
363    pub fn set_leader(&self, leader: bool) {
364        self.leader.set(if leader { 1.0 } else { 0.0 });
365    }
366
367    /// Flag whether this worker is a zero-split standby observer.
368    pub fn set_idle(&self, idle: bool) {
369        self.idle.set(if idle { 1.0 } else { 0.0 });
370    }
371
372    /// Record one split acquisition.
373    pub fn acquired(&self, reason: AcquireReason) {
374        match reason {
375            AcquireReason::Create => self.acquired_create.increment(1),
376            AcquireReason::Reclaimed => self.acquired_reclaimed.increment(1),
377            AcquireReason::Expired => self.acquired_expired.increment(1),
378            AcquireReason::Reassigned => self.acquired_reassigned.increment(1),
379        }
380    }
381
382    /// Record one revocation event.
383    pub fn revocation(&self, outcome: RevocationOutcome) {
384        match outcome {
385            RevocationOutcome::Requested => self.revocations_requested.increment(1),
386            RevocationOutcome::Drained => self.revocations_drained.increment(1),
387            RevocationOutcome::Forced => self.revocations_forced.increment(1),
388            RevocationOutcome::Cancelled => self.revocations_cancelled.increment(1),
389        }
390    }
391
392    // The two timings below were one `_duration_seconds` family split by a
393    // `phase` label back when a move was a negotiation: the requester's
394    // wait strictly *contained* the victim's drain, so the two were nested
395    // terms of a single time-to-balance figure and belonged on one family.
396    //
397    // Leader-assigned reconciliation destroyed that relationship. Nothing
398    // now spans both: a drain starts when the leader removes a split from
399    // one worker's assignment, an assignment wait starts when the leader
400    // adds a split to another's, and neither worker observes the other's
401    // clock. They also have different denominators — every assigned split
402    // is waited for, including brand-new splits and dead owners' work that
403    // no revocation ever touched, so the assignment wait is not a
404    // revocation measurement at all.
405    //
406    // Two families, therefore, not one with a label. A shared family would
407    // assert a composition that no longer exists, and it would leave
408    // `histogram_quantile` over `sum by (le)` — aggregating away `phase` —
409    // spelled exactly like a reasonable query while silently mixing two
410    // populations. Separate names make the meaningless aggregate
411    // unwritable rather than merely discouraged.
412
413    /// Record one cooperative drain: revocation requested to the release
414    /// landing, on the **releasing** worker.
415    ///
416    /// Observed only when the drain completes cooperatively
417    /// ([`RevocationOutcome::Drained`]) — a forced release is a failure of
418    /// the drain, and timing it would mix `drain_deadline` into the
419    /// distribution of how long draining actually takes.
420    pub fn drain_duration(&self, d: Duration) {
421        self.drain_duration.record(d.as_secs_f64());
422    }
423
424    /// Record one assignment wait: a split appearing in this worker's
425    /// assignment to this worker holding its lease, on the **gaining**
426    /// worker.
427    ///
428    /// This is the fleet's time-to-balance as an operator experiences it —
429    /// how long work the leader has already decided this worker should be
430    /// doing sat undone. It spans whatever stood in the way, including the
431    /// previous owner's drain, so it never flatters itself by timing only
432    /// the final claim.
433    pub fn assignment_latency(&self, d: Duration) {
434        self.assignment_latency.record(d.as_secs_f64());
435    }
436
437    /// Set the number of splits this worker is currently draining away
438    /// under revocation.
439    pub fn set_splits_draining(&self, draining: usize) {
440        self.splits_draining.set(draining as f64);
441    }
442
443    /// Record one involuntary split loss.
444    pub fn lost(&self, reason: SplitLossReason) {
445        match reason {
446            SplitLossReason::Fenced => self.lost_fenced.increment(1),
447            SplitLossReason::Starved => self.lost_starved.increment(1),
448            SplitLossReason::Revoked => self.lost_revoked.increment(1),
449        }
450    }
451
452    /// Record voluntarily released splits.
453    pub fn released(&self, splits: u64) {
454        self.releases.increment(splits);
455    }
456
457    /// Record splits newly written into the plan while leader.
458    pub fn planned(&self, splits: u64) {
459        self.splits_planned.increment(splits);
460    }
461
462    /// Record one planner run while leader and its duration.
463    pub fn replan(&self, outcome: ReplanOutcome, d: Duration) {
464        match outcome {
465            ReplanOutcome::Ok => self.replans_ok.increment(1),
466            ReplanOutcome::Error => self.replans_error.increment(1),
467            ReplanOutcome::Noop => self.replans_noop.increment(1),
468        }
469        self.replan_duration.record(d.as_secs_f64());
470    }
471
472    /// Record one explicit split failure report.
473    pub fn failed(&self) {
474        self.split_failures.increment(1);
475    }
476
477    /// Record one split parked in quarantine.
478    pub fn quarantined(&self) {
479        self.quarantines.increment(1);
480    }
481
482    /// Record one split-record write and its round-trip time.
483    pub fn write(&self, outcome: WriteOutcome, d: Duration) {
484        match outcome {
485            WriteOutcome::Ok => self.writes_ok.increment(1),
486            WriteOutcome::Conflict => self.writes_conflict.increment(1),
487            WriteOutcome::Error => self.writes_error.increment(1),
488        }
489        self.write_duration.record(d.as_secs_f64());
490    }
491
492    /// Record one full reconcile listing (the watch-loss backstop).
493    pub fn reconcile(&self, d: Duration) {
494        self.reconcile_duration.record(d.as_secs_f64());
495    }
496
497    /// Record one store primitive's round-trip time.
498    pub fn store_op(&self, op: StoreOp, d: Duration) {
499        let h = match op {
500            StoreOp::Get => &self.store_op_get,
501            StoreOp::Put => &self.store_op_put,
502            StoreOp::Delete => &self.store_op_delete,
503            StoreOp::List => &self.store_op_list,
504            StoreOp::Watch => &self.store_op_watch,
505        };
506        h.record(d.as_secs_f64());
507    }
508}