Skip to main content

aion_core/
cluster_event.rs

1//! Cluster topology and ownership events for the ops console real-time channel (WS3).
2//!
3//! This module defines the *typed contract* for the second class of real-time push the ops console
4//! consumes: cluster/topology/ownership deltas that today exist only as `tracing` logs and
5//! Prometheus gauges. The wire shapes live in `aion-core` (not `aion-server`) for one reason: only
6//! this leaf crate depends on `ts-rs`, so this is the single place a Rust type can cross the
7//! Rust -> TypeScript boundary into the ops console's generated bindings.
8//!
9//! # Scope (WS3 FOUNDATION)
10//!
11//! This file is *types only*. It deliberately does **not** define the broadcast publisher, the
12//! emit call-sites in the supervisor / registry, the subscription endpoint, or the namespace gate.
13//! Those are wired in later WS3 increments. Defining the contract first lets the ops console and the
14//! server agree on shapes before any behaviour ships.
15//!
16//! # Honesty corrections applied to the original design
17//!
18//! The design table named several variants whose payloads cannot be sourced from the subsystems
19//! that would emit them today. Rather than ship a type whose fields are guaranteed to be faked,
20//! those variants are **descoped from Phase 1** and documented here so the omission is explicit and
21//! not silently rediscovered at wiring time:
22//!
23//! - **`NodeMetricsSampled` — DEFERRED.** `aion-server`'s metrics (`observability/metrics.rs`) have
24//!   no `node` dimension (`connected_workers` is an `IntGaugeVec` keyed by *namespace*) and there
25//!   is no `workflows_running` gauge at all. A `{ node, connected_workers, workflows_running }`
26//!   payload cannot be produced by "piggybacking the gauge setters". Sourcing it honestly requires
27//!   a real metrics change (node-labelled gauges + a running gauge). Until then the ops console
28//!   derives `connected_workers` client-side from [`ClusterEvent::WorkerConnected`] /
29//!   [`ClusterEvent::WorkerDisconnected`] deltas against the [`ClusterSnapshot`] baseline. Adding
30//!   a timer that scrapes Prometheus to fill this variant is the exact polling-as-push regression
31//!   WS3 exists to remove, so the variant is omitted rather than tempting that shortcut.
32//! - **`ShardOwnerChanged` / `FencedCasRejected` — DEFERRED (fast-follow).** The haematite seam
33//!   (`HaematiteStore::publish_ln(shard)`) carries only `shard`; the fenced-CAS reject is a
34//!   `haematite::DatabaseError::Fenced { .. }` destructured with `..` and mapped to
35//!   `StoreError::NotOwner { shard }`, carrying only `shard` — never the owner name or the
36//!   attempted/current epochs. The proposed payloads require enriching haematite's `Fenced` error
37//!   to surface owner identity and epoch, which is a cross-crate (likely cross-repo) change. The
38//!   cluster map is still *honest* without these two: shard adoption is fully observable from the
39//!   supervisor's `tick()` ([`ClusterEvent::ShardAdopted`] and friends). Only the store-side
40//!   CAS-reject detail is delayed.
41//!
42//! # u64 precision across the TS boundary
43//!
44//! The ts-rs config exports every `u64` as TS `number` (`with_large_int("number")` in
45//! `generated_types.rs`), which truncates above `2^53`. [`ClusterEventMeta::cluster_seq`] and the
46//! epoch fields below are `u64`. This is the *same* accepted ceiling that already applies to
47//! [`crate::EventEnvelope::seq`]; cluster sequencing follows the established project convention
48//! rather than introducing a divergent string encoding. The ceiling is documented on each field so
49//! the gap-detection math on the client is aware of the bound. A long-lived deployment must keep
50//! `cluster_seq` below `2^53`; in practice this is never reached for a topology-event counter.
51
52use chrono::{DateTime, Utc};
53use serde::{Deserialize, Serialize};
54
55/// Metadata stamped on every [`ClusterEvent`], mirroring [`crate::EventEnvelope`] for the cluster
56/// channel.
57#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq)]
58pub struct ClusterEventMeta {
59    /// Monotonic sequence number assigned by the cluster publisher (a single deployment-global
60    /// `AtomicU64`). The client uses this for gap detection and `after_seq` reconnect dedup, the
61    /// cluster analog of [`crate::EventEnvelope::seq`].
62    ///
63    /// Exported to TypeScript as `number`; see the module docs for the accepted `2^53` ceiling.
64    pub cluster_seq: u64,
65    /// UTC wall-clock instant at which the originating subsystem observed the state change.
66    pub observed_at: DateTime<Utc>,
67}
68
69/// Transport a connected worker is delivered to over.
70///
71/// Mirrors the live `aion_server::worker::registry::WorkerDelivery` discriminants without carrying
72/// the (non-serializable) delivery channels, so it can cross the wire and the ts-rs boundary.
73#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Copy, Debug, PartialEq, Eq)]
74#[serde(tag = "transport")]
75pub enum WorkerTransport {
76    /// gRPC bidirectional-stream delivery (the default transport).
77    Grpc,
78    /// Liminal server-push delivery (feature-gated on the server).
79    Liminal,
80}
81
82/// Why a worker left the connected set.
83///
84/// NOTE (wiring honesty): the single registry deregistration site
85/// (`ConnectedWorkerRegistry::deregister` / `remove_worker`) does not today distinguish a transport
86/// disconnect from an idle timeout from an explicit deregister. The emit increment MUST derive the
87/// reason at the call site from real signal (e.g. a closed delivery channel vs an explicit
88/// deregister RPC vs a liveness-timeout sweep) or collapse to the variant it can actually prove.
89/// This enum defines the *contract*; it must not be populated with a fabricated distinction.
90#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Copy, Debug, PartialEq, Eq)]
91#[serde(tag = "reason")]
92pub enum WorkerDeathReason {
93    /// The worker's delivery transport dropped (stream/connection closed).
94    Disconnect,
95    /// The worker was removed by a liveness/idle-timeout sweep.
96    Timeout,
97    /// The worker explicitly deregistered.
98    Deregistered,
99}
100
101/// A cluster/topology/ownership delta pushed over the ops console real-time channel.
102///
103/// Tagged union on `type` to match the existing [`crate::Event`] wire shape. Every variant carries
104/// [`ClusterEventMeta`] as `meta`. Cluster events are deployment-scoped, not namespace-stamped at
105/// the envelope level; the `Worker*` variants carry their own `namespaces` so the server-side gate
106/// can intersect them against the caller's grants (see the deferred `cluster_filter`).
107#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq)]
108#[serde(tag = "type")]
109pub enum ClusterEvent {
110    /// A peer was added to this node's watch set (cluster-membership grew mid-session).
111    ///
112    /// Distinct from [`Self::PeerConnected`]: this is a *topology* change (a new peer to watch),
113    /// not a *liveness* transition. Without it, a peer that joins after the priming
114    /// [`ClusterSnapshot`] would silently never appear on the map until the next snapshot.
115    PeerAdded {
116        /// Cluster-event metadata.
117        meta: ClusterEventMeta,
118        /// The newly-watched peer's node name.
119        peer_name: String,
120        /// The peer's forwarding address, when known.
121        forward_addr: Option<String>,
122    },
123    /// A watched peer transitioned from down/unknown to connected (a liveness recovery).
124    ///
125    /// EMIT NOTE: the supervisor `tick()` resets `consecutive_down`/`adopted` on the same tick it
126    /// observes `connected`, so the recovery condition (`was_down = consecutive_down > 0 ||
127    /// adopted`) MUST be captured *before* that reset and the emit driven by the captured value.
128    /// Emitting after the reset produces no recovery events (every tick looks freshly connected).
129    PeerConnected {
130        /// Cluster-event metadata.
131        meta: ClusterEventMeta,
132        /// The recovered peer's node name.
133        peer_name: String,
134        /// The peer's forwarding address, when known.
135        forward_addr: Option<String>,
136    },
137    /// A watched peer was observed down on a supervisor tick.
138    ///
139    /// Emitted on every tick the peer is observed down; `confirmed` flips to `true` once
140    /// `consecutive_down >= confirmations` (the debounce threshold that authorizes adoption).
141    PeerDisconnected {
142        /// Cluster-event metadata.
143        meta: ClusterEventMeta,
144        /// The down peer's node name.
145        peer_name: String,
146        /// Consecutive ticks this peer has been observed down.
147        consecutive_down: u32,
148        /// Whether the debounce threshold has been crossed (adoption-eligible).
149        confirmed: bool,
150    },
151    /// This node adopted shards previously owned by a failed peer.
152    ShardAdopted {
153        /// Cluster-event metadata.
154        meta: ClusterEventMeta,
155        /// Shard indices adopted in this transition.
156        shards: Vec<usize>,
157        /// The peer the shards were adopted from.
158        from_peer: String,
159        /// This node's name (the new owner).
160        adopted_by: String,
161    },
162    /// An adoption attempt failed and will be retried on a subsequent tick.
163    ///
164    /// HONESTY: the original design hardcoded `will_retry: true`. That is a silent lie — a peer
165    /// later observed connected resets `adopted` and stops retrying, and the
166    /// "handled elsewhere" path terminally stops. The retry decision is not expressible as a
167    /// constant, so the field is omitted; the ops console infers retry by observing whether a
168    /// subsequent [`Self::ShardAdopted`] / [`Self::ShardAdoptionSkipped`] for the same
169    /// `(from_peer, shards)` arrives, rather than trusting a promise that may never be kept
170    /// (ADR-016 no-silent-failure).
171    ShardAdoptionFailed {
172        /// Cluster-event metadata.
173        meta: ClusterEventMeta,
174        /// Shard indices the failed attempt targeted.
175        shards: Vec<usize>,
176        /// The peer the shards would have been adopted from.
177        from_peer: String,
178        /// Human-readable adoption error.
179        error: String,
180    },
181    /// Adoption was skipped because the shards are already held by a live third-party owner.
182    ShardAdoptionSkipped {
183        /// Cluster-event metadata.
184        meta: ClusterEventMeta,
185        /// Shard indices that were skipped.
186        shards: Vec<usize>,
187        /// The peer the shards would have been adopted from.
188        from_peer: String,
189        /// The live node currently holding the shards.
190        held_by: String,
191    },
192    /// A worker joined the connected set.
193    WorkerConnected {
194        /// Cluster-event metadata.
195        meta: ClusterEventMeta,
196        /// Stable worker identifier.
197        worker_id: String,
198        /// Namespaces this worker serves.
199        namespaces: Vec<String>,
200        /// Task-queue pool this worker serves within its namespaces.
201        task_queue: String,
202        /// Delivery transport for this worker.
203        transport: WorkerTransport,
204        /// Locality/node label, when the worker reported one.
205        node: Option<String>,
206    },
207    /// A worker left the connected set.
208    WorkerDisconnected {
209        /// Cluster-event metadata.
210        meta: ClusterEventMeta,
211        /// Stable worker identifier.
212        worker_id: String,
213        /// Namespaces this worker served.
214        namespaces: Vec<String>,
215        /// Why the worker left (see [`WorkerDeathReason`] wiring note).
216        reason: WorkerDeathReason,
217    },
218    /// The cluster supervisor started on this node (lifecycle).
219    ///
220    /// Lets the calm-state view (ADR-019) distinguish "supervisor running, all peers healthy" from
221    /// "supervisor not running".
222    SupervisorStarted {
223        /// Cluster-event metadata.
224        meta: ClusterEventMeta,
225        /// This node's name.
226        node: String,
227    },
228    /// The cluster supervisor stopped on this node (clean drain / shutdown).
229    SupervisorStopped {
230        /// Cluster-event metadata.
231        meta: ClusterEventMeta,
232        /// This node's name.
233        node: String,
234    },
235    /// A brand-new namespace was minted in the durable registry (Control-Plane
236    /// Phase 1, S8).
237    ///
238    /// Emitted exactly once per genuinely-new namespace at the registry's single
239    /// `MintOutcome::Created` choke-point — so the worker-register seam (S5), the
240    /// workflow-start safety net (S6), and the explicit `POST /namespaces` path
241    /// (S7) all surface the same delta. An idempotent re-reference of an existing
242    /// namespace (`MintOutcome::AlreadyExisted`) never emits this, so the ops
243    /// console appends each namespace exactly once with no refresh.
244    ///
245    /// `origin` is the stable `snake_case` label (`worker_mint` / `start_mint` /
246    /// `explicit` / `inferred_from_state`) rather than the `aion-store`
247    /// `NamespaceOrigin` enum, because this leaf crate must not depend on the
248    /// store crate; the label is the same string the audit `tracing` event logs.
249    /// `created_at` is the durable record's first-mint instant, carried so the
250    /// console's created column matches the registry without a follow-up fetch.
251    NamespaceCreated {
252        /// Cluster-event metadata.
253        meta: ClusterEventMeta,
254        /// The minted namespace's name (registry primary key).
255        name: String,
256        /// The durable record's creation instant (its `created_at`).
257        created_at: DateTime<Utc>,
258        /// How the namespace came to exist, as the stable `snake_case` label.
259        origin: String,
260    },
261    /// A namespace's durable placement directive was changed (Control-Plane
262    /// Phase 2, P2-P2 — `PUT /namespaces/{name}/placement`).
263    ///
264    /// Emitted on the SAME deploy-scoped channel as [`Self::NamespaceCreated`]
265    /// after the placement is durably set, so the ops console's namespace panel
266    /// reflects an operator's placement change live with no refresh. `placement`
267    /// is the stable wire projection ([`NamespacePlacementWire`]) of the durable
268    /// `NamespacePlacement`, carried as a `kind` label + label set rather than the
269    /// `aion-store` enum, because this leaf crate must not depend on the store
270    /// crate (the same discipline [`Self::NamespaceCreated`] applies to `origin`).
271    NamespacePlacementChanged {
272        /// Cluster-event metadata.
273        meta: ClusterEventMeta,
274        /// The namespace whose placement changed (registry primary key).
275        name: String,
276        /// The new placement directive, as the stable wire projection.
277        placement: NamespacePlacementWire,
278    },
279    /// A periodic snapshot of a namespace's concurrency-quota state (Control-Plane
280    /// Phase 2, P2-Q3), pushed on the SAME deploy-scoped channel as
281    /// [`Self::NamespaceCreated`] so the ops console renders a live "in-flight /
282    /// ceiling" badge that ticks as work flows.
283    ///
284    /// This is the ONE edge-triggered exception's honest counterweight: unlike the
285    /// other variants (each emitted at a real subsystem mutation), quota state
286    /// changes on every claim/settle, so per-row emission would be a firehose.
287    /// Instead a throttled snapshot task samples each active namespace's REAL
288    /// durable state once per cadence and emits this — a periodic snapshot per
289    /// active namespace, never a client poll. The console folds the latest snapshot
290    /// per namespace, superseding the prior value.
291    ///
292    /// `in_flight` is the durable **Claimed** outbox-row count for the namespace
293    /// (`count_claimed_outbox_rows`) — the SAME notion the keyed backpressure caps,
294    /// never the dead `inflight_activities` gauge (P2-Q0). `ceiling` is the tenant's
295    /// **cluster-wide** contract: its explicit `max_in_flight_activities` override
296    /// or the platform default — the number the operator set, not the per-node
297    /// proportional slice (exposing per-node math is the leaky-abstraction footgun
298    /// §3.6 rejects). On a single node the durable `in_flight` this node sees is the
299    /// whole cluster's; multi-node exact aggregation is the reserved §3.6 follow-up.
300    NamespaceQuotaState {
301        /// Cluster-event metadata.
302        meta: ClusterEventMeta,
303        /// The namespace this quota snapshot describes (registry primary key).
304        namespace: String,
305        /// Durable count of currently-**Claimed** outbox rows for the namespace —
306        /// the in-flight activities the ceiling caps.
307        ///
308        /// Exported to TypeScript as `number`; see the module docs for the accepted
309        /// `2^53` ceiling (an in-flight count never approaches it).
310        in_flight: u64,
311        /// The tenant's cluster-wide concurrency ceiling: its explicit
312        /// `max_in_flight_activities` override, or the platform default when unset.
313        ceiling: u32,
314    },
315}
316
317/// Stable wire projection of a namespace's durable placement directive
318/// (`NamespacePlacement` in `aion-store`), for the ops-console real-time channel
319/// (Control-Plane Phase 2).
320///
321/// Lives in this leaf crate (not `aion-server` / `aion-store`) for the same reason
322/// the rest of [`ClusterEvent`] does: only this crate crosses the Rust ->
323/// TypeScript boundary via `ts-rs`. `kind` is the stable `snake_case` variant tag
324/// (`unplaced` / `prefer` / `pinned`) and `nodes` is the (possibly empty)
325/// node-label set; `Unplaced` carries an empty `nodes`. Modelling it as a flat
326/// `{kind, nodes}` shape rather than re-encoding the store's tagged form keeps the
327/// generated TS binding a single simple type the console can switch on.
328#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq)]
329pub struct NamespacePlacementWire {
330    /// Stable `snake_case` placement-kind tag: `unplaced` / `prefer` / `pinned`.
331    pub kind: String,
332    /// The placement's node-label set, deterministically ordered. Empty for
333    /// `unplaced`; the preferred/required labels for `prefer`/`pinned`.
334    pub nodes: Vec<String>,
335}
336
337/// A peer entry in the priming [`ClusterSnapshot`].
338#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq)]
339pub struct ClusterPeer {
340    /// The peer's node name.
341    pub peer_name: String,
342    /// The peer's forwarding address, when known.
343    pub forward_addr: Option<String>,
344    /// Whether the peer is currently observed connected.
345    pub connected: bool,
346    /// Consecutive ticks observed down (0 when connected).
347    pub consecutive_down: u32,
348}
349
350/// A shard ownership entry in the priming [`ClusterSnapshot`].
351#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq)]
352pub struct ClusterShard {
353    /// Shard index.
354    pub shard: usize,
355    /// The node that currently owns the shard.
356    pub owner: String,
357    /// The epoch fence value at which the owner holds the shard.
358    ///
359    /// Exported to TypeScript as `number`; see the module docs for the accepted `2^53` ceiling.
360    pub epoch: u64,
361}
362
363/// A connected-worker entry in the priming [`ClusterSnapshot`].
364#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq)]
365pub struct ClusterWorker {
366    /// Stable worker identifier.
367    pub worker_id: String,
368    /// Namespaces this worker serves (already intersected with the caller's grants by the gate).
369    pub namespaces: Vec<String>,
370    /// Task-queue pool this worker serves.
371    pub task_queue: String,
372    /// Delivery transport for this worker.
373    pub transport: WorkerTransport,
374    /// Locality/node label, when reported.
375    pub node: Option<String>,
376}
377
378/// A calm-state baseline of the whole cluster, sent as the priming reply before the live delta
379/// stream so the ops console can render an at-a-glance "all clear" before any [`ClusterEvent`]
380/// arrives (ADR-019). On `cluster_lagged` the client re-requests this rather than replaying a
381/// (non-durable) delta history.
382#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq)]
383pub struct ClusterSnapshot {
384    /// The reading node's own name (self-identity; baselines the `Peer*` deltas which describe
385    /// *other* nodes).
386    pub node: String,
387    /// The `cluster_seq` this snapshot is consistent as-of; the client applies only deltas with a
388    /// strictly greater `cluster_seq`.
389    ///
390    /// Exported to TypeScript as `number`; see the module docs for the accepted `2^53` ceiling.
391    pub as_of_seq: u64,
392    /// Watched peers and their current liveness.
393    pub peers: Vec<ClusterPeer>,
394    /// Shards owned by (or visible to) the reading node.
395    pub shards: Vec<ClusterShard>,
396    /// Connected workers, already gated to the caller's namespaces.
397    pub workers: Vec<ClusterWorker>,
398}
399
400/// A command the ops console can issue against the cluster channel (ADR-020 command seam).
401///
402/// Defined now for contract coherence. **Phase 1 ships only [`Self::RequestClusterSnapshot`]** (a
403/// read). The mutating variants compile so the contract exists, but their handlers reject with an
404/// `unimplemented` wire error — and, per ADR-020, MUST still run the full auth gate
405/// (`caller.deploy_granted()`) *before* rejecting, so the seam's authorization contract is
406/// exercised now and an `unimplemented` stub is never an auth-bypass-shaped hole.
407///
408/// Tagged on `command` (distinct from [`ClusterEvent`]'s `type`) because commands and events are
409/// different directions on the wire; the ops console's protocol parser keys command frames on
410/// `command` and event frames on `type`.
411#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq)]
412#[serde(tag = "command")]
413pub enum ClusterCommand {
414    /// Read the current cluster baseline (peers + shards + workers). Read-only; needs only
415    /// namespace-or-deploy scope. **Phase 1.**
416    RequestClusterSnapshot {},
417    /// Cancel a running workflow. Aspirational — handler returns `unimplemented`.
418    CancelWorkflow {
419        /// Owning namespace.
420        namespace: String,
421        /// Target workflow.
422        workflow_id: String,
423    },
424    /// Reopen a failed/closed workflow. Aspirational — handler returns `unimplemented`.
425    ReopenWorkflow {
426        /// Owning namespace.
427        namespace: String,
428        /// Target workflow.
429        workflow_id: String,
430    },
431    /// Redrive a single outbox row. Aspirational — handler returns `unimplemented`.
432    RedriveOutboxRow {
433        /// Owning namespace.
434        namespace: String,
435        /// Target workflow.
436        workflow_id: String,
437        /// Outbox row ordinal.
438        ordinal: u64,
439    },
440    /// Drain a node (stop-new-work + finish-in-flight + safe shutdown). Aspirational.
441    DrainNode {
442        /// Target node name.
443        node: String,
444    },
445    /// Planned epoch-fenced shard handoff to a target node. Aspirational.
446    PlannedHandoff {
447        /// Shard to move.
448        shard: usize,
449        /// Destination node.
450        target_node: String,
451    },
452    /// Test-only chaos kill of a node. Aspirational (gated).
453    ChaosKillNode {
454        /// Target node name.
455        node: String,
456    },
457}
458
459/// A typed terminal error on the cluster channel.
460///
461/// Mirrors the workflow path's lagged contract: when a subscriber falls behind the bounded cluster
462/// broadcast buffer the server sends exactly one of these and closes, carrying the skipped count so
463/// the client can decide snapshot-vs-resume (it always re-requests a [`ClusterSnapshot`], since
464/// there is no durable cluster history). Surfaced to the UI as a typed error, never silently
465/// dropped (ADR-016).
466#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq)]
467#[serde(tag = "kind")]
468pub enum ClusterStreamError {
469    /// The subscriber lagged past the bounded broadcast buffer; `skipped` deltas were dropped.
470    ClusterLagged {
471        /// Number of cluster events dropped because the subscriber fell behind.
472        ///
473        /// Exported to TypeScript as `number`; see the module docs for the accepted `2^53` ceiling.
474        skipped: u64,
475    },
476}