fix: entry deletion tombstone creation for cross-device sync#3014
fix: entry deletion tombstone creation for cross-device sync#3014
Conversation
Entry deletions were not creating tombstones in device_state_tombstones, so deletions never synced to peer devices. Location and volume deletions worked because their managers manually created tombstones, but entries (the most common case) were missing this entirely. Changes: - Add create_tombstone() to Syncable trait with generic ConnectionTrait parameter so it works inside transactions - Add tombstone creation to the entry deletion path in DatabaseAdapter, with device_db_id cached at construction to avoid per-deletion queries - Refactor location/volume managers to use the unified create_tombstone API instead of manual tombstone insertion - Add deletion-specific test helpers (wait_for_deletion_sync, count_tombstones, create_entry_tombstone) - Add sync_deletion_test.rs with 6 test cases: location deletion sync, volume deletion sync, entry tombstone creation, entry deletion sync, cascading folder deletion, and tombstone race condition protection Co-authored-by: Cursor <[email protected]>
PR SummaryMedium Risk Overview Adds a unified Updates the persistent indexing Written by Cursor Bugbot for commit 8f4daab. Configure here. |
| ) | ||
| .exec(db) | ||
| .await | ||
| .ok(); // Ignore duplicate conflict (idempotent) |
There was a problem hiding this comment.
Swallowing all DB errors here makes it hard to detect real failures (disk full, schema mismatch, etc). With on_conflict(...).do_nothing(), duplicates are already idempotent, so it’s safe to just propagate errors.
| .ok(); // Ignore duplicate conflict (idempotent) | |
| .exec(db) | |
| .await?; | |
| Ok(()) |
| }; | ||
|
|
||
| // Create tombstone for root entry only (cascading design) | ||
| // This must happen before database deletion for atomicity |
There was a problem hiding this comment.
Minor nit: this comment says “atomicity”, but create_tombstone(..., &self.db) runs outside the transaction that actually deletes the rows (let txn = self.db.begin() below). If the intent is “tombstone insert + deletes commit together”, consider moving the tombstone insert to after let txn = ... and passing &txn instead (you already made create_tombstone accept ConnectionTrait for this).
| use crate::infra::sync::Syncable; | ||
| entities::location::Model::create_tombstone(location_id, location.device_id, &txn) | ||
| .await | ||
| .map_err(|e| LocationError::Other(format!("Failed to create tombstone: {}", e)))?; |
There was a problem hiding this comment.
Since this is a DB error, it might be nicer to keep it in a DB-flavored variant (so callers can pattern-match / aggregate) while still preserving context.
| .map_err(|e| LocationError::Other(format!("Failed to create tombstone: {}", e)))?; | |
| .map_err(|e| LocationError::DatabaseError(format!("Failed to create tombstone: {e}")))?; |
| .parent() | ||
| .unwrap() | ||
| .join("docs"); | ||
| let device_record = entities::device::Entity::find() |
There was a problem hiding this comment.
This query is a bit fragile once you register Bob (Alice’s DB now has multiple devices). Filtering by UUID makes it deterministic and keeps the test future-proof.
| let device_record = entities::device::Entity::find() | |
| let device_record = entities::device::Entity::find() | |
| .filter(entities::device::Column::Uuid.eq(device_alice_id)) | |
| .one(library_alice.db().conn()) | |
| .await? | |
| .ok_or_else(|| anyhow::anyhow!("Device not found"))?; |
| .unwrap() | ||
| .join("crates") | ||
| .join("sdk"); | ||
| let device_record = entities::device::Entity::find() |
There was a problem hiding this comment.
Same thing here: .find().one() can become nondeterministic if the test ever ends up with multiple devices in the DB. Filtering by device_id keeps it stable.
| let device_record = entities::device::Entity::find() | |
| let device_record = entities::device::Entity::find() | |
| .filter(entities::device::Column::Uuid.eq(device_id)) | |
| .one(library.db().conn()) | |
| .await? | |
| .ok_or_else(|| anyhow::anyhow!("Device not found"))?; |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
Bugbot Autofix is OFF. To automatically fix reported issues with Cloud Agents, enable Autofix in the Cursor dashboard.
| .exec(db) | ||
| .await | ||
| .ok(); // Ignore duplicate conflict (idempotent) | ||
| Ok(()) |
There was a problem hiding this comment.
Tombstone creation silently swallows all database errors
High Severity
The create_tombstone method uses .ok() to discard the Result from exec(), then unconditionally returns Ok(()). This swallows all database errors, not just the expected conflict case. All three callers (location/manager.rs, volume/manager.rs, persistent.rs) have error handling with .map_err()? that is now dead code. If tombstone creation fails due to a connection error or other database issue, the deletion will proceed without a tombstone, causing deletions to not sync to peer devices—the exact problem this PR intends to fix. The original code properly propagated errors with ?.
Additional Locations (2)
| entities::entry::Model::create_tombstone(root_uuid, self.device_db_id, &self.db) | ||
| .await | ||
| .map_err(|e| anyhow::anyhow!("Failed to create tombstone: {}", e))?; | ||
| } |
There was a problem hiding this comment.
Entry tombstone creation lacks transactional atomicity with deletion
Medium Severity
The tombstone creation uses &self.db (a raw DatabaseConnection) while the comment claims "for atomicity". The deletion transaction starts on line 401 with self.db.begin() - a separate transaction. If the tombstone commits but the deletion transaction fails/rolls back, peer devices will delete their copies (via the tombstone) while the local device retains the entry, causing cross-device data inconsistency. The location/manager.rs and volume/manager.rs implementations correctly pass &txn to create_tombstone to achieve actual atomicity.
Added a new step to the setup-system action for Windows that configures the environment for LLVM. This includes setting the LIBCLANG_PATH, CC, and CXX variables, and modifying the PATH to prevent CMake from using Clang instead of MSVC's cl.exe. This change enhances compatibility for builds on Windows systems.


Summary
create_tombstone()to theSyncabletrait as a unified API, with a genericConnectionTraitparameter so it works inside transactions.DatabaseAdapter, withdevice_db_idcached at construction to avoid per-deletion queries.sync_deletion_test.rswith 6 test cases covering location, volume, and entry deletion sync, cascading folder deletion, and tombstone race condition protection.Test plan
cargo test -p sd-core --test sync_deletion_test -- --test-threads=1 --nocaptureto verify all 6 deletion sync tests passcargo test -p sd-coreto verify no regressions in existing testscargo clippyto verify no new warningsMade with Cursor