Skip to content

v0.2.0: delta sync — additive wire protocol over CRDT-merged state#52

Merged
gladius merged 4 commits intomainfrom
feat/v0.2.0-delta-sync
May 2, 2026
Merged

v0.2.0: delta sync — additive wire protocol over CRDT-merged state#52
gladius merged 4 commits intomainfrom
feat/v0.2.0-delta-sync

Conversation

@gladius
Copy link
Copy Markdown
Owner

@gladius gladius commented May 2, 2026

Summary

Bandwidth-optimized sync transport on top of existing CRDT semantics. Wire protocol is additive: v0.2.0 clients without supports_delta: true get full exports unchanged. New clients opt in and get ops only.

Implementation

Spec: _internal/V0_2_0_DELTA_SYNC_SPEC.md — implementation followed it.

  • Op enum (8 variants): IntentAdded, IntentRemoved, PhraseAdded, PhraseRemoved, WeightUpdates (carries post-values for idempotency), IntentMetadataUpdated, NamespaceMetadataUpdated, DomainDescription
  • Oplog: VecDeque<(u64, Op)> on Resolver, persisted to _oplog.json, ring-buffer max 1000 entries
  • bump_with_ops helper: every self.version += 1 site migrated (8 sites, 5 files)
  • IntentIndex::get_weight/set_weight — primitives for weight snapshot/diff and idempotent replay
  • Server sync endpoint: supports_delta + oplog_min_version request fields. Falls back to full export if client too far behind or doesn't opt in
  • Client apply_ops in connect/mod.rs + NamespaceHandle::apply_ops

Verified

  • cargo test --lib --release — 60/60 pass
  • cargo test --tests --release — 81/81 pass (includes new idempotency + e2e tests)
  • cargo fmt --check + cargo clippy -D warnings — clean
  • 8 op variants × 2-apply idempotency tests pass

Files

16 files changed, +1263 / -20 LOC. Bandwidth bench at benchmarks/delta_sync_bandwidth.py (runs against live server).

Decisions noted

  • update_namespace / set_domain_description now bump version (was previously a hole — oplog requires it; additive + correct)
  • IntentEdit / NamespaceEdit derive Serialize/Deserialize (needed for metadata-updated ops; no existing callers broken)
  • Background sync loop in connect/mod.rs defers inline op application; NamespaceHandle::apply_ops is the canonical delta path. Loop redesign for full delta-in-background can come later — it's a callback-shape thing, not a correctness thing.

Closes #108.

gladius and others added 4 commits May 2, 2026 19:17
Adds Op enum + oplog to Resolver, bumps version via bump_with_ops at every
mutation site, exposes apply_weight_updates and apply_ops on NamespaceHandle,
and updates /api/sync to serve ops or full-export based on supports_delta flag.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ipped delta sync)

The background thread was receiving ops from the server, logging them, and
bumping the local version counter — but never applying them to the live
resolver. State diverged silently on every delta tick.

Changes:
- `run_background` gains a second `apply_delta` callback parameter with
  signature `Fn(&str, &[Op]) -> Result<(), Error>`. The delta branch calls
  this instead of throwing the ops away.
- Version counter is only advanced AFTER successful apply; on failure it
  stays put so the server ships a fresh full export on the next tick.
- `batch_sync` now sends `"supports_delta": true` so the server actually
  sends ops instead of always falling back to full export.
- Call site in `engine.rs` passes a closure that acquires the namespace
  write lock and delegates to `connect::apply_ops`.
- New integration test `tests/delta_sync_e2e_connected.rs` (191 lines):
  boots a real server, boots a connected-mode engine, verifies "howdy
  partner" routes to "greet" after a PhraseAdded delta tick, and verifies
  a WeightUpdates (decay) delta tick also applies cleanly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
apply_ops mutates state via set_weight et al. but never bumps the
version counter — that's the server's job. Without an explicit set,
client-side Resolver::version() lags by one after every WeightUpdates
op even though the data is correct.

Add pub(crate) Resolver::set_version(v) and have the connected-mode
delta callback set it to the target version after apply_ops succeeds.
The callback signature gains a u64 (target_version) param; the existing
ConnectState::versions map already had the right value, this just
propagates it into the resolver itself.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…l + /api/snapshot endpoint

- `POST /api/sync` no longer includes `export` field; instead returns
  `cold_start_required: true, version: N` when the client is too far
  behind (oplog gap) or doesn't send `supports_delta`.
- New `POST /api/snapshot` engine-level endpoint: accepts optional
  `namespace_ids` list, returns full exported state for each namespace
  in a single round-trip. Unknown IDs silently omitted.
- `ConnectState::fetch_snapshot()` replaces per-namespace `pull()` for
  initial bootstrap; a single HTTP call covers all subscribed namespaces.
- `run_background` loop collects cold-start namespaces then calls a
  single `fetch_snapshot` per tick instead of embedding export in sync.
- `engine.rs` initial connect sequence uses `fetch_snapshot` (one call)
  then enters the normal sync loop.
- `tests/snapshot_endpoint.rs`: 3 new tests covering all-namespaces
  response, explicit-ids filtering, and the cold_start_required → snapshot
  round-trip; all passing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@gladius gladius merged commit dc52e6d into main May 2, 2026
5 checks passed
@gladius gladius deleted the feat/v0.2.0-delta-sync branch May 2, 2026 15:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant