refactor(sync): atomic playlist write + outbox enqueue in one tx#195
Conversation
Closes #193. Closes the drift window #192 documented where the playlist write committed in one SQLite transaction and the matching sync_pending_op row landed in a separate await — a crash or DB hiccup between the two could leave the local state ahead of the queue and the server would never hear about the user's edit. ## waveflow-core Every write method on SqlitePlaylistRepository now has a sibling free function in the same module taking &mut SqliteConnection (insert_custom_conn, update_conn, delete_conn, append_track_conn, append_tracks_conn, remove_track_conn, reorder_track_conn). The trait impls become thin wrappers that acquire pool.begin() and delegate, so every existing caller keeps working unchanged. reorder_track_conn returns Option<i64> (effective position post-clamp) instead of bool, so the caller can stamp the sync payload with the row's actual new state — closes the divergence the #192 readback patch worked around with a post-write SELECT. The trait method maps the Option to a bool for back-compat. ## waveflow desktop - lamport::next_conn, queue::enqueue_conn, mode::read_conn, server_client::read_token_conn — sibling variants that take &mut SqliteConnection so they compose in a caller's open tx. - sync::hooks::enqueue_op_in_tx(conn, draft) — atomic outbox path. Returns AppResult<bool> so the caller can ? the failure and let the surrounding tx roll back the entity write too. The fire-and- forget enqueue_op variant is gone — playlist commands all use the atomic path now, and a future non-playlist hook would too. Module docstring rewritten: atomicity is no longer a documented known-gap, it's the guaranteed shape. ## commands/playlist.rs The 8 mutation commands (create / update / delete / add_track / add_tracks / add_source / remove_track / reorder) all wrap their write + enqueue in a single pool.begin() → ..._conn(&mut tx) → ... → enqueue_op_in_tx(&mut tx) → tx.commit() block. Filesystem-level side effects (playlist_cover::maybe_regen_auto_cover) run OUTSIDE the tx as before — they're not transactional and shouldn't pin the write window open. reorder_playlist_track sheds its post-write SELECT (the position readback #192 added) because reorder_track_conn now returns the effective position directly inside the same tx. ## Tests - 22 desktop sync unit tests still green. - 35 waveflow-core unit tests still green. - The trait impl methods (existing call paths) are now thin wrappers, so behaviour is unchanged for everyone except the newly-tx-aware playlist commands. ## Out of scope - library + edit hooks still use the (now-removed) fire-and-forget path conceptually, but they're not actually hooked anywhere yet (#192 deferred them). When they get wired, they'll use enqueue_op_in_tx like playlist. Signed-off-by: InstaZDLL <github.105mh@8shield.net>
📝 WalkthroughWalkthroughCe changement rend atomiques les mutations playlist et l'enqueue des opérations de sync : le repository expose helpers ChangesAtomicité de la mutation playlist et sync outbox
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Suggested labels
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 docstrings
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src-tauri/crates/app/src/commands/playlist.rs (1)
193-221:⚠️ Potential issue | 🟠 Major | ⚡ Quick winPlace le contrôle d’existence dans la même transaction.
repo.exists()est fait avantpool.begin(). Si une autre écriture supprime la playlist entre les deux,update_conndevient un no-op silencieux et la boucle enfile quand même des ops"set". On recrée donc une divergence local/outbox malgré ce chemin atomique. Il faut faire remonterrows_affected == 0depuisupdate_conn, ou refaire la vérification danstxavant d’enqueue.🤖 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 `@src-tauri/crates/app/src/commands/playlist.rs` around lines 193 - 221, The existence check must happen inside the same transaction to avoid enqueuing ops for a deleted playlist; modify update_conn (or its caller) to surface rows_affected and treat 0 as an error, or perform an existence SELECT using the same transaction before calling enqueue_op_in_tx. Concretely: change update_conn to return the number of rows updated (or a Result<Option<()>>), call it within the tx begun by pool.begin(), check the returned rows_affected == 0 and return an error (aborting before the enqueue loop) so enqueue_op_in_tx and tx.commit() only run for actually-updated playlists.
🤖 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 `@src-tauri/crates/app/src/commands/playlist.rs`:
- Around line 391-404: The code enqueues a "delete" pending op even when
remove_track_conn returned false (nothing removed locally); change the flow in
the block around remove_track_conn, enqueue_op_in_tx and tx.commit so you
capture remove_track_conn(...)'s boolean result and only call
crate::sync::hooks::enqueue_op_in_tx with the PendingOpDraft (and then commit
the transaction) when remove_track_conn returned true; if it returns false, roll
back or drop the transaction without enqueuing the delete op to avoid removing
server-side tracks that weren't removed locally.
---
Outside diff comments:
In `@src-tauri/crates/app/src/commands/playlist.rs`:
- Around line 193-221: The existence check must happen inside the same
transaction to avoid enqueuing ops for a deleted playlist; modify update_conn
(or its caller) to surface rows_affected and treat 0 as an error, or perform an
existence SELECT using the same transaction before calling enqueue_op_in_tx.
Concretely: change update_conn to return the number of rows updated (or a
Result<Option<()>>), call it within the tx begun by pool.begin(), check the
returned rows_affected == 0 and return an error (aborting before the enqueue
loop) so enqueue_op_in_tx and tx.commit() only run for actually-updated
playlists.
🪄 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: c59a2bc0-c9e5-44c9-a093-f4c13a80a0d5
📒 Files selected for processing (7)
src-tauri/crates/app/src/commands/playlist.rssrc-tauri/crates/app/src/server_client.rssrc-tauri/crates/app/src/sync/hooks.rssrc-tauri/crates/app/src/sync/lamport.rssrc-tauri/crates/app/src/sync/mode.rssrc-tauri/crates/app/src/sync/queue.rssrc-tauri/crates/core/src/repository/sqlite/playlist.rs
@coderabbitai PR #195 surfaced two valid windows the initial atomic refactor missed: 1. remove_track_from_playlist enqueued a "delete tracks" op even when remove_track_conn returned false (track wasn't in the playlist — concurrent removal, double-click, stale UI state). The server would then replay the delete and drop a row that legitimately belonged there. Capture the boolean; only enqueue when removed == true. Tx still commits so the no-op stays idempotent from the caller's POV. Cover regen also gated on the removed flag since there's no cover change to recompute. 2. update_playlist did its exists() probe BEFORE opening the tx, so a concurrent delete between the two could leave update_conn touching zero rows while the per-field enqueue loop still fired. Refactor update_conn in waveflow-core to return CoreResult<bool> (rows_affected > 0). The trait method drops the boolean for back-compat (non-tx callers like smart_playlists never consumed it anyway). The command site uses the new signal: same-tx existence check, return the same 404-style error when false, tx auto-rolls-back on drop without enqueueing anything. 22 desktop sync tests still green, 35 core tests still green, clippy + fmt clean. Signed-off-by: InstaZDLL <github.105mh@8shield.net>
Closes #193. Closes the drift window #192 documented where the playlist write committed in one SQLite transaction and the matching `sync_pending_op` row landed in a separate await — a crash or DB hiccup between the two could leave the local state ahead of the queue and the server would never hear about the user's edit.
Summary
Module docstring
`crate::sync::hooks` rewritten — atomicity is no longer a documented known-gap, it's the guaranteed shape. Skip path (no JWT or `SyncMode::Local`) still returns `Ok(false)` without writing.
Test plan
Out of scope
Summary by CodeRabbit
Notes de version