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