Skip to content

Commit f7fcc77

Browse files
committed
feat(crdt): add per-row bounded range export for local ops
A deferred batch of local writes needs to be split into one self-contained delta per row so each can be committed independently downstream. Since a peer's own ops occupy a contiguous range in its op sequence, exporting a bounded IdSpan over that range yields exactly one row's operations at a cost proportional to the span width rather than everything since a version.
1 parent 65121d5 commit f7fcc77

1 file changed

Lines changed: 103 additions & 0 deletions

File tree

nodedb-crdt/src/state/history.rs

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,40 @@ impl CrdtState {
5656
.map_err(|e| CrdtError::Loro(format!("delta export: {e}")))
5757
}
5858

59+
/// Next op counter this document's own peer will author.
60+
///
61+
/// Equivalently, how many ops this peer has authored so far. Looked up
62+
/// directly in the oplog version vector rather than tracked separately,
63+
/// so it can never drift from what `doc.oplog_vv()` actually reports.
64+
pub fn local_op_counter(&self) -> i32 {
65+
self.doc.oplog_vv().get(&self.peer_id).copied().unwrap_or(0)
66+
}
67+
68+
/// Export exactly the ops this document's own peer authored in
69+
/// `[from_counter, to_counter)`.
70+
///
71+
/// A deferred batch of local writes must be split into one
72+
/// self-contained delta per row so each can be committed independently
73+
/// downstream. Because each row's ops occupy a contiguous range in this
74+
/// peer's own op sequence (interleaving never happens within a single
75+
/// author), a bounded range export over that peer's `IdSpan` yields
76+
/// exactly that row's operations — nothing from before, nothing from
77+
/// after, and nothing from any other peer. Unlike `export_updates_since`,
78+
/// whose cost is proportional to everything after a version, this is
79+
/// bounded by the width of the requested span.
80+
///
81+
/// An empty or inverted range (`to_counter <= from_counter`) is not an
82+
/// error — it simply exports nothing.
83+
pub fn export_local_range(&self, from_counter: i32, to_counter: i32) -> Result<Vec<u8>> {
84+
if to_counter <= from_counter {
85+
return Ok(Vec::new());
86+
}
87+
let span = loro::IdSpan::new(self.peer_id, from_counter, to_counter);
88+
self.doc
89+
.export(loro::ExportMode::updates_in_range(vec![span]))
90+
.map_err(|e| CrdtError::Loro(format!("bounded range export failed: {e}")))
91+
}
92+
5993
/// Compact history at a specific version (not just current frontiers).
6094
///
6195
/// Discards oplog entries before the target version. Current state and
@@ -303,4 +337,73 @@ mod tests {
303337
.is_empty()
304338
);
305339
}
340+
341+
#[test]
342+
fn local_op_counter_starts_at_zero_and_advances() {
343+
let state = CrdtState::new(1).expect("state");
344+
assert_eq!(state.local_op_counter(), 0);
345+
346+
state
347+
.upsert("docs", "a", &[("v", LoroValue::I64(1))])
348+
.expect("write a");
349+
state.doc.commit();
350+
let after_a = state.local_op_counter();
351+
assert!(after_a > 0);
352+
353+
state
354+
.upsert("docs", "b", &[("v", LoroValue::I64(2))])
355+
.expect("write b");
356+
state.doc.commit();
357+
let after_b = state.local_op_counter();
358+
assert!(after_b > after_a);
359+
}
360+
361+
#[test]
362+
fn export_local_range_round_trips_one_row() {
363+
let state = CrdtState::new(1).expect("state");
364+
365+
let start_a = state.local_op_counter();
366+
state
367+
.upsert("docs", "a", &[("v", LoroValue::I64(1))])
368+
.expect("write a");
369+
state.doc.commit();
370+
let end_a = state.local_op_counter();
371+
372+
state
373+
.upsert("docs", "b", &[("v", LoroValue::I64(2))])
374+
.expect("write b");
375+
state.doc.commit();
376+
377+
let row_a_delta = state
378+
.export_local_range(start_a, end_a)
379+
.expect("export row a range");
380+
381+
let target = CrdtState::new(2).expect("target state");
382+
target.import(&row_a_delta).expect("import row a delta");
383+
384+
assert!(target.row_exists("docs", "a"));
385+
assert!(!target.row_exists("docs", "b"));
386+
}
387+
388+
#[test]
389+
fn empty_range_exports_nothing() {
390+
let state = CrdtState::new(1).expect("state");
391+
state
392+
.upsert("docs", "a", &[("v", LoroValue::I64(1))])
393+
.expect("write a");
394+
state.doc.commit();
395+
396+
let delta = state.export_local_range(5, 5).expect("empty range export");
397+
assert!(delta.is_empty());
398+
399+
// An empty range yields no bytes at all — there is nothing to send, and
400+
// an empty blob is NOT importable (it carries no header). Callers must
401+
// skip an empty export rather than enqueue or import it.
402+
let target = CrdtState::new(2).expect("target state");
403+
assert!(
404+
target.import(&delta).is_err(),
405+
"an empty export is not a valid delta; callers must skip it"
406+
);
407+
assert!(!target.row_exists("docs", "a"));
408+
}
306409
}

0 commit comments

Comments
 (0)