chronogrid/docs/ARCHITECTURE/ADR/0012-core-sync-engine-p2p-i...

5.2 KiB

ADR-0012: chronogrid_core — SyncEngine P2P Integration (Task 2.6)

Status

Accepted

Date

2026-08-22

Context

Task 2.6 required wiring the P2P sync layer from chronogrid_net into chronogrid_core so that CRDT delta packets received from remote peers are automatically merged into the local SQLCipher database, and local CRDT mutations are broadcast to connected peers.

The integration must:

  • Remain optional (not break the existing chronogrid_core build without the p2p feature)
  • Bridge NetEvent::DeltasReceived → Automerge merge → SQL row update
  • Bridge local CRDT mutations → delta computation → SwarmHandle::trigger_sync()
  • Provide merge_remote_deltas() returning affected entity IDs for UI invalidation

Decision

Feature Gate

chronogrid_net is added as an optional dependency in chronogrid_core/Cargo.toml under the p2p feature:

[features]
p2p = ["dep:chronogrid_net", "dep:tokio", "dep:tracing"]

[dependencies]
chronogrid_net = { path = "../chronogrid_net", optional = true }
tokio = { version = "1", features = ["sync", "rt"], optional = true }
tracing = { version = "0.1", optional = true }

This ensures that cargo build --package chronogrid_core (without --features p2p) compiles cleanly with no libp2p or OpenSSL dependencies, keeping the base crate lightweight for FFI and test builds.

SyncEngine Architecture

A new core::sync module is created with a single SyncEngine struct:

crates/chronogrid_core/src/sync/mod.rs

SyncEngine owns:

  • Arc<Mutex<Database>> — shared reference to the encrypted SQLCipher database
  • SwarmHandle — cloneable handle to the running libp2p swarm
  • tokio::sync::broadcast::Receiver<NetEvent> — event stream from the swarm

The engine runs a background Tokio task (tokio::spawn) that loops on event_rx.recv():

  • NetEvent::DeltasReceived { from_peer, deltas } → calls merge_remote_deltas()
  • NetEvent::SyncComplete / NetEvent::SyncError → logged via tracing

Delta Merge Flow

merge_remote_deltas(deltas: Vec<DeltaPacket>) -> Result<Vec<String>>:

  1. For each DeltaPacket, extract entity_id and entity_type
  2. Load existing crdt_docs row from database (or create new empty Automerge doc)
  3. Merge the incoming Automerge changeset bytes via automerge::AutoCommit::load_incremental()
  4. Persist the merged doc bytes back to crdt_docs
  5. Update the corresponding SQL entity row (tasks, notes, time_blocks, etc.) with the merged scalar values
  6. Return the list of affected entity_id strings for UI cache invalidation

Local Mutation → Broadcast Flow

SyncEngine::notify_local_change(entity_id: &str):

  • Calls swarm_handle.trigger_sync() to initiate delta exchange with all connected peers
  • The SwarmManager computes the delta by comparing local VectorClock with each peer's last-known clock

Error Handling

  • Merge errors are logged via tracing::warn! and do not crash the engine
  • Individual delta failures are isolated — one bad packet does not block others
  • CoreError gains a new Sync(String) variant for sync-specific errors

Test Strategy

Unit tests use Database::open_in_memory() and a mock SwarmHandle (created via SwarmHandle::new_for_test()) to avoid requiring a live libp2p swarm:

  • test_merge_remote_deltas_task — merges a task delta, verifies crdt_docs updated
  • test_merge_remote_deltas_note — merges a note delta, verifies crdt_docs updated
  • test_merge_empty_deltas — empty delta list returns empty affected IDs
  • test_sync_engine_new — engine constructs without panicking

Tests are gated behind #[cfg(all(test, feature = "p2p"))].

Consequences

Positive

  • chronogrid_core without --features p2p remains a lightweight, zero-network-dependency crate suitable for FFI and embedded use
  • The SyncEngine provides a clean boundary between network events and database mutations
  • merge_remote_deltas() returns affected entity IDs, enabling targeted UI cache invalidation in Phase 3
  • Automerge's deterministic conflict resolution handles concurrent edits without application-level logic

Negative / Trade-offs

  • The p2p feature adds significant compile-time overhead (libp2p + OpenSSL) on first build
  • SwarmHandle::new_for_test() requires chronogrid_net to expose a test-only constructor, adding a small surface area to the net crate's public API
  • The background Tokio task requires the caller to hold the SyncEngine alive for the duration of the session

Alternatives Considered

Alternative 1: Separate chronogrid_sync Crate

A dedicated crate could own the bridge logic. Rejected because it adds workspace complexity without benefit — the sync engine is tightly coupled to Database internals and belongs in chronogrid_core.

Alternative 2: Callback-Based Integration

Instead of a background task, expose a process_event(NetEvent) method for the caller to drive. Rejected because it pushes event-loop management into the daemon/FFI layer, violating the single-responsibility principle.

Alternative 3: Always-On Dependency

Make chronogrid_net a non-optional dependency. Rejected because it would force OpenSSL compilation for all chronogrid_core consumers (FFI, tests, daemon), significantly increasing build times.