Skip to content

fix: entry deletion tombstone creation for cross-device sync#3014

Open
jamiepine wants to merge 2 commits intomainfrom
fix/deletion-sync-tombstones
Open

fix: entry deletion tombstone creation for cross-device sync#3014
jamiepine wants to merge 2 commits intomainfrom
fix/deletion-sync-tombstones

Conversation

@jamiepine
Copy link
Member

Summary

  • Entry deletions were not creating tombstones, so deletions never synced to peer devices. The tombstone infrastructure (protocol, querying, receiving) was fully implemented but the entry sending side was broken.
  • Adds create_tombstone() to the Syncable trait as a unified API, with a generic ConnectionTrait parameter so it works inside transactions.
  • Adds tombstone creation to the entry deletion path in DatabaseAdapter, with device_db_id cached at construction to avoid per-deletion queries.
  • Refactors location/volume managers to use the unified API instead of manual tombstone insertion.
  • Adds sync_deletion_test.rs with 6 test cases covering location, volume, and entry deletion sync, cascading folder deletion, and tombstone race condition protection.

Test plan

  • Run cargo test -p sd-core --test sync_deletion_test -- --test-threads=1 --nocapture to verify all 6 deletion sync tests pass
  • Run cargo test -p sd-core to verify no regressions in existing tests
  • Run cargo clippy to verify no new warnings
  • Manual test: index a location on device A, sync to device B, delete a file on device A, verify it disappears on device B after incremental sync

Made with Cursor

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]>
@cursor
Copy link

cursor bot commented Feb 6, 2026

PR Summary

Medium Risk
Touches deletion/sync semantics and introduces new DB writes during indexing-driven deletes, which could affect data correctness if tombstones are created with the wrong device/model identifiers. Changes are localized and covered by new end-to-end deletion sync tests, reducing regression risk.

Overview
Fixes cross-device deletion sync by ensuring local entry deletions emit a deletion tombstone (previously missing, so peers never applied deletes).

Adds a unified Syncable::create_tombstone helper that works with both connections and transactions (ConnectionTrait), then refactors location/volume deletion paths to use it so record deletion and tombstone creation are atomic.

Updates the persistent indexing DatabaseAdapter delete path to create a tombstone for the root entry UUID (cascading deletion model) and caches device_db_id during adapter construction to avoid per-delete lookups, plus adds a new sync_deletion_test.rs suite and helper utilities to validate location/volume/entry deletion sync, cascade behavior, and tombstone race-condition protection.

Written by Cursor Bugbot for commit 8f4daab. Configure here.

)
.exec(db)
.await
.ok(); // Ignore duplicate conflict (idempotent)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
.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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)))?;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
.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()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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"))?;

Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(())
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Fix in Cursor Fix in Web

entities::entry::Model::create_tombstone(root_uuid, self.device_db_id, &self.db)
.await
.map_err(|e| anyhow::anyhow!("Failed to create tombstone: {}", e))?;
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Fix in Cursor Fix in Web

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant