118 lines
5.2 KiB
Markdown
118 lines
5.2 KiB
Markdown
# 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:
|
|
|
|
```toml
|
|
[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.
|