Skip to content

Commit ef0996c

Browse files
committed
feat(backup): per-tenant write-HLC high-water and restore staleness gate
Restoring an older backup into a cluster that has already committed newer writes for the same tenant silently rolls those writes back. This change introduces a per-tenant monotonic high-water mark on SharedState that records the HLC wall-ns of every successful dispatch and uses it in RESTORE to reject envelopes whose snapshot_watermark predates the destination's observed write timeline. SharedState gains a `tenant_write_hlc` map and an `advance_tenant_write_hlc` helper that is called from every successful dispatch path: gateway core, dispatch_utils, pgwire sync_dispatch, and pgwire handler dispatch. The backup orchestrator captures the envelope's `snapshot_watermark` from `hlc_clock.now()` AFTER completing the per-node fan-out, so the stamped watermark is guaranteed to be >= any tenant_write_hlc advanced by the orchestrator's own dispatches. Envelopes with watermark == 0 (pre-feature backups) are passed through for compatibility.
1 parent ce43a62 commit ef0996c

9 files changed

Lines changed: 129 additions & 8 deletions

File tree

nodedb/src/control/backup/orchestrator.rs

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -36,20 +36,38 @@ pub async fn backup_tenant(state: &Arc<SharedState>, tenant_id: u32) -> Result<B
3636
let nodes = unique_origin_nodes(state);
3737
let snapshot_plan = PhysicalPlan::Meta(MetaOp::CreateTenantSnapshot { tenant_id });
3838

39-
let meta = EnvelopeMeta {
40-
tenant_id,
41-
source_vshard_count: VSHARD_COUNT,
42-
hash_seed: 0, // VSHARD_COUNT-derived hash; no seed today
43-
snapshot_watermark: 0, // global watermark capture is a future enhancement
44-
};
45-
let mut writer = EnvelopeWriter::new(meta);
46-
39+
// Collect per-node sections first. The orchestrator's own
40+
// dispatches advance the tenant write-HLC high-water via
41+
// `dispatch_async`; capturing the envelope watermark AFTER the
42+
// fan-out guarantees `envelope.watermark ≥ tenant_write_hlc`
43+
// at backup time, so a subsequent restore of this envelope into
44+
// the same (unchanged) cluster passes the staleness gate.
45+
let mut sections = Vec::with_capacity(nodes.len());
4746
for node_id in nodes {
4847
let body = if is_self(state, node_id) {
4948
snapshot_self(state, tenant_id, &snapshot_plan).await?
5049
} else {
5150
snapshot_remote(state, node_id, tenant_id, &snapshot_plan).await?
5251
};
52+
sections.push((node_id, body));
53+
}
54+
55+
// Capture a cluster-wide logical instant for the envelope via the
56+
// HLC. `hlc_clock.now()` advances past any previously observed
57+
// local or remote HLC — the wall-ns component is the scalar
58+
// watermark we stamp into the header. Restore compares this
59+
// against the destination's `tenant_write_hlc` to detect stale
60+
// envelopes.
61+
let snapshot_watermark = state.hlc_clock.now().wall_ns;
62+
let meta = EnvelopeMeta {
63+
tenant_id,
64+
source_vshard_count: VSHARD_COUNT,
65+
hash_seed: 0, // VSHARD_COUNT-derived hash; no seed today
66+
snapshot_watermark,
67+
};
68+
let mut writer = EnvelopeWriter::new(meta);
69+
70+
for (node_id, body) in sections {
5371
writer
5472
.push_section(node_id, body)
5573
.map_err(|e| Error::Internal {

nodedb/src/control/backup/restore.rs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,34 @@ pub async fn restore_tenant(
8181
});
8282
}
8383

84+
// Staleness gate. An envelope whose captured watermark is older
85+
// than any dispatch this destination cluster has already observed
86+
// for the same tenant would silently roll back newer committed
87+
// writes. Reject unconditionally today — a `FORCE` override is
88+
// the operator's escape hatch and is a separate DDL decision.
89+
// Envelopes with `snapshot_watermark == 0` come from a source
90+
// that did not capture a watermark (pre-gate envelopes); let them
91+
// through for compatibility — the gate only rejects real
92+
// ordered-against-ordered comparisons.
93+
if !dry_run && env.meta.snapshot_watermark != 0 {
94+
let current_high_water = state
95+
.tenant_write_hlc
96+
.lock()
97+
.ok()
98+
.and_then(|map| map.get(&tenant_id).copied())
99+
.unwrap_or(0);
100+
if env.meta.snapshot_watermark < current_high_water {
101+
return Err(Error::Internal {
102+
detail: format!(
103+
"restore refused: envelope watermark {} is older than the \
104+
destination cluster's last observed write-HLC {} for tenant \
105+
{} — newer writes would be silently overwritten",
106+
env.meta.snapshot_watermark, current_high_water, tenant_id
107+
),
108+
});
109+
}
110+
}
111+
84112
let mut stats = RestoreStats {
85113
tenant_id,
86114
dry_run,

nodedb/src/control/gateway/core.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,18 @@ impl Gateway {
104104
0,
105105
result.is_ok(),
106106
);
107+
108+
// Advance per-tenant observed write-HLC high-water on any
109+
// successful cluster dispatch (local or remote). Used by
110+
// RESTORE staleness gate. Tracking on success of every
111+
// gateway.execute is intentional: backup captures its
112+
// envelope watermark AFTER its own fan-out, so a fresh
113+
// backup's watermark always dominates the tenant_wm it
114+
// itself advanced.
115+
if result.is_ok() {
116+
self.shared.advance_tenant_write_hlc(ctx.tenant_id.as_u32());
117+
}
118+
107119
result
108120
}
109121

nodedb/src/control/server/dispatch_utils.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,15 @@ pub async fn dispatch_to_data_plane_with_source(
245245
}
246246
}
247247

