feat(sync): append-only ops log + websocket fan-out + compaction (Phase 1.f) - #19
Conversation
…se 1.f) Server-side end-to-end of the multi-device sync protocol per RFC-001 §6.6: * **Schema** — `sync_op` BIGSERIAL log with double UNIQUE on `(user_id, device_id, operation_id)` (idempotency) + `(user_id, device_id, lamport_ts)` (monotonicity); `device_sync_cursor` tracking per-device read position; `sync_compaction_watermark` floor. All `ON DELETE CASCADE` via `users(id)`. * **REST** — `POST /api/v1/sync/ops` batch push (idempotent replay via `ON CONFLICT operation_id DO NOTHING`, 409 + `stored_max` on lamport regression, all-or-nothing transaction); `GET /api/v1/sync/ops?since=N` pull (410 + `compacted_up_to` when `since < watermark`, read-only — never advances the cursor); `POST /api/v1/sync/ack` (the only path that writes `device_sync_cursor`, buffered in memory + flushed every 5 s with a monotonic UPSERT guard). * **WebSocket** — `GET /api/v1/sync/ws?device_id=…` subscribes to the global tokio `broadcast::Sender`; per-frame `user_id` filter so a cross-tenant op never reaches a foreign socket; pre-serialised envelope so N subscribers share one `Arc<String>`; on-disconnect flush so a tab close doesn't strand the last ACK in memory. * **Compaction** — daily tokio task; flushes pending ACKs first so the MIN reflects the latest device positions (closes the "stale Postgres cursor" race), then in one transaction collapses superseded ops per `(entity, entity_id, COALESCE(field, ''))` at `id <= MIN(last_seen_id)` excluding stale (> 90 d) devices, and UPSERTs the watermark. Watermark monotonic by UPSERT `WHERE` clause. * **Tests** — 10 integration tests covering push/pull round-trip, idempotent replay, lamport regression 409, pull-doesn't-advance- cursor, ACK + flush + monotonicity, resurrected device 410, tenant isolation on pull, oversized batch 400, compaction collapses + watermark advances + stale device skipped, WebSocket fan-out same-tenant + cross-tenant isolation. Wires `SyncHub` into `AppState` + `main.rs` boot. `SyncHub::for_tests` skips the background tasks so the compaction tests drive `flush_acks` / `compact_once` deterministically. Signed-off-by: InstaZDLL <github.105mh@8shield.net>
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Plus Run ID: 📒 Files selected for processing (1)
📝 WalkthroughWalkthroughAjout complet d'une API de synchronisation multi‑appareils (Phase 1.f) : schéma SQL append‑only, SyncHub (broadcast + ACK buffering), flush/compaction périodiques, handlers REST/WS pour push/pull/ack, et tests E2E couvrant les scénarios critiques. ChangesSynchronisation Multi‑Appareils (Phase 1.f)
Sequence Diagram(s)sequenceDiagram
participant Client
participant API as "POST /api/v1/sync/ops"
participant Hub as SyncHub
participant DB as PostgreSQL
participant WS as "WebSocket subscribers"
Client->>API: POST batch ops
API->>DB: INSERT op(s) transaction
DB-->>API: RETURN inserted rows or CONFLICT
API->>Hub: broadcast(newly_inserted ops)
Hub->>WS: send pre-serialized frames to subscribers
Note right of Hub: record_ack updates in-memory accumulator
Hub->>DB: flush_acks (periodic UPSERT device_sync_cursor)
Hub->>DB: compact_once (delete superseded ops & update watermark)
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related issues
Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 7
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@CLAUDE.md`:
- Line 45: The documentation is inconsistent: AppState is documented as
"currently just the PgPool" but Phase 1.f introduces a live sync hub; update the
AppState description to include the new sync field by documenting that AppState
now holds sync: SyncHub (or a reference/wrapper to the SyncHub), briefly
describe its role (broadcast sender + DashMap of AckEntry + compaction/flush
responsibilities) and note any test-only construction (e.g., SyncHub::for_tests)
so contributors know how to wire it and instantiate AppState in tests.
In `@src/api/sync.rs`:
- Around line 195-213: The handler contains inline SQL (the INSERT that sets
insert_res in src/api/sync.rs) which violates the guideline—move this SQL into a
DB/repository function (e.g., create a new function insert_sync_op or
create_sync_operation in src/db.rs or a new src/db/sync.rs or inside
waveflow-core::repository::postgres::*), implement the same sqlx::query(...) and
.bind(...) logic there (accepting the same args: user_id, device_id,
op_in.operation_id, op_in.lamport_ts, entity, entity_id, op_in.field.as_deref(),
op_kind, op_in.payload.as_ref(), now and a &mut Tx/Pool as needed) and return
the same result type (Option/Result of the returned row). Then replace the
inline query in the handler (where insert_res is set) with a call to that new DB
function, passing the transaction/context; do the same refactor for the other
inline queries referenced in this file so handlers remain free of SQL.
- Around line 329-335: The DB error is being swallowed by `.unwrap_or(None)`
when fetching `compacted_up_to` which can incorrectly treat connection/timeouts
as "no watermark"; instead remove the `.unwrap_or(None)` and propagate the error
from `sqlx::query_scalar(...).bind(user_id).fetch_optional(pool).await` (e.g.,
use the `?` operator or map the error into the function's error type) so that
failures when reading `sync_compaction_watermark` surface to the caller; update
the code paths that use the `compacted` variable accordingly to handle the
Result/propagated error.
In `@src/main.rs`:
- Around line 83-97: The comment above the SyncHub::spawn call is inaccurate: it
claims "we don't store the handles" while the code captures them as _flush_task
and _compaction_task; update the comment to reflect that we keep the JoinHandles
in scope (stored in variables with leading underscores to avoid unused warnings)
so they remain alive for the runtime, or remove the mention entirely and state
that SyncHub::spawn returns the hub and two JoinHandles which are intentionally
retained as _flush_task and _compaction_task to keep tasks running.
In `@src/sync.rs`:
- Around line 314-315: The code currently increments the `applied` counter by 1
inside the `match res { Ok(_) => applied += 1, ... }` block, which counts UPSERT
executions rather than actual modified rows; change the logic to extract the
number of affected rows from the execution result (e.g., call `rows_affected()`
or the equivalent method on the DB result returned into `res`) and add that
value to `applied` instead of adding 1, ensuring you handle the `Ok(result)` arm
where you read `result.rows_affected()` and convert to the same integer type as
`applied`.
In `@tests/sync.rs`:
- Around line 227-236: The test uses a fragile tokio::time::sleep to assert no
cursor row is written after spawn_authenticated; replace the
background-task-based setup with the deterministic test harness: use
spawn_app_with_sync (or the app variant that accepts a SyncHub) and create a
SyncHub via SyncHub::for_tests, then call hub.flush_acks().await (and/or
compact_once as needed) before running the COUNT(*) query so the assertion that
GET /sync/ops never advances the cursor is deterministic; update references in
the test from spawn_authenticated to spawn_app_with_sync and ensure you call
SyncHub::for_tests and flush_acks() prior to the SQL check.
- Around line 239-312: The test ack_writes_cursor_after_flush is
timing-dependent: replace the live/background flusher usage (spawn_authenticated
/ implicit flusher) and the polling loop with a deterministic SyncHub test
harness; create a SyncHub via SyncHub::for_tests and start the app with
spawn_app_with_sync (instead of spawn_authenticated), perform the push and ACK
as before, then call sync.flush_acks().await to force the ack flush, and replace
the polling/fetch_optional loop with a single sqlx fetch_one (query_as or
query_scalar) bound to auth.user_id to assert last_seen == head_id; keep
existing checks that re-ACKing with a lower id doesn't regress.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: 5c015c38-6db8-4c32-9773-7f1feb0151d1
⛔ Files ignored due to path filters (1)
Cargo.lockis excluded by!**/*.lock,!Cargo.lock,!*.lock
📒 Files selected for processing (11)
CLAUDE.mdCargo.tomlmigrations/20260601000000_sync_ops.sqlsrc/api/mod.rssrc/api/sync.rssrc/lib.rssrc/main.rssrc/sync.rstests/openapi.rstests/support.rstests/sync.rs
…nistic tests - handler: move every inline `sync_op` / `sync_compaction_watermark` query into `db::sync` (mirroring `db::users`), so `api/sync.rs` stays pure HTTP orchestration per the "no SQL in handlers" rule. New helpers: `insert_op_returning`, `fetch_op_by_operation_id`, `lamport_max`, `fetch_compacted_up_to`, `pull_ops_since`. - watermark + lamport reads: stop `unwrap_or`-ing the `sqlx` errors. A pool / transport hiccup while reading `sync_compaction_watermark` was being silently downgraded to "no floor", which would let a resurrected device sneak past the 410 guard during a flake. Same story for the lamport_max readback after a regression — a `0` fallback would lie to the client about how far the server is ahead. Both paths now surface 500. - flush_acks: count `result.rows_affected()` instead of incrementing by 1 per UPSERT statement. The monotonic UPSERT (`WHERE last_seen_id < EXCLUDED`) matches zero rows when the persisted cursor is already ahead, so the returned tally now reflects the actual DB change. The ack-flush test uses this signal to assert that a regressing ACK produces no flush write. - main.rs: the JoinHandle comment claimed "we don't store the handles" while the code does capture them as `_flush_task` / `_compaction_task`. Rewrote to describe what's actually happening — underscored bindings keep ownership through main's scope. - tests: refactor `pull_does_not_advance_cursor` and `ack_writes_cursor_after_flush` onto the deterministic `SyncHub::for_tests` + `spawn_app_with_sync` harness — no more 25 ms polling loops or 120 ms sleeps. The ACK test now asserts `flush_acks() == 1` on first flush and `== 0` after a regressing ACK, which is a tighter monotonicity check than the live-flusher setup gave. - CLAUDE.md: AppState section was still claiming "currently just the PgPool". Updated to list every field the 1.d / 1.e / 1.f phases added (`jwt_verifier`, `stream_ctx`, `sync`) plus the `SyncHub::for_tests` construction path. Signed-off-by: InstaZDLL <github.105mh@8shield.net>
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
CLAUDE.md (1)
45-45:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winClarifier l'exception
since=0pour le bootstrap.La doc indique « 410 +
compacted_up_towhensince < watermark» mais ne précise pas quesince=0est permis pour le bootstrap initial (mentionné dans les objectifs du PR). Un développeur pourrait croire quesince=0déclenche systématiquement un 410 si le watermark > 0.📝 Clarification suggérée
-GET /ops?since=N` pull (410 + `compacted_up_to` when `since < watermark`, read-only — never advances the cursor) +GET /ops?since=N` pull (410 + `compacted_up_to` when `0 < since < watermark`; `since=0` allowed for bootstrap, read-only — never advances the cursor)🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@CLAUDE.md` at line 45, The docs for GET /ops?since=N are ambiguous about since=0; update the sentence describing "410 + `compacted_up_to` when `since < watermark`" to explicitly state that since=0 is permitted for initial bootstrap and must not be treated as an automatic 410 even when `watermark > 0` — clarify the server behavior for `since=0` (allowed for bootstrap initial sync, how `compacted_up_to` is reported) so readers of the GET /ops?since=N, `compacted_up_to`, and `watermark` behavior understand that `since=0` is a valid bootstrap request.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@CLAUDE.md`:
- Line 45: The docs for GET /ops?since=N are ambiguous about since=0; update the
sentence describing "410 + `compacted_up_to` when `since < watermark`" to
explicitly state that since=0 is permitted for initial bootstrap and must not be
treated as an automatic 410 even when `watermark > 0` — clarify the server
behavior for `since=0` (allowed for bootstrap initial sync, how
`compacted_up_to` is reported) so readers of the GET /ops?since=N,
`compacted_up_to`, and `watermark` behavior understand that `since=0` is a valid
bootstrap request.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: 74589e67-904c-48f0-930a-43cc4bb0114f
📒 Files selected for processing (6)
CLAUDE.mdsrc/api/sync.rssrc/db.rssrc/main.rssrc/sync.rstests/sync.rs
@coderabbitai PR #19 flagged the description of the GET /sync/ops 410 guard as ambiguous — the existing phrasing "410 + compacted_up_to when since < watermark" could be read as "since=0 trips the guard whenever the watermark is non-zero", which would contradict the handler's `if query.since > 0` early-exit. Rephrase to make the bootstrap path explicit. Signed-off-by: InstaZDLL <github.105mh@8shield.net>
Server-side end-to-end of the multi-device sync protocol per RFC-001 §6.6. Closes the server half of Phase 1.f #133; the desktop integration (settings toggle, repository swap, Lamport clock, pending-ops queue, login flow) lands in a follow-up against the WaveFlow desktop repo.
Summary
sync_opBIGSERIAL log with double UNIQUE on(user_id, device_id, operation_id)(idempotency) and(user_id, device_id, lamport_ts)(monotonicity).device_sync_cursortracks per-device read position;sync_compaction_watermarkis the floor below which a pull returns 410 Gone. All three tablesON DELETE CASCADEviausers(id)./api/v1/sync/*:POST /ops— batch push.ON CONFLICT (user_id, device_id, operation_id) DO NOTHINGabsorbs idempotent replays; a(user_id, device_id, lamport_ts)unique violation surfaces as 409 +stored_maxso a stale client can resync its clock. All-or-nothing transaction; broadcast fires only after commit so subscribers never see a rolled-back op.GET /ops?since=N— pull. Read-only by contract — never advances the device cursor (that'sPOST /ack's sole job). 410 Gone +compacted_up_towhensince > 0 AND since < watermark;since == 0always allowed so fresh devices bootstrap cleanly.POST /ack— the only path that writesdevice_sync_cursor. Buffered in memory + flushed every 5 s with a monotonic UPSERT guard (WHERE last_seen_id < EXCLUDED).GET /ws?device_id=…— subscribes to a tokiobroadcast::Sender. Per-frameuser_idequality filter so cross-tenant ops never leave the socket. Pre-serialised envelope so N subscribers share oneArc<String>. On-disconnect flush so a tab close doesn't strand the last ACK in memory.sync::SyncHubholds the broadcastSender+DashMap<(user_id, device_id), AckEntry>+ handles. Live boot spawns the 5 s flusher + daily compaction task inmain.rs.SyncHub::for_testsskips the loops so compaction tests step throughflush_acks/compact_oncedeterministically.last_seen_at < now - 90 d; phase 3 collapses superseded ops per(entity, entity_id, COALESCE(field, ''))atid <= MINAND UPSERTs the watermark in the same Postgres transaction so a crash mid-compaction is atomic. Watermark monotonic by UPSERTWHEREclause.AppState.synceverywhere it needs to land (handlers, WS, tests). Test harness gainsspawn_app_with_syncso sync tests injectSyncHub::for_testswhile the legacy helpers keep wiring a live hub (50 ms flush cadence) so the ack-after-flush path stays exercised in REST tests.Test coverage
10 integration tests in
tests/sync.rs:push_then_pull_round_trip— POST → GET ?since=0 returns the row + correctlast_id; empty pull at head.idempotent_replay_returns_same_id— sameoperation_idtwice yields the sameid, one physical row.lamport_regression_returns_409_with_stored_max— replay at a taken lamport_ts → 409 +stored_max+offending_lamport_ts.pull_does_not_advance_cursor— GET never touchesdevice_sync_cursor.ack_writes_cursor_after_flush— ACK + 5 s flush UPSERTs; re-ACK at lower id refused (monotonic).resurrected_device_returns_410— planted watermark;since < watermark→ 410,since == 0still 200.tenant_isolation_pull— user B's pull never sees user A's ops.oversized_batch_is_rejected— > 1024 ops → 400.compaction_collapses_old_ops_and_advances_watermark— three SETs over same field collapse to one; unrelated entity untouched; watermark = head_id; second pass is a no-op.compaction_skips_stale_devices— device withlast_seen_at = -200 dexcluded from MIN, so the up-to-date device's MIN is used.websocket_fans_out_to_same_tenant— WS subscriber sees a frame from a different device under the same user.websocket_isolates_other_tenants— user B's WS does NOT receive user A's push.Plus
tests/openapi.rsupdated so a future refactor that drops#[utoipa::path]from a sync handler trips the spec assertion.Test plan
/api/v1/sync/ops+/api/v1/sync/ack.Out of scope
tokio::sync::broadcastis single-process. Multi-replica fan-out is a Phase 2 / production-hardening concern.Summary by CodeRabbit