nodedb_cluster/metadata_group/cache.rs
1// SPDX-License-Identifier: BUSL-1.1
2
3//! Per-node in-memory view of the replicated metadata state.
4//!
5//! The cache tracks everything `nodedb-cluster` natively understands:
6//! the applied raft index, the HLC watermark, topology / routing
7//! change history, descriptor leases, and cluster version. It
8//! **does not** maintain per-DDL-object descriptor state — that
9//! lives on the host side via
10//! `nodedb::control::catalog_entry::CatalogEntry::apply_to` writing
11//! into `SystemCatalog` redb. The `CatalogDdl { payload }` variant
12//! is opaque here: the cache tracks its applied index and forwards
13//! the payload to the host's `MetadataCommitApplier`.
14
15use std::collections::HashMap;
16
17use nodedb_types::Hlc;
18use tracing::{debug, info, warn};
19
20use crate::error::{ClusterError, MigrationCheckpointError};
21use crate::metadata_group::compensation::Compensation;
22use crate::metadata_group::descriptors::{DescriptorId, DescriptorLease};
23use crate::metadata_group::entry::{MetadataEntry, RoutingChange, TopologyChange};
24use crate::metadata_group::migration_state::{
25 MigrationPhaseTag, PersistedMigrationCheckpoint, SharedMigrationStateTable,
26};
27use crate::routing::RoutingTable;
28
29/// In-memory view of the committed metadata state.
30#[derive(Debug, Default)]
31pub struct MetadataCache {
32 pub applied_index: u64,
33 pub last_applied_hlc: Hlc,
34
35 /// `(descriptor_id, node_id) -> lease`.
36 pub leases: HashMap<(DescriptorId, u64), DescriptorLease>,
37
38 /// Topology mutations applied so far.
39 pub topology_log: Vec<TopologyChange>,
40 pub routing_log: Vec<RoutingChange>,
41
42 pub cluster_version: u16,
43
44 /// Monotonically-increasing count of committed `CatalogDdl`
45 /// entries. Exposed for tests and metrics — planners read
46 /// catalog state through the host-side `SystemCatalog`, not
47 /// this counter.
48 pub catalog_entries_applied: u64,
49}
50
51impl MetadataCache {
52 pub fn new() -> Self {
53 Self::default()
54 }
55
56 /// Advance the applied-index watermark past a committed Raft no-op (a
57 /// leader-transition entry with no `MetadataEntry` payload).
58 ///
59 /// A no-op mutates no cache state, but it IS applied, so the cache watermark
60 /// must move in lockstep with the Raft applied index the tick loop reports.
61 /// Otherwise the startup applied-index sanity check reads `cache_applied` <
62 /// `watcher_current` and fails the boot with a spurious gap — which is
63 /// exactly what happens on a fresh single-node start, where every group's
64 /// first committed entry is an election no-op at index 1. Monotonic: never
65 /// regresses the watermark.
66 pub fn advance_applied_index(&mut self, index: u64) {
67 if index > self.applied_index {
68 self.applied_index = index;
69 }
70 }
71
72 /// Apply a committed entry. Idempotent by `applied_index`:
73 /// entries at or below the current watermark are ignored.
74 pub fn apply(&mut self, index: u64, entry: &MetadataEntry) {
75 if index != 0 && index <= self.applied_index {
76 debug!(
77 index,
78 watermark = self.applied_index,
79 "metadata cache: skipping already-applied entry"
80 );
81 return;
82 }
83 self.applied_index = index;
84
85 match entry {
86 MetadataEntry::CatalogDdl { payload: _ } | MetadataEntry::CatalogDdlAudited { .. } => {
87 // Opaque to the cluster crate. The host-side applier
88 // decodes the payload and writes through to
89 // `SystemCatalog`. We just count it — both DDL
90 // shapes contribute to `catalog_entries_applied`.
91 self.catalog_entries_applied += 1;
92 }
93 MetadataEntry::TopologyChange(change) => self.topology_log.push(change.clone()),
94 MetadataEntry::RoutingChange(change) => self.routing_log.push(change.clone()),
95
96 MetadataEntry::ClusterVersionBump { from, to } => {
97 if *from != self.cluster_version && self.cluster_version != 0 {
98 warn!(
99 expected = self.cluster_version,
100 got = *from,
101 "cluster version bump mismatch"
102 );
103 }
104 self.cluster_version = *to;
105 }
106
107 MetadataEntry::DescriptorLeaseGrant(lease) => {
108 if lease.expires_at > self.last_applied_hlc {
109 self.last_applied_hlc = lease.expires_at;
110 }
111 self.leases
112 .insert((lease.descriptor_id.clone(), lease.node_id), lease.clone());
113 }
114 MetadataEntry::DescriptorLeaseRelease {
115 node_id,
116 descriptor_ids,
117 } => {
118 for id in descriptor_ids {
119 self.leases.remove(&(id.clone(), *node_id));
120 }
121 }
122 // Drain state is host-side (lives in
123 // `nodedb::control::lease::DescriptorDrainTracker`);
124 // the cluster-side cache only tracks lease state
125 // directly. These no-op arms keep the exhaustive
126 // match coverage so adding new variants is a
127 // compile-time error here too.
128 MetadataEntry::DescriptorDrainStart { expires_at, .. } => {
129 if *expires_at > self.last_applied_hlc {
130 self.last_applied_hlc = *expires_at;
131 }
132 }
133 MetadataEntry::DescriptorDrainEnd { .. } => {}
134 MetadataEntry::DdlPrepareAcquire { .. } | MetadataEntry::DdlPrepareRelease { .. } => {
135 // Host-side ephemeral coordination state; replay rebuilds it.
136 }
137 MetadataEntry::DdlPrepared { .. } => {
138 // The host-side applier unwraps and fences this entry. The cache
139 // owns no preparation outcome state.
140 }
141 MetadataEntry::CaTrustChange { .. } => {
142 // CA trust mutations are host-side only: the production
143 // applier in the nodedb crate writes/deletes
144 // `tls/ca.d/<fp>.crt` and rebuilds the rustls config.
145 // Cluster cache has nothing to track.
146 }
147 MetadataEntry::SyncProducerRegister { .. } => {
148 // host-side only: the production applier calls
149 // `SyncProducerRegistry::apply_register` on every node.
150 }
151 MetadataEntry::SyncProducerFence { .. } => {
152 // host-side only: the production applier calls
153 // `SyncProducerRegistry::apply_fence` on every node.
154 }
155 MetadataEntry::SurrogateAlloc { .. } => {
156 // Surrogate HWM advance is host-side only: the production
157 // applier calls `SurrogateRegistry::restore_hwm`. The
158 // cluster cache has no surrogate state to track.
159 }
160 MetadataEntry::SurrogateReserve { .. } => {
161 // Surrogate batch reservation is host-side only: the
162 // production applier advances the global watermark via
163 // `SurrogateRegistry::reserve_from_global` on every node
164 // and installs the batch on the owning node. The cluster
165 // cache has no surrogate state to track.
166 }
167 MetadataEntry::JoinTokenTransition { .. } => {
168 // Token lifecycle transitions are enforced by the bootstrap-
169 // listener handler at apply time. The cluster cache records
170 // the applied index but carries no per-token state — the
171 // host-side token store is authoritative.
172 }
173 MetadataEntry::Batch { entries } => {
174 for sub in entries {
175 self.apply(index, sub);
176 }
177 }
178 // Migration checkpoint/abort upserts are handled by the
179 // live-state applier (`CacheApplier`) which holds the
180 // `SharedMigrationStateTable` handle. The cache itself
181 // has no migration state to track beyond applied_index.
182 MetadataEntry::MigrationCheckpoint { .. } => {}
183 MetadataEntry::MigrationAbort { .. } => {}
184 }
185 }
186}
187
188// ── Migration live-state application helpers ─────────────────────────────────
189
190/// Apply a `MigrationCheckpoint` entry against the shared state table.
191///
192/// Returns `Err` on CRC32C mismatch (fatal — corruption is louder than data
193/// loss) and on storage failures. Idempotent: if
194/// `(migration_id, phase, attempt)` is already present, the call is a no-op.
195pub fn apply_migration_checkpoint(
196 table: &SharedMigrationStateTable,
197 migration_id: uuid::Uuid,
198 _phase: MigrationPhaseTag,
199 attempt: u32,
200 payload: crate::metadata_group::migration_state::MigrationCheckpointPayload,
201 expected_crc: u32,
202 ts_ms: u64,
203) -> Result<(), ClusterError> {
204 // Validate CRC32C against the encoded payload bytes.
205 let actual_crc = payload.crc32c()?;
206 if actual_crc != expected_crc {
207 return Err(ClusterError::MigrationCheckpoint(
208 MigrationCheckpointError::Crc32cMismatch {
209 migration_id,
210 expected: expected_crc,
211 actual: actual_crc,
212 },
213 ));
214 }
215
216 let row = PersistedMigrationCheckpoint {
217 migration_id: migration_id.hyphenated().to_string(),
218 attempt,
219 payload,
220 crc32c: actual_crc,
221 ts_ms,
222 };
223
224 let mut guard = table.lock().unwrap_or_else(|p| p.into_inner());
225 guard.upsert(row)
226}
227
228/// Apply a `MigrationAbort` entry against the shared state table and
229/// live routing table.
230///
231/// Applies each compensation in order; any failure is fatal (no
232/// warn-and-continue — a partial abort is as broken as a partial commit).
233/// On success, removes the migration row from the state table.
234pub fn apply_migration_abort(
235 table: &SharedMigrationStateTable,
236 routing: Option<&std::sync::Arc<std::sync::RwLock<RoutingTable>>>,
237 migration_id: uuid::Uuid,
238 reason: &str,
239 compensations: &[Compensation],
240) -> Result<(), ClusterError> {
241 info!(
242 migration_id = %migration_id,
243 reason,
244 steps = compensations.len(),
245 "applying migration abort"
246 );
247
248 for (step, comp) in compensations.iter().enumerate() {
249 apply_compensation(routing, step, migration_id, comp)?;
250 }
251
252 let mut guard = table.lock().unwrap_or_else(|p| p.into_inner());
253 guard.remove(&migration_id)
254}
255
256fn apply_compensation(
257 routing: Option<&std::sync::Arc<std::sync::RwLock<RoutingTable>>>,
258 step: usize,
259 migration_id: uuid::Uuid,
260 comp: &Compensation,
261) -> Result<(), ClusterError> {
262 let Some(live) = routing else {
263 // No routing handle attached — log and treat as success.
264 // This path is only reachable in unit tests without live state.
265 debug!(
266 migration_id = %migration_id,
267 step,
268 ?comp,
269 "compensation: no live routing handle, skipping"
270 );
271 return Ok(());
272 };
273
274 let mut rt = live.write().unwrap_or_else(|p| p.into_inner());
275 match comp {
276 Compensation::RemoveLearner { group_id, peer_id }
277 | Compensation::RemoveVoter { group_id, peer_id } => {
278 rt.remove_group_member(*group_id, *peer_id);
279 }
280 Compensation::RestoreLeaderHint { group_id, peer_id } => {
281 rt.set_leader(*group_id, *peer_id);
282 }
283 Compensation::RemoveGhostStub { vshard_id: _ } => {
284 // Ghost stub removal is handled by the caller's ghost_table;
285 // the routing table has no ghost state.
286 }
287 }
288 drop(rt);
289
290 debug!(
291 migration_id = %migration_id,
292 step,
293 ?comp,
294 "compensation applied"
295 );
296 Ok(())
297}