Skip to main content

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}