248+
// Advance the tenant's observed write-HLC high-water on any
249+
// successful dispatch. Used by RESTORE staleness gate. Advance
250+
// on every success (not just writes) is intentionally
251+
// conservative — envelope.watermark is captured AFTER fan-out so
252+
// it always dominates the tenant_wm of a fresh backup.
253+
if response.status == crate::bridge::envelope::Status::Ok {
254+
shared.advance_tenant_write_hlc(tenant_id.as_u32());
255+
}
256+
248257
observe(shared);
249258
Ok(response)
250259
}

nodedb/src/control/server/pgwire/ddl/sync_dispatch.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,5 +98,16 @@ pub async fn dispatch_async_with_source(
9898
return Err(crate::Error::Internal { detail });
9999
}
100100

101+
// Advance the tenant's observed write-HLC high-water. Used by
102+
// RESTORE to reject stale envelopes. Tracking on every dispatch
103+
// (not just known-write ops) is intentional: advance is
104+
// monotonic, and capturing the backup envelope's watermark AFTER
105+
// its own fan-out ensures envelope.wm ≥ tenant_wm on a fresh
106+
// backup (so a same-cluster roundtrip passes the staleness gate).
107+
// Reached only after the `resp.status != Ok` early-return above, so
108+
// this point is the "success" branch per the advance_tenant_write_hlc
109+
// contract.
110+
state.advance_tenant_write_hlc(tenant_id.as_u32());
111+
101112
Ok(resp.payload.to_vec())
102113
}

