Skip to content

Commit 847821f

Browse files
committed
feat(sync): implement delete-on-ack transport with idempotent frame stamping
Transport layer changes for the durable outbound queue model: - Add `dispatch_acks` module: each ack type is handled in its own function that applies `AckStatus` (Applied/Duplicate → delete durable entry; Fenced → halt push; Gap → warn) and advances the stream seq frontier via `record_ack`. - `dispatch_frame`: route all engine ack types to `dispatch_acks`; handle `AckStatus` on `DeltaAck` (fenced halt, gap warning). - `connect_and_run`: reload persisted producer state before reconnect, clear engine in-flight maps so durable entries are eligible for re-drain, persist server-assigned producer state after handshake. - Push loop: stamp every outbound frame with `producer_id`, `epoch`, and a stable per-collection `seq` (assigned once, reused on re-sends). Halt all push when fenced. Run stale in-flight cleanup with AIMD multiplicative decrease every tick. - `SyncDelegate` trait: all pending/ack methods are now async; add `next_stream_seq`, `set_pending_delta_seq`, `persist_*_seq` for durable seq stamping; add `clear_engine_in_flight`, `load/persist _producer_state`.
1 parent 047f9bf commit 847821f

13 files changed

Lines changed: 999 additions & 199 deletions

File tree

nodedb-lite/src/sync/transport/connect.rs

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,29 @@ pub(super) async fn connect_and_run(
2525
client: &Arc<SyncClient>,
2626
delegate: &Arc<dyn SyncDelegate>,
2727
) -> Result<(), LiteError> {
28-
// Reset state for a fresh connection.
28+
// Reload durable producer identity so the outbound path has a valid
29+
// producer_id/accepted_epoch before the handshake is built. This is a
30+
// no-op on the very first connect (returns 0, 0) and a restore on
31+
// reconnect (returns the last values Origin assigned).
32+
let (persisted_producer_id, persisted_accepted_epoch) = delegate.load_producer_state().await;
33+
client
34+
.load_producer_state(persisted_producer_id, persisted_accepted_epoch)
35+
.await;
36+
37+
// Reset per-connection inbound state (shape LSN gaps, flow control).
38+
// The per-stream seq frontier (StreamSeqTracker) is NOT reset here —
39+
// it is loaded once from storage at startup and never cleared on reconnect
40+
// so outbound frame numbering resumes from the durable last_assigned.
41+
// The fenced flag is cleared so this attempt can push; if Origin still
42+
// fences the producer the flag will be set again on the first ack.
2943
client.reset_sequence_tracking().await;
3044
client.reset_flow_control().await;
45+
client.clear_fenced();
46+
47+
// Clear in-flight maps for all engine outbound queues. The durable entries
48+
// are still in storage; clearing in-flight makes them eligible for
49+
// re-drain → re-send → Origin deduplicates via its idempotent gate.
50+
delegate.clear_engine_in_flight().await;
3151

3252
// ── Connect ──
3353
let (ws_stream, _response) = tokio_tungstenite::connect_async(&client.config().url)
@@ -93,6 +113,12 @@ pub(super) async fn connect_and_run(
93113
});
94114
}
95115

116+
// Durably persist the server-assigned producer identity so it survives
117+
// process restart and is available on the next reconnect.
118+
delegate
119+
.persist_producer_state(ack.producer_id, ack.accepted_epoch)
120+
.await;
121+
96122
// ── Message loop ──
97123
let sink = Arc::new(Mutex::new(sink));
98124

nodedb-lite/src/sync/transport/delegate.rs

Lines changed: 179 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,11 @@ use crate::sync::outbound::vector::{PendingVectorDelete, PendingVectorInsert};
2828
pub trait SyncDelegate: Send + Sync + 'static {
2929
/// Get all pending CRDT deltas to push to Origin.
3030
fn pending_deltas(&self) -> Vec<PendingDelta>;
31+
/// Assign a stable stream seq to a pending CRDT delta (first-send only).
32+
///
33+
/// No-op if the delta already has a non-zero seq, so the same seq is
34+
/// reused on reconnect re-sends and Origin can deduplicate.
35+
async fn set_pending_delta_seq(&self, mutation_id: u64, seq: u64);
3136
/// Acknowledge deltas up to the given mutation_id.
3237
fn acknowledge(&self, mutation_id: u64);
3338
/// Reject a specific delta (rollback optimistic state).
@@ -73,40 +78,189 @@ pub trait SyncDelegate: Send + Sync + 'static {
7378
fn handle_array_reject(&self, msg: &nodedb_types::sync::wire::ArrayRejectMsg);
7479

