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 run ended without a confirmed plan-record write: the planner, a
165 /// split write, the recount or the plan write failed, or leadership moved
166 /// during the run. Splits it seeded stay in the store and count as planned.
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 split_lease_headroom: Histogram,
233 /// Shared so `Clone` hands out co-owners rather than duplicate claimants.
234 /// The series is released when the last clone drops.
235 _claim: Option<Arc<SeriesClaim>>,
236}
237
238impl CoordinationMetrics {
239 /// Resolve all coordination handles.
240 ///
241 /// Claims the `spate_coordination_*` series for these labels; a second live
242 /// handle set logs and becomes a shadow, counting but publishing no gauge.
243 /// Cloning this struct shares the claim; clones are co-owners.
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 split_lease_headroom: labels
339 .histogram(names::COORDINATION_SPLIT_LEASE_HEADROOM_SECONDS),
340 _claim: claim,
341 }
342 }
343
344 /// Set the number of splits this worker currently leases.
345 pub fn set_splits_owned(&self, owned: usize) {
346 self.splits_owned.set(owned as f64);
347 }
348
349 /// Set the observed count of completed splits across the fleet.
350 pub fn set_splits_completed(&self, completed: usize) {
351 self.splits_completed.set(completed as f64);
352 }
353
354 /// Set the observed count of quarantined splits across the fleet.
355 pub fn set_splits_quarantined(&self, quarantined: usize) {
356 self.splits_quarantined.set(quarantined as f64);
357 }
358
359 /// Set the observed count of distinct live workers.
360 pub fn set_live_workers(&self, workers: usize) {
361 self.live_workers.set(workers as f64);
362 }
363
364 /// Flag whether this worker currently holds planner leadership.
365 pub fn set_leader(&self, leader: bool) {
366 self.leader.set(if leader { 1.0 } else { 0.0 });
367 }
368
369 /// Flag whether this worker is a zero-split standby observer.
370 pub fn set_idle(&self, idle: bool) {
371 self.idle.set(if idle { 1.0 } else { 0.0 });
372 }
373
374 /// Record one split acquisition.
375 pub fn acquired(&self, reason: AcquireReason) {
376 match reason {
377 AcquireReason::Create => self.acquired_create.increment(1),
378 AcquireReason::Reclaimed => self.acquired_reclaimed.increment(1),
379 AcquireReason::Expired => self.acquired_expired.increment(1),
380 AcquireReason::Reassigned => self.acquired_reassigned.increment(1),
381 }
382 }
383
384 /// Record one revocation event.
385 pub fn revocation(&self, outcome: RevocationOutcome) {
386 match outcome {
387 RevocationOutcome::Requested => self.revocations_requested.increment(1),
388 RevocationOutcome::Drained => self.revocations_drained.increment(1),
389 RevocationOutcome::Forced => self.revocations_forced.increment(1),
390 RevocationOutcome::Cancelled => self.revocations_cancelled.increment(1),
391 }
392 }
393
394 // The two timings below are separate families, not one split by a `phase`
395 // label. Nothing spans both. A drain starts when the leader removes a
396 // split from one worker's assignment, an assignment wait starts when the
397 // leader adds a split to another's, and neither worker observes the
398 // other's clock. The denominators differ too. Every assigned split is
399 // waited for, including brand-new splits and dead owners' work that no
400 // revocation touched, so the assignment wait is not a revocation
401 // measurement.
402 //
403 // A shared family would leave `histogram_quantile` over `sum by (le)`,
404 // aggregating away `phase`, spelled exactly like a reasonable query while
405 // silently mixing two populations. Separate names make that aggregate
406 // unwritable.
407
408 /// Record one cooperative drain: revocation requested to the release
409 /// landing, on the **releasing** worker.
410 ///
411 /// Observed only when the drain completes cooperatively
412 /// ([`RevocationOutcome::Drained`]). A forced release is a failure of
413 /// the drain, and timing it would mix `drain_deadline` into the
414 /// distribution of how long draining takes.
415 pub fn drain_duration(&self, d: Duration) {
416 self.drain_duration.record(d.as_secs_f64());
417 }
418
419 /// Record one assignment wait: a split appearing in this worker's
420 /// assignment to this worker holding its lease, on the **gaining**
421 /// worker.
422 ///
423 /// This is the fleet's time-to-balance as an operator experiences it, the
424 /// time work the leader has already decided this worker should be doing
425 /// sat undone. It spans whatever stood in the way, including the previous
426 /// owner's drain, rather than timing only the final claim.
427 pub fn assignment_latency(&self, d: Duration) {
428 self.assignment_latency.record(d.as_secs_f64());
429 }
430
431 /// Record the time left before this worker's starvation self-fence, on
432 /// its own clock, when a renewal of one of its split leases is confirmed.
433 ///
434 /// 0 means the renewal was confirmed a full lease or more after the
435 /// previous confirmed write.
436 pub fn split_lease_headroom(&self, headroom: Duration) {
437 self.split_lease_headroom.record(headroom.as_secs_f64());
438 }
439
440 /// Set the number of splits this worker is currently draining away
441 /// under revocation.
442 pub fn set_splits_draining(&self, draining: usize) {
443 self.splits_draining.set(draining as f64);
444 }
445
446 /// Record one involuntary split loss.
447 pub fn lost(&self, reason: SplitLossReason) {
448 match reason {
449 SplitLossReason::Fenced => self.lost_fenced.increment(1),
450 SplitLossReason::Starved => self.lost_starved.increment(1),
451 SplitLossReason::Revoked => self.lost_revoked.increment(1),
452 }
453 }
454
455 /// Record voluntarily released splits.
456 pub fn released(&self, splits: u64) {
457 self.releases.increment(splits);
458 }
459
460 /// Record splits newly written into the plan while leader.
461 pub fn planned(&self, splits: u64) {
462 self.splits_planned.increment(splits);
463 }
464
465 /// Record one planner run while leader and its duration.
466 pub fn replan(&self, outcome: ReplanOutcome, d: Duration) {
467 match outcome {
468 ReplanOutcome::Ok => self.replans_ok.increment(1),
469 ReplanOutcome::Error => self.replans_error.increment(1),
470 ReplanOutcome::Noop => self.replans_noop.increment(1),
471 }
472 self.replan_duration.record(d.as_secs_f64());
473 }
474
475 /// Record one explicit split failure report.
476 pub fn failed(&self) {
477 self.split_failures.increment(1);
478 }
479
480 /// Record one split parked in quarantine.
481 pub fn quarantined(&self) {
482 self.quarantines.increment(1);
483 }
484
485 /// Record one split-record write and its round-trip time.
486 pub fn write(&self, outcome: WriteOutcome, d: Duration) {
487 match outcome {
488 WriteOutcome::Ok => self.writes_ok.increment(1),
489 WriteOutcome::Conflict => self.writes_conflict.increment(1),
490 WriteOutcome::Error => self.writes_error.increment(1),
491 }
492 self.write_duration.record(d.as_secs_f64());
493 }
494
495 /// Record one full reconcile listing (the watch-loss backstop).
496 pub fn reconcile(&self, d: Duration) {
497 self.reconcile_duration.record(d.as_secs_f64());
498 }
499
500 /// Record one store primitive's round-trip time.
501 pub fn store_op(&self, op: StoreOp, d: Duration) {
502 let h = match op {
503 StoreOp::Get => &self.store_op_get,
504 StoreOp::Put => &self.store_op_put,
505 StoreOp::Delete => &self.store_op_delete,
506 StoreOp::List => &self.store_op_list,
507 StoreOp::Watch => &self.store_op_watch,
508 };
509 h.record(d.as_secs_f64());
510 }
511}