nodedb/src/control/server/pgwire/handler/dispatch.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,22 @@ impl NodeDbPgHandler {
1616
/// In cluster mode, write operations are proposed to Raft first and only
1717
/// executed on the Data Plane after quorum commit. Reads bypass Raft.
1818
pub(super) async fn dispatch_task(&self, task: PhysicalTask) -> crate::Result<Response> {
19+
let tenant_id = task.tenant_id;
20+
let result = self.dispatch_task_inner(task).await;
21+
// Advance per-tenant observed write-HLC high-water on any
22+
// successful dispatch (local, raft-replicated, or broadcast).
23+
// Used by RESTORE's staleness gate. Backup captures envelope
24+
// watermark AFTER its own fan-out, so envelope.wm dominates
25+
// tenant_wm on a fresh backup.
26+
if let Ok(ref resp) = result
27+
&& resp.status == crate::bridge::envelope::Status::Ok
28+
{
29+
self.state.advance_tenant_write_hlc(tenant_id.as_u32());
30+
}
31+
result
32+
}
33+
34+
async fn dispatch_task_inner(&self, task: PhysicalTask) -> crate::Result<Response> {
1935
if matches!(
2036
task.plan,
2137
crate::bridge::envelope::PhysicalPlan::Document(

nodedb/src/control/state/fields.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,14 @@ pub struct SharedState {
352352
/// before every stamp so cross-node causality is preserved.
353353
pub hlc_clock: Arc<nodedb_types::HlcClock>,
354354

355+
/// Per-tenant monotonic high-water HLC wall-ns observed for any
356+
/// dispatched plan. Used by RESTORE to reject envelopes whose
357+
/// captured watermark is older than the destination cluster's
358+
/// most recent observed dispatch for the same tenant —
359+
/// silently overwriting newer committed writes would otherwise
360+
/// be a correctness bug.
361+
pub tenant_write_hlc: Arc<std::sync::Mutex<std::collections::HashMap<u32, u64>>>,
362+
355363
/// Replicated descriptor lease drain state.
356364
/// Written by the metadata applier on `DescriptorDrainStart`
357365
/// / `DescriptorDrainEnd` raft entries (and implicitly

nodedb/src/control/state/init.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,7 @@ impl SharedState {
185185
ts_partition_registries: Some(Mutex::new(std::collections::HashMap::new())),
186186
cold_storage: None,
187187
hlc_clock: Arc::new(nodedb_types::HlcClock::new()),
188+
tenant_write_hlc: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
188189
lease_drain: Arc::new(crate::control::lease::DescriptorDrainTracker::new()),
189190
lease_refcount: Arc::new(crate::control::lease::LeaseRefCount::new()),
190191
tuning: TuningConfig::default(),
@@ -443,6 +444,7 @@ impl SharedState {
443444
ts_partition_registries: Some(Mutex::new(std::collections::HashMap::new())),
444445
cold_storage: None,
445446
hlc_clock: Arc::new(nodedb_types::HlcClock::new()),
447+
tenant_write_hlc: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
446448
lease_drain: Arc::new(crate::control::lease::DescriptorDrainTracker::new()),
447449
lease_refcount: Arc::new(crate::control::lease::LeaseRefCount::new()),
448450
tuning,

nodedb/src/control/state/methods.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,23 @@ use crate::types::TenantId;
1010
use super::SharedState;
1111

1212
impl SharedState {
13+
/// Advance the per-tenant observed write-HLC high-water to the current
14+
/// HLC wall time. Idempotent and monotonic: no-op if a larger value is
15+
/// already recorded. Callers MUST invoke this only after a successful
16+
/// dispatch; "success" is defined as `Response.status == Status::Ok`
17+
/// (and, for `Result<Response>` callers, `Result::Ok` as well). A
18+
/// poisoned lock is silently ignored — the high-water is best-effort
19+
/// and the RESTORE staleness gate treats missing entries as zero.
20+
pub fn advance_tenant_write_hlc(&self, tenant_id: u32) {
21+
let wall = self.hlc_clock.now().wall_ns;
22+
if let Ok(mut map) = self.tenant_write_hlc.lock() {
23+
let entry = map.entry(tenant_id).or_insert(0);
24+
if wall > *entry {
25+
*entry = wall;
26+
}
27+
}
28+
}
29+
1330
/// Shared HTTP client reused by every outbound emitter. Cloning the
1431
/// Arc is cheap — the client itself owns a connection pool, DNS
1532
/// resolver, and TLS session cache that every caller benefits from.

0 commit comments

Comments
 (0)