7580
// ── Columnar ─────────────────────────────────────────────────────────────
76-
fn pending_columnar_batches(&self) -> Vec<PendingColumnarBatch>;
77-
fn acknowledge_columnar_batch(&self, batch_id: u64);
78-
fn reject_columnar_batch(&self, batch: PendingColumnarBatch);
81+
/// Drain up to `PUSH_DRAIN_LIMIT` pending batches from durable storage,
82+
/// skipping any currently in-flight entries.
83+
///
84+
/// Returns `(durable_key, batch)` pairs. On successful send, call
85+
/// `mark_columnar_batch_in_flight`. The durable entry is deleted only when
86+
/// Origin's ack arrives via `ack_columnar_batch_in_flight`.
87+
async fn pending_columnar_batches(&self) -> Vec<(Vec<u8>, PendingColumnarBatch)>;
88+
/// Record that a columnar batch has been sent and is awaiting Origin ack.
89+
async fn mark_columnar_batch_in_flight(&self, batch_id: u64, durable_key: Vec<u8>);
90+
/// On Origin ack: remove in-flight record and delete the durable entry.
91+
async fn ack_columnar_batch_in_flight(&self, batch_id: u64);
92+
/// Delete the durable entry for a confirmed batch directly (used for
93+
/// un-encodable entries that must be discarded at send time).
94+
async fn acknowledge_columnar_batch(&self, durable_key: Vec<u8>);
7995

8096
// ── Vector ───────────────────────────────────────────────────────────────
81-
fn pending_vector_inserts(&self) -> Vec<PendingVectorInsert>;
82-
fn acknowledge_vector_insert(&self, batch_id: u64);
83-
fn reject_vector_insert(&self, entry: PendingVectorInsert);
97+
/// Drain up to `PUSH_DRAIN_LIMIT` pending insert entries, skipping in-flight.
98+
async fn pending_vector_inserts(&self) -> Vec<(Vec<u8>, PendingVectorInsert)>;
99+
/// Record that a vector insert has been sent and is awaiting Origin ack.
100+
async fn mark_vector_insert_in_flight(&self, batch_id: u64, durable_key: Vec<u8>);
101+
/// On Origin ack: remove in-flight record and delete the durable entry.
102+
async fn ack_vector_insert_in_flight(&self, batch_id: u64);
103+
/// Delete the durable insert entry directly (for un-encodable entries).
104+
async fn acknowledge_vector_insert(&self, durable_key: Vec<u8>);
84105

85-
fn pending_vector_deletes(&self) -> Vec<PendingVectorDelete>;
86-
fn acknowledge_vector_delete(&self, batch_id: u64);
87-
fn reject_vector_delete(&self, entry: PendingVectorDelete);
106+
/// Drain up to `PUSH_DRAIN_LIMIT` pending delete entries, skipping in-flight.
107+
async fn pending_vector_deletes(&self) -> Vec<(Vec<u8>, PendingVectorDelete)>;
108+
/// Record that a vector delete has been sent and is awaiting Origin ack.
109+
async fn mark_vector_delete_in_flight(&self, batch_id: u64, durable_key: Vec<u8>);
110+
/// On Origin ack: remove in-flight record and delete the durable entry.
111+
async fn ack_vector_delete_in_flight(&self, batch_id: u64);
112+
/// Delete the durable delete entry directly (for un-encodable entries).
113+
async fn acknowledge_vector_delete(&self, durable_key: Vec<u8>);
88114

89115
// ── FTS ──────────────────────────────────────────────────────────────────
90-
fn pending_fts_indexes(&self) -> Vec<PendingFtsIndex>;
91-
fn acknowledge_fts_index(&self, batch_id: u64);
92-
fn reject_fts_index(&self, entry: PendingFtsIndex);
116+
/// Drain up to `PUSH_DRAIN_LIMIT` pending index entries, skipping in-flight.
117+
async fn pending_fts_indexes(&self) -> Vec<(Vec<u8>, PendingFtsIndex)>;
118+
/// Record that an FTS index entry has been sent and is awaiting Origin ack.
119+
async fn mark_fts_index_in_flight(&self, batch_id: u64, durable_key: Vec<u8>);
120+
/// On Origin ack: remove in-flight record and delete the durable entry.
121+
async fn ack_fts_index_in_flight(&self, batch_id: u64);
122+
/// Delete the durable index entry directly (for un-encodable entries).
123+
async fn acknowledge_fts_index(&self, durable_key: Vec<u8>);
93124

94-
fn pending_fts_deletes(&self) -> Vec<PendingFtsDelete>;
95-
fn acknowledge_fts_delete(&self, batch_id: u64);
96-
fn reject_fts_delete(&self, entry: PendingFtsDelete);
125+
/// Drain up to `PUSH_DRAIN_LIMIT` pending FTS delete entries, skipping in-flight.
126+
async fn pending_fts_deletes(&self) -> Vec<(Vec<u8>, PendingFtsDelete)>;
127+
/// Record that an FTS delete entry has been sent and is awaiting Origin ack.
128+
async fn mark_fts_delete_in_flight(&self, batch_id: u64, durable_key: Vec<u8>);
129+
/// On Origin ack: remove in-flight record and delete the durable entry.
130+
async fn ack_fts_delete_in_flight(&self, batch_id: u64);
131+
/// Delete the durable delete entry directly (for un-encodable entries).
132+
async fn acknowledge_fts_delete(&self, durable_key: Vec<u8>);
97133

98134
// ── Spatial ──────────────────────────────────────────────────────────────
99-
fn pending_spatial_inserts(&self) -> Vec<PendingSpatialInsert>;
100-
fn acknowledge_spatial_insert(&self, batch_id: u64);
101-
fn reject_spatial_insert(&self, entry: PendingSpatialInsert);
135+
/// Drain up to `PUSH_DRAIN_LIMIT` pending insert entries, skipping in-flight.
136+
async fn pending_spatial_inserts(&self) -> Vec<(Vec<u8>, PendingSpatialInsert)>;
137+
/// Record that a spatial insert has been sent and is awaiting Origin ack.
138+
async fn mark_spatial_insert_in_flight(&self, batch_id: u64, durable_key: Vec<u8>);
139+
/// On Origin ack: remove in-flight record and delete the durable entry.
140+
async fn ack_spatial_insert_in_flight(&self, batch_id: u64);
141+
/// Delete the durable insert entry directly (for un-encodable entries).
142+
async fn acknowledge_spatial_insert(&self, durable_key: Vec<u8>);
102143

103-
fn pending_spatial_deletes(&self) -> Vec<PendingSpatialDelete>;
104-
fn acknowledge_spatial_delete(&self, batch_id: u64);
105-
fn reject_spatial_delete(&self, entry: PendingSpatialDelete);
144+
/// Drain up to `PUSH_DRAIN_LIMIT` pending spatial delete entries, skipping in-flight.
145+
async fn pending_spatial_deletes(&self) -> Vec<(Vec<u8>, PendingSpatialDelete)>;
146+
/// Record that a spatial delete has been sent and is awaiting Origin ack.
147+
async fn mark_spatial_delete_in_flight(&self, batch_id: u64, durable_key: Vec<u8>);
148+
/// On Origin ack: remove in-flight record and delete the durable entry.
149+
async fn ack_spatial_delete_in_flight(&self, batch_id: u64);
150+
/// Delete the durable delete entry directly (for un-encodable entries).
151+
async fn acknowledge_spatial_delete(&self, durable_key: Vec<u8>);
106152

107153
// ── Timeseries ───────────────────────────────────────────────────────────
108-
fn pending_timeseries_batches(&self) -> Vec<PendingTimeseriesBatch>;
109-
/// Acknowledge all pending batches for a collection (Origin confirmed receipt).
110-
fn acknowledge_timeseries_collection(&self, collection: &str);
111-
fn reject_timeseries_batch(&self, batch: PendingTimeseriesBatch);
154+
/// Drain up to `PUSH_DRAIN_LIMIT` pending batches, skipping in-flight entries.
155+
async fn pending_timeseries_batches(&self) -> Vec<(Vec<u8>, PendingTimeseriesBatch)>;
156+
/// Record that a timeseries batch has been sent and is awaiting Origin ack.
157+
///
158+
/// Keyed by `stream_seq` because `TimeseriesAckMsg` echoes `applied_seq`
159+
/// but not `batch_id`.
160+
async fn mark_timeseries_batch_in_flight(&self, stream_seq: u64, durable_key: Vec<u8>);
161+
/// On Origin ack: delete all durable entries whose seq ≤ `applied_seq`.
162+
async fn ack_timeseries_batches_through_seq(&self, applied_seq: u64);
163+
/// Delete the durable entry directly (for empty/un-encodable batches).
164+
async fn acknowledge_timeseries_batch(&self, durable_key: Vec<u8>);
165+
166+
// ── Stable seq persistence ────────────────────────────────────────────────
167+
168+
/// Persist an assigned stream seq into the durable columnar entry at `key`.
169+
///
170+
/// Must be called before sending the frame. If this returns an error the
171+
/// caller must NOT send — it should retain the entry for the next drain tick.
172+
async fn persist_columnar_seq(
173+
&self,
174+
key: &[u8],
175+
batch: &PendingColumnarBatch,
176+
) -> Result<(), crate::error::LiteError>;
177+
178+
/// Persist an assigned stream seq into the durable timeseries entry at `key`.
179+
async fn persist_timeseries_seq(
180+
&self,
181+
key: &[u8],
182+
batch: &PendingTimeseriesBatch,
183+
) -> Result<(), crate::error::LiteError>;
184+
185+
/// Persist an assigned stream seq into the durable vector insert entry at `key`.
186+
async fn persist_vector_insert_seq(
187+
&self,
188+
key: &[u8],
189+
insert: &PendingVectorInsert,
190+
) -> Result<(), crate::error::LiteError>;
191+
192+
/// Persist an assigned stream seq into the durable vector delete entry at `key`.
193+
async fn persist_vector_delete_seq(
194+
&self,
195+
key: &[u8],
196+
delete: &PendingVectorDelete,
197+
) -> Result<(), crate::error::LiteError>;
198+
199+
/// Persist an assigned stream seq into the durable FTS index entry at `key`.
200+
async fn persist_fts_index_seq(
201+
&self,
202+
key: &[u8],
203+
entry: &PendingFtsIndex,
204+
) -> Result<(), crate::error::LiteError>;
205+
206+
/// Persist an assigned stream seq into the durable FTS delete entry at `key`.
207+
async fn persist_fts_delete_seq(
208+
&self,
209+
key: &[u8],
210+
entry: &PendingFtsDelete,
211+
) -> Result<(), crate::error::LiteError>;
212+
213+
/// Persist an assigned stream seq into the durable spatial insert entry at `key`.
214+
async fn persist_spatial_insert_seq(
215+
&self,
216+
key: &[u8],
217+
insert: &PendingSpatialInsert,
218+
) -> Result<(), crate::error::LiteError>;
219+
220+
/// Persist an assigned stream seq into the durable spatial delete entry at `key`.
221+
async fn persist_spatial_delete_seq(
222+
&self,
223+
key: &[u8],
224+
delete: &PendingSpatialDelete,
225+
) -> Result<(), crate::error::LiteError>;
226+
227+
// ── Reconnect ────────────────────────────────────────────────────────────
228+
229+
/// Clear all engine in-flight maps on reconnect.
230+
///
231+
/// The durable entries are still in storage and will be re-drained on the
232+
/// next push tick. Origin's idempotent gate deduplicates re-sent batches.
233+
async fn clear_engine_in_flight(&self);
234+
235+
// ── Producer state ───────────────────────────────────────────────────────
236+
237+
/// Durably persist the server-assigned `producer_id` and `accepted_epoch`
238+
/// so they survive process restart and can be reloaded on reconnect.
239+
///
240+
/// Called after every successful handshake acknowledgement.
241+
async fn persist_producer_state(&self, producer_id: u64, accepted_epoch: u64);
242+
243+
/// Load the last-persisted `producer_id` and `accepted_epoch`.
244+
///
245+
/// Returns `(0, 0)` if no state has been persisted yet (first run).
246+
/// Called at the start of each connection attempt so the client has its
247+
/// identity available before the outbound handshake is built.
248+
async fn load_producer_state(&self) -> (u64, u64);
249+
250+
// ── Stream sequence ──────────────────────────────────────────────────────
251+
252+
/// Assign the next monotonic sequence number for the given `stream_id`.
253+
///
254+
/// Delegates to `StreamSeqTracker::next_seq` which persists before
255+
/// returning (persist-before-send invariant). On storage error, logs a
256+
/// warning and returns `0` — a zero seq applies unconditionally on Origin,
257+
/// so this is safe degradation with no data loss.
258+
async fn next_stream_seq(&self, stream_id: u64) -> u64;
259+
260+
/// Record that Origin has applied `applied_seq` for `stream_id`.
261+
///
262+
/// Delegates to `StreamSeqTracker::record_ack`. Errors are logged and
263+
/// ignored — the last_assigned frontier already prevents re-sending
264+
/// un-acked seqs, so this is a refinement of the acknowledged frontier.
265+
async fn record_stream_ack(&self, stream_id: u64, applied_seq: u64);
112266
}

0 commit comments

Comments
 (0)