nodedb_cluster/metadata_group/entry.rs
1// SPDX-License-Identifier: BUSL-1.1
2
3//! The canonical wire-type for every entry proposed to the metadata Raft group.
4
5use serde::{Deserialize, Serialize};
6
7use nodedb_types::Hlc;
8
9use crate::metadata_group::compensation::Compensation;
10use crate::metadata_group::descriptors::{DescriptorId, DescriptorLease};
11use crate::metadata_group::migration_state::{MigrationCheckpointPayload, MigrationPhaseTag};
12
13/// An entry in the replicated metadata log.
14///
15/// Every mutation to cluster-wide state — DDL, topology, routing,
16/// descriptor leases, cluster version bumps — is encoded as one of
17/// these variants, proposed against the metadata Raft group, and
18/// applied on every node by a
19/// [`crate::metadata_group::applier::MetadataApplier`].
20///
21/// The `CatalogDdl` variant is the single wire shape for every DDL
22/// mutation. Its `payload` is an opaque, host-serialized
23/// `nodedb::control::catalog_entry::CatalogEntry` value — the
24/// `nodedb-cluster` crate is deliberately ignorant of the host's
25/// per-DDL-object struct shapes. This keeps the cluster crate
26/// layering-clean and makes adding new DDL object types on the
27/// host side a zero-wire-change operation.
28#[derive(
29 Debug,
30 Clone,
31 PartialEq,
32 Eq,
33 Serialize,
34 Deserialize,
35 zerompk::ToMessagePack,
36 zerompk::FromMessagePack,
37)]
38pub enum MetadataEntry {
39 /// Single generic DDL entry carrying an opaque host-side payload.
40 /// Produced by every pgwire DDL handler via
41 /// `nodedb::control::metadata_proposer::propose_catalog_entry`.
42 CatalogDdl {
43 payload: Vec<u8>,
44 },
45
46 /// DDL entry with attached audit context. Produced by pgwire DDL
47 /// handlers that have the authenticated identity + raw statement
48 /// text bound at the call site (every `CREATE`, `ALTER`, `DROP`,
49 /// `GRANT`, `REVOKE` path). Applied identically to `CatalogDdl`
50 /// on every node; additionally, the production applier fsync-
51 /// appends an audit record to the audit segment WAL with the
52 /// authenticated user, HLC at commit, descriptor versions before
53 /// + after, and the raw SQL — exactly what J.4 requires.
54 ///
55 /// Carries its own payload so legacy proposers (internal lease
56 /// and descriptor-drain flows that have no SQL text) can keep
57 /// using the plain `CatalogDdl` variant without synthesizing
58 /// fake audit context.
59 CatalogDdlAudited {
60 payload: Vec<u8>,
61 /// Authenticated user id at propose time.
62 auth_user_id: String,
63 /// Authenticated username at propose time.
64 auth_user_name: String,
65 /// Raw SQL statement as the client sent it. Not parsed here —
66 /// the cluster crate is opaque to SQL syntax. Persisted on
67 /// every replica so post-hoc audit queries don't depend on
68 /// the proposing node still being alive.
69 sql_text: String,
70 },
71
72 /// Atomic batch of metadata entries proposed by a transactional
73 /// DDL session (`BEGIN; CREATE ...; CREATE ...; COMMIT;`). The
74 /// applier unpacks and applies each sub-entry in order at a
75 /// single raft log index, so either all commit or none do.
76 Batch {
77 entries: Vec<MetadataEntry>,
78 },
79
80 /// A catalog DDL proposal fenced by the replicated preparation owner.
81 /// Appliers execute `entry` only while `token` is the current owner;
82 /// superseded proposals are deterministic no-ops so they cannot wedge the
83 /// Raft apply watermark.
84 DdlPrepared {
85 token: u64,
86 entry: Box<MetadataEntry>,
87 },
88
89 /// Acquire the replicated global descriptor-preparation lease. The first
90 /// unexpired owner wins; contenders observe the winner after this entry is
91 /// applied and retry after its matching release or expiry.
92 DdlPrepareAcquire {
93 token: u64,
94 },
95 /// Release the descriptor-preparation lease iff `token` still owns it.
96 DdlPrepareRelease {
97 token: u64,
98 },
99
100 // ── Topology / routing ─────────────────────────────────────────────
101 TopologyChange(TopologyChange),
102 RoutingChange(RoutingChange),
103
104 // ── Cluster version ────────────────────────────────────────────────
105 ClusterVersionBump {
106 from: u16,
107 to: u16,
108 },
109
110 // ── Descriptor leases ──────────────────────────────────────────────
111 DescriptorLeaseGrant(DescriptorLease),
112 DescriptorLeaseRelease {
113 node_id: u64,
114 descriptor_ids: Vec<DescriptorId>,
115 },
116
117 // ── Descriptor lease drain ────────────────────────────────────────
118 /// Begin draining leases on a descriptor. While a drain entry
119 /// is active, any `acquire_descriptor_lease` at
120 /// `version <= up_to_version` must be rejected cluster-wide so
121 /// the in-flight DDL that bumps the version can make progress.
122 ///
123 /// `expires_at` is the HLC at which this drain entry is
124 /// considered stale and ignored by `is_draining` checks on
125 /// read. Acts as a TTL that prevents a crashed proposer from
126 /// leaving an orphaned drain that blocks the cluster forever.
127 DescriptorDrainStart {
128 descriptor_id: DescriptorId,
129 up_to_version: u64,
130 expires_at: Hlc,
131 },
132 /// End draining on a descriptor. Emitted explicitly on drain
133 /// timeout so the cluster can make progress. On the happy
134 /// path (successful `Put*` apply), the host-side applier
135 /// clears drain implicitly — this variant is the escape
136 /// hatch for the failure path.
137 DescriptorDrainEnd {
138 descriptor_id: DescriptorId,
139 },
140
141 /// Cluster-wide CA trust mutation (L.4). Proposed by
142 /// `nodedb rotate-ca --stage` (to add a new CA) and
143 /// `nodedb rotate-ca --finalize --remove <fp>` (to drop an old
144 /// CA). Applied on every node by `MetadataCommitApplier`: writes
145 /// or deletes `data_dir/tls/ca.d/<fp_hex>.crt` and triggers a
146 /// live rebuild of the rustls server + client configs so the
147 /// new trust set takes effect without restart.
148 ///
149 /// `add_ca_cert` and `remove_ca_fingerprint` are independent:
150 /// the `--stage` form sets `add_ca_cert = Some(new_ca_der)` +
151 /// `remove_ca_fingerprint = None`; `--finalize` flips both. A
152 /// single entry carrying both performs the cutover atomically
153 /// once the operator has confirmed every node has reissued.
154 CaTrustChange {
155 /// DER-encoded CA certificate to add to the trust set. `None`
156 /// when this entry only removes.
157 add_ca_cert: Option<Vec<u8>>,
158 /// SHA-256 fingerprint of the CA to remove from the trust set.
159 /// `None` when this entry only adds.
160 remove_ca_fingerprint: Option<[u8; 32]>,
161 },
162
163 // ── Sync producer identity ────────────────────────────────────────
164 /// Replicate a new Lite client registration to every node.
165 ///
166 /// On apply every node writes the same registration row verbatim and
167 /// advances its producer-id allocator HWM to at least `producer_id`, so
168 /// a future leader never reissues the same id after a failover. The
169 /// `producer_id` and initial `epoch` are assigned by the leader before
170 /// proposing, so all replicas store identical state.
171 ///
172 /// Applied host-side only; the cluster cache tracks the applied index.
173 SyncProducerRegister {
174 lite_id: String,
175 producer_id: u64,
176 tenant_id: u64,
177 epoch: u64,
178 created_ms: i64,
179 },
180
181 /// Replicate a fencing epoch advance to every node.
182 ///
183 /// Applied with max-wins semantics: each node sets
184 /// `current_epoch = max(stored_epoch, new_epoch)` so out-of-order
185 /// or duplicate delivery cannot roll the epoch backwards. A fence
186 /// entry with no prior register row is a no-op on that node
187 /// (tolerated for reordering/missing idempotency).
188 ///
189 /// Applied host-side only; the cluster cache tracks the applied index.
190 SyncProducerFence {
191 lite_id: String,
192 new_epoch: u64,
193 },
194
195 // ── Surrogate identity ────────────────────────────────────────────
196 /// Advance the cluster-wide surrogate high-watermark to `hwm`.
197 ///
198 /// Proposed by the metadata-group leader whenever the local
199 /// `SurrogateRegistry` flush threshold trips (every 1024
200 /// allocations or 200 ms, whichever comes first). Applied on
201 /// every node by the host-side `MetadataCommitApplier` which
202 /// calls `SurrogateRegistry::restore_hwm(hwm)` — idempotent and
203 /// monotonic, so out-of-order replay or duplicate delivery are
204 /// both safe. Followers must never allocate surrogates locally;
205 /// they only advance their in-memory HWM via these log entries.
206 SurrogateAlloc {
207 hwm: u32,
208 },
209
210 /// Reserve a disjoint batch of surrogates for one node (HiLo).
211 ///
212 /// The carved `[start, end)` range is **not** carried in the entry:
213 /// it is computed AT APPLY by advancing the cluster-wide surrogate
214 /// global watermark on every node. Because every node applies the
215 /// metadata log in the same order against an identical watermark,
216 /// all nodes deterministically agree on which range this reservation
217 /// carves — guaranteeing globally-unique, disjoint surrogate batches
218 /// without any node ever minting a value another node also minted.
219 ///
220 /// `node_id` + `request_id` identify the requesting node and the
221 /// specific in-flight reservation so the owning node's host-side
222 /// applier can (a) install the carved batch locally and (b) fire the
223 /// completion signal that unblocks the waiting allocation path. Only
224 /// the owning node installs the batch; every other node merely
225 /// advances the watermark so future reservations stay disjoint.
226 SurrogateReserve {
227 node_id: u64,
228 request_id: u64,
229 batch_size: u32,
230 },
231
232 /// Join-token lifecycle transition (L.4). Proposed by the
233 /// bootstrap-listener handler on every state change so that all
234 /// Raft peers can enforce single-use token semantics even after a
235 /// crash-restart cycle.
236 ///
237 /// `token_hash` is the SHA-256 of the token hex string — the raw
238 /// token is never stored in the log. `transition` encodes the
239 /// direction: `Register` for first issuance, `BeginInFlight` when
240 /// a joiner presents the token, `MarkConsumed` when the bundle is
241 /// delivered, `RevertInFlight` when the dead-man timer fires, and
242 /// `MarkExpired` / `MarkAborted` for the terminal states.
243 JoinTokenTransition {
244 token_hash: [u8; 32],
245 transition: JoinTokenTransitionKind,
246 /// Unix-ms timestamp at the time of the proposal.
247 ts_ms: u64,
248 },
249
250 /// Crash-safe migration phase checkpoint. Persisted on every phase
251 /// transition; on coordinator restart, recovery scans the
252 /// `MigrationStateTable` and resumes from the latest committed
253 /// checkpoint. Apply is idempotent on `(migration_id, phase, attempt)`
254 /// — duplicate delivery is a no-op. CRC32C mismatch is fatal.
255 MigrationCheckpoint {
256 /// Hyphenated UUID string (zerompk does not serialize uuid::Uuid).
257 migration_id: String,
258 phase: MigrationPhaseTag,
259 attempt: u32,
260 payload: MigrationCheckpointPayload,
261 crc32c: u32,
262 ts_ms: u64,
263 },
264
265 /// Replicated migration abort with ordered compensations. Each
266 /// compensation is applied in order; any failure is fatal (no
267 /// warn-and-continue). On success, the migration's row in
268 /// `MigrationStateTable` is deleted.
269 MigrationAbort {
270 migration_id: String,
271 reason: String,
272 compensations: Vec<Compensation>,
273 },
274}
275
276/// The direction of a join-token lifecycle transition.
277#[derive(
278 Debug,
279 Clone,
280 PartialEq,
281 Eq,
282 Serialize,
283 Deserialize,
284 zerompk::ToMessagePack,
285 zerompk::FromMessagePack,
286)]
287pub enum JoinTokenTransitionKind {
288 /// New token registered (Issued state). Carries expiry so all nodes
289 /// can enforce the TTL independently.
290 Register { expires_at_ms: u64 },
291 /// Joiner presented the token; transitioning Issued → InFlight.
292 BeginInFlight { node_addr: String },
293 /// Bundle delivered; transitioning InFlight → Consumed.
294 MarkConsumed { node_addr: String },
295 /// Dead-man timer fired; transitioning InFlight → Issued.
296 RevertInFlight,
297 /// Token TTL elapsed without consumption.
298 MarkExpired,
299 /// Explicitly invalidated by an operator.
300 MarkAborted,
301}
302
303/// Topology mutations proposed through the metadata group.
304#[derive(
305 Debug,
306 Clone,
307 PartialEq,
308 Eq,
309 Serialize,
310 Deserialize,
311 zerompk::ToMessagePack,
312 zerompk::FromMessagePack,
313)]
314pub enum TopologyChange {
315 Join { node_id: u64, addr: String },
316 Leave { node_id: u64 },
317 PromoteToVoter { node_id: u64 },
318 StartDecommission { node_id: u64 },
319 FinishDecommission { node_id: u64 },
320}
321
322/// Routing-table mutations proposed through the metadata group.
323#[derive(
324 Debug,
325 Clone,
326 PartialEq,
327 Eq,
328 Serialize,
329 Deserialize,
330 zerompk::ToMessagePack,
331 zerompk::FromMessagePack,
332)]
333pub enum RoutingChange {
334 /// Move a vShard to a new raft group leaseholder.
335 ReassignVShard {
336 vshard_id: u32,
337 new_group_id: u64,
338 new_leaseholder_node_id: u64,
339 },
340 /// Record a leadership transfer within an existing group.
341 LeadershipTransfer {
342 group_id: u64,
343 new_leader_node_id: u64,
344 },
345 /// Remove a node from a Raft group's member and learner sets.
346 ///
347 /// Used by the decommission flow to strip a draining node out of
348 /// every group it belongs to. Proposing this is only safe once
349 /// `safety::check_can_decommission` has confirmed the group will
350 /// still satisfy the configured replication factor.
351 RemoveMember { group_id: u64, node_id: u64 },
352 /// Set the explicit placement (intended voter set) for a Raft group.
353 ///
354 /// Records the target voter membership authored by the placement
355 /// subsystem and replicated to all nodes via the metadata Raft group.
356 /// Consumers compare this against the current `members` to decide
357 /// whether a learner is eligible for promotion.
358 SetPlacement { group_id: u64, placement: Vec<u64> },
359}