Skip to content

Commit 5cb8e3a

Browse files
pmaxhoganclaude
andauthored
feat(core): remote-existence audit heals files whose Drive objects vanished (#171)
## Why Today a user deleted their backup's Drive folders and emptied the trash. That left **4,526 `file_state` rows pointing at hard-deleted `drive_file_id`s.** #168 fixed half of it: a file that *later changes* plans an UPDATE, the update 404s, and the stale id self-heals. But a file that **never changes never plans an update** - so it would have stayed silently un-backed-up **forever**: no error shown, restore impossible. This PR adds the proactive pass that closes that gap, and verifies the adjacent folder-deletion recovery path. ## Part 1: the audit **Enumeration, not per-file GETs.** New required `RemoteStore::list_source_object_ids`. Every object the executor creates carries `appProperties driven.source_id`, so one paged `files.list` returns a source's whole live footprint in ~N/1000 requests. Folders carry only `driven.folder_marker`, so they are excluded for free. A new `ID_ONLY_FIELDS` projection (`fields=nextPageToken,files(id)`) avoids pulling nine fields per row and discarding eight on a large source. `SOURCE_ID_KEY` now has **one** definition in `driven-drive`, re-exported by the executor. Two copies that drifted would make the audit match nothing, judge every id dead, and re-upload the whole source. **The heal.** Dead FILE: clear `drive_file_id` + `drive_md5` and stamp the `REQUEUE_FORCE_RESCAN_MTIME_NS` sentinel (the exact remediation applied by hand during the incident), so the next scan re-emits the path in *either* scan mode and the executor re-creates it. Dead BUNDLE object: `heal_dead_bundle` re-queues the members and drops the `bundles` row in **one transaction** - members must be read before the row is deleted, because the `bundle_id` FK cascades the membership rows away. Members then re-upload individually, which is the same standalone promotion a bundled member already gets whenever it changes. **The safety property.** The audit infers "gone" from **absence**, so an enumeration that cannot be completed writes **nothing** and returns `Err`. A partial listing would name live objects as dead and churn the entire source; retrying next cycle is free. This is also why the trait method has **no default body** - "no live objects" is not a safe degradation, it is that same failure arriving silently, so `BreakerReportingStore` and every test double must delegate or error explicitly. **Scheduling.** Once per source per process, **plus** every deep-verify cycle. Startup-once matters because the damage appeared overnight and `deep_verify_interval_secs` defaults to a week. It runs **before** the scan, so a heal completes in one cycle instead of two. A source is marked done only on success, so a transient failure retries rather than being skipped until restart; an audit error never fails the cycle. **Reporting.** Capped at 20 per-file `drive.remote_file_missing` WARN rows - the incident would have written 4,526 - plus one `remote_audit_done` Info row whose `file_count` carries the **full** healed count, so the cap hides nothing. A clean audit writes nothing at all. ## Part 2: ancestor-deletion recovery - verified, and it found a real wedge The answer is yes, it recovers, but **not** for the reason the brief guessed, and there was a genuine bug. Not the mechanism: nothing in the orchestrator halts a source on `dest_folder_missing` (grep returns no matches), so that was never the failure mode. The real one: an intermediate folder cached in `parent_dirs` can be dead while a **deeper component is not cached** - a *new subdirectory* under a deleted-but-cached parent. `ensure_folder` then 404s **inside** `resolve_remote_target`, which maps to `UploadError::Fatal` and **aborted the entire `execute()` for the source**. And because `invalidate_parent_dirs` only ran *after* the target resolved, the poisoned cache entry **survived for the process lifetime** - every later file under that chain failed identically. A permanent wedge until restart. Fix: `ensure_parents` now drops the path's cached chain and re-ensures **once** from the source root, gated on the invalidation having actually removed something. That gate is what keeps a deleted destination **root** on its unchanged fail-fast mass-delete guard - the root is never in this cache, so it removes nothing, never retries, and is never silently re-created. Only the subfolder chain self-heals. Three cases are pinned: cold cache, warm cache with the whole chain cached (recovers in two cycles via the pre-existing invalidate path - a per-op failure, never an aborted cycle), and the new-subdirectory case that was broken. ## Test-infrastructure notes - The fake gains `delete_folder_tree` - real Drive **cascades** a folder deletion, while `trash`/`delete_permanent` touch one object, and that cascade is the incident. - New `with_source_listing_broken` fault, scoped to the enumeration, so a test can let its setup uploads land and break *only* the audit. - One e2e fixture hand-created an orphan **without** the `source_id` stamp - unfaithful to the executor, which has stamped it since v0.1.0 (verified present in `v1.0.0`, so there is no back-compat hazard for existing installs). It is now stamped. A duplicate re-upload in that test is what caught it. ## Gates `cargo fmt`; `clippy -p driven-core -p driven-drive --all-targets -D warnings` clean; `cargo test -p driven-core -p driven-drive` all green (533 tests); `cargo check --workspace --all-targets` clean; vitest 515 passed; prettier clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01JLB3E2Jm7knNJd37fVpH8X Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 3792e47 commit 5cb8e3a

13 files changed

Lines changed: 2317 additions & 100 deletions

File tree

crates/driven-core/src/executor.rs

Lines changed: 1270 additions & 94 deletions
Large diffs are not rendered by default.

crates/driven-core/src/orchestrator.rs

Lines changed: 444 additions & 0 deletions
Large diffs are not rendered by default.

crates/driven-core/src/state/mod.rs

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1152,6 +1152,120 @@ pub trait StateRepo: Send + Sync {
11521152
path: &RelativePath,
11531153
) -> Result<()>;
11541154

1155+
/// Every `(relative_path, drive_file_id)` pair this source records - i.e.
1156+
/// every file whose bytes are supposed to exist as a STANDALONE Drive
1157+
/// object right now.
1158+
///
1159+
/// The recorded half of the remote-existence audit: the executor diffs
1160+
/// these ids against the live ids Drive reports
1161+
/// ([`driven_drive::remote_store::RemoteStore::list_source_object_ids`])
1162+
/// and heals whatever is recorded but no longer live. Rows with a NULL
1163+
/// `drive_file_id` are EXCLUDED - a bundled member (whose bytes live inside
1164+
/// a `.tar.gz`) and a never-uploaded row both have no standalone object to
1165+
/// audit, and treating their absence from Drive as damage would re-upload
1166+
/// them on every pass.
1167+
///
1168+
/// Leaner than [`Self::load_source_file_state`] on purpose: the audit needs
1169+
/// two columns, and a source can hold hundreds of thousands of rows.
1170+
///
1171+
/// The default returns an empty list, which reads as "this source records
1172+
/// no standalone objects" and makes the audit a no-op - the SAFE
1173+
/// degradation for a fake that models no `file_state` (mirrors
1174+
/// [`Self::list_empty_bundles`]).
1175+
async fn list_file_state_drive_ids(
1176+
&self,
1177+
source: SourceId,
1178+
) -> Result<Vec<(RelativePath, String)>> {
1179+
let _ = source;
1180+
Ok(Vec::new())
1181+
}
1182+
1183+
/// Every `(bundle_id, drive_file_id)` this source records - the bundle half
1184+
/// of the remote-existence audit's recorded set.
1185+
///
1186+
/// Distinct from [`Self::list_empty_bundles`], which returns only the
1187+
/// MEMBERLESS bundles that GC should trash. The audit needs ALL of them,
1188+
/// because a bundle whose Drive object was deleted out-of-band is still
1189+
/// full of members - members whose bytes are now nowhere. Default empty
1190+
/// (audit no-op), like the other bundle accessors.
1191+
async fn list_bundles_for_source(&self, source: SourceId) -> Result<Vec<(String, String)>> {
1192+
let _ = source;
1193+
Ok(Vec::new())
1194+
}
1195+
1196+
/// Re-queue one `file_state` row for a fresh upload after its recorded
1197+
/// Drive object was proven GONE.
1198+
///
1199+
/// Three writes in one targeted UPDATE:
1200+
/// - `drive_file_id = NULL`, so the executor's next execution of this path
1201+
/// takes the CREATE branch (it decides create-vs-update from this column,
1202+
/// not from the plan) instead of a doomed UPDATE against a dead id;
1203+
/// - `drive_md5 = NULL`, since the md5 described bytes that no longer
1204+
/// exist and would otherwise linger as a claim about a live object;
1205+
/// - `mtime_ns = force_rescan_mtime_ns`, the caller's sentinel. Clearing
1206+
/// the id ALONE is not enough: the scanner treats a file as unchanged
1207+
/// when its `(size, mtime_ns)` equals the stored row's, so an untouched
1208+
/// file would never be re-emitted and the cleared id would sit there
1209+
/// forever. A sentinel no real filesystem can produce guarantees the
1210+
/// very next scan re-emits the path in EITHER scan mode.
1211+
///
1212+
/// The row itself survives - only its remote pointer was wrong - so the
1213+
/// file's recorded history and its `bundle_members` linkage (if any) are
1214+
/// untouched. A missing row is an idempotent no-op.
1215+
///
1216+
/// The default errors rather than silently succeeding: a repo that cannot
1217+
/// perform the heal must not report a file as healed when its row still
1218+
/// points at a dead object. Unreachable under the default listings above,
1219+
/// which return nothing to heal.
1220+
async fn requeue_file_state_for_reupload(
1221+
&self,
1222+
source: SourceId,
1223+
path: &RelativePath,
1224+
force_rescan_mtime_ns: i64,
1225+
) -> Result<()> {
1226+
let _ = (source, path, force_rescan_mtime_ns);
1227+
Err(anyhow::anyhow!(
1228+
"requeue_file_state_for_reupload is not implemented by this StateRepo"
1229+
))
1230+
}
1231+
1232+
/// Heal one bundle whose `.tar.gz` Drive object is GONE, returning the
1233+
/// member paths that were re-queued.
1234+
///
1235+
/// In ONE transaction: read the bundle's members, re-queue each member's
1236+
/// `file_state` row exactly as
1237+
/// [`Self::requeue_file_state_for_reupload`] does, then delete the
1238+
/// `bundles` row (whose `bundle_id` FK cascades the now-meaningless
1239+
/// `bundle_members` rows away).
1240+
///
1241+
/// The ordering is load-bearing and the reason this is one method rather
1242+
/// than a loop at the call site: deleting the bundle row cascades the
1243+
/// membership rows, so once it is gone the member list is UNRECOVERABLE.
1244+
/// Members must be read - and re-queued - first. Doing it in a transaction
1245+
/// additionally means a crash mid-heal cannot leave members orphaned from a
1246+
/// deleted bundle with no re-upload pending.
1247+
///
1248+
/// Each member keeps `drive_file_id = NULL` (it never had a standalone
1249+
/// object), so the sentinel mtime is what does the work: the next scan
1250+
/// re-emits the path and the planner - seeing an existing `file_state` row,
1251+
/// which makes the file "changed" rather than "genuinely new" - schedules it
1252+
/// as an INDIVIDUAL upload. That is the same standalone promotion a bundled
1253+
/// member gets whenever it changes, so the recovery path is one the
1254+
/// executor already exercises rather than a bespoke re-bundle.
1255+
///
1256+
/// Default errors, for the same reason as
1257+
/// [`Self::requeue_file_state_for_reupload`].
1258+
async fn heal_dead_bundle(
1259+
&self,
1260+
bundle_id: &str,
1261+
force_rescan_mtime_ns: i64,
1262+
) -> Result<Vec<RelativePath>> {
1263+
let _ = (bundle_id, force_rescan_mtime_ns);
1264+
Err(anyhow::anyhow!(
1265+
"heal_dead_bundle is not implemented by this StateRepo"
1266+
))
1267+
}
1268+
11551269
/// R2-P1-3 (DESIGN s5.4): increment the CONSECUTIVE checksum-mismatch
11561270
/// counter for `(source, path)` by one and return the NEW count. After the
11571271
/// 3rd consecutive mismatch the executor marks the file

crates/driven-core/src/state/sqlite.rs

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1576,6 +1576,106 @@ impl StateRepo for SqliteStateRepo {
15761576
Ok(())
15771577
}
15781578

1579+
async fn list_file_state_drive_ids(
1580+
&self,
1581+
source: SourceId,
1582+
) -> Result<Vec<(RelativePath, String)>> {
1583+
let source_str = source.to_string();
1584+
// Runtime `sqlx::query` (NOT the compile-checked `query!` macro) so this
1585+
// additive method needs NO `.sqlx` cache regeneration - the same reason
1586+
// `clear_file_state_drive_file_id` uses it.
1587+
let rows: Vec<(String, String)> = sqlx::query_as(
1588+
"SELECT relative_path, drive_file_id FROM file_state \
1589+
WHERE source_id = ?1 AND drive_file_id IS NOT NULL",
1590+
)
1591+
.bind(source_str.as_str())
1592+
.fetch_all(&self.pool)
1593+
.await?;
1594+
let mut out = Vec::with_capacity(rows.len());
1595+
for (path, drive_file_id) in rows {
1596+
out.push((relative_path_from_string(path)?, drive_file_id));
1597+
}
1598+
Ok(out)
1599+
}
1600+
1601+
async fn list_bundles_for_source(&self, source: SourceId) -> Result<Vec<(String, String)>> {
1602+
let source_str = source.to_string();
1603+
let rows: Vec<(String, String)> =
1604+
sqlx::query_as("SELECT id, drive_file_id FROM bundles WHERE source_id = ?1")
1605+
.bind(source_str.as_str())
1606+
.fetch_all(&self.pool)
1607+
.await?;
1608+
Ok(rows)
1609+
}
1610+
1611+
async fn requeue_file_state_for_reupload(
1612+
&self,
1613+
source: SourceId,
1614+
path: &RelativePath,
1615+
force_rescan_mtime_ns: i64,
1616+
) -> Result<()> {
1617+
let source_str = source.to_string();
1618+
let path_str = path.as_str().to_string();
1619+
// A targeted three-column UPDATE: it cannot clobber a column a
1620+
// concurrent edit may have changed, and a missing row updates 0 rows
1621+
// (idempotent no-op).
1622+
sqlx::query(
1623+
"UPDATE file_state SET drive_file_id = NULL, drive_md5 = NULL, mtime_ns = ?3 \
1624+
WHERE source_id = ?1 AND relative_path = ?2",
1625+
)
1626+
.bind(source_str.as_str())
1627+
.bind(path_str.as_str())
1628+
.bind(force_rescan_mtime_ns)
1629+
.execute(&self.pool)
1630+
.await?;
1631+
Ok(())
1632+
}
1633+
1634+
async fn heal_dead_bundle(
1635+
&self,
1636+
bundle_id: &str,
1637+
force_rescan_mtime_ns: i64,
1638+
) -> Result<Vec<RelativePath>> {
1639+
let mut tx = self.pool.begin().await?;
1640+
1641+
// Read the members FIRST: the `DELETE FROM bundles` below cascades
1642+
// `bundle_members` away via its `bundle_id` FK, after which this list
1643+
// cannot be recovered.
1644+
let members: Vec<(String, String)> = sqlx::query_as(
1645+
"SELECT source_id, relative_path FROM bundle_members WHERE bundle_id = ?1",
1646+
)
1647+
.bind(bundle_id)
1648+
.fetch_all(&mut *tx)
1649+
.await?;
1650+
1651+
let mut paths = Vec::with_capacity(members.len());
1652+
for (source_id, relative_path) in &members {
1653+
// Members carry `drive_file_id = NULL` already (their bytes lived
1654+
// in the bundle), so the sentinel mtime is what forces the rescan.
1655+
// The NULLs are still written so the statement is identical to
1656+
// `requeue_file_state_for_reupload` and stays correct if a member
1657+
// ever does hold an id.
1658+
sqlx::query(
1659+
"UPDATE file_state SET drive_file_id = NULL, drive_md5 = NULL, mtime_ns = ?3 \
1660+
WHERE source_id = ?1 AND relative_path = ?2",
1661+
)
1662+
.bind(source_id.as_str())
1663+
.bind(relative_path.as_str())
1664+
.bind(force_rescan_mtime_ns)
1665+
.execute(&mut *tx)
1666+
.await?;
1667+
paths.push(relative_path_from_string(relative_path.clone())?);
1668+
}
1669+
1670+
sqlx::query("DELETE FROM bundles WHERE id = ?1")
1671+
.bind(bundle_id)
1672+
.execute(&mut *tx)
1673+
.await?;
1674+
1675+
tx.commit().await?;
1676+
Ok(paths)
1677+
}
1678+
15791679
async fn bump_checksum_mismatch_count(
15801680
&self,
15811681
source: SourceId,

crates/driven-core/tests/e2e_fake.rs

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ use driven_core::types::{
4444
use driven_crypto::key::SourceKey;
4545
use driven_crypto::{ContentDecryptor, DrivenCryptoSuite, SourceCryptoSuite, HEADER_LEN};
4646

47-
use driven_drive::fake::{InMemoryRemoteStore, CLIENT_OP_UUID_KEY};
47+
use driven_drive::fake::{InMemoryRemoteStore, CLIENT_OP_UUID_KEY, SOURCE_ID_KEY};
4848
use driven_drive::remote_store::{DriveContext, RemoteStore, UploadBody};
4949

5050
use driven_power::{PowerSource, PowerState};
@@ -1039,10 +1039,22 @@ async fn reconcile_requeue_reuploads_changed_bytes_on_next_cycle() {
10391039
let old_bytes = b"OLD uploaded bytes".to_vec();
10401040
let new_bytes = b"NEW locally-edited bytes - different length".to_vec();
10411041

1042+
// The source is built FIRST so the orphan below can be stamped with its id,
1043+
// exactly as the executor would have stamped it.
1044+
let src = source_in(account, src_dir.path(), &folder);
1045+
state.upsert_source(&src).await.unwrap();
1046+
10421047
// The orphan landed on Drive with the OLD bytes + its client_op_uuid.
10431048
let op_uuid = uuid::Uuid::new_v4().to_string();
10441049
let mut app = std::collections::HashMap::new();
10451050
app.insert(CLIENT_OP_UUID_KEY.to_string(), op_uuid.clone());
1051+
// The orphan stands in for an object the EXECUTOR created, and the executor
1052+
// stamps the owning source on everything it creates (it has done so since
1053+
// v0.1.0). Omitting it made this fixture unfaithful in a way that now
1054+
// matters: the remote-existence audit enumerates a source's live objects by
1055+
// exactly this key, so an UNSTAMPED object reads as deleted - and the
1056+
// adopted orphan would be re-uploaded as a duplicate.
1057+
app.insert(SOURCE_ID_KEY.to_string(), src.id.to_string());
10461058
let created = remote
10471059
.create(
10481060
&folder,
@@ -1057,8 +1069,6 @@ async fn reconcile_requeue_reuploads_changed_bytes_on_next_cycle() {
10571069
// But locally the file now holds the NEW bytes (edited after the upload,
10581070
// before the lost commit).
10591071
write_file(src_dir.path(), "drift.bin", &new_bytes);
1060-
let src = source_in(account, src_dir.path(), &folder);
1061-
state.upsert_source(&src).await.unwrap();
10621072
let rel = RelativePath::try_from("drift.bin".to_string()).unwrap();
10631073

10641074
let clock = Arc::new(FakeClock::new());

crates/driven-drive/src/fake/fault_injection.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,29 @@ impl InMemoryRemoteStore {
193193
self
194194
}
195195

196+
/// Latches "the source-object enumeration cannot be completed": every
197+
/// [`crate::remote_store::RemoteStore::list_source_object_ids`] call fails.
198+
/// Every OTHER call - uploads, reads, trashes - keeps working.
199+
///
200+
/// Targets that ONE call on purpose. It models the failure the
201+
/// remote-existence audit must survive without writing anything: the
202+
/// enumeration is the only input that tells the audit which objects are
203+
/// still alive, so a test needs the setup uploads to land normally and then
204+
/// exactly that request to fail. A blanket read fault would break the
205+
/// setup too and prove nothing.
206+
///
207+
/// The audit infers "gone" from ABSENCE, so this is the fault that would
208+
/// otherwise re-upload an entire source - the reason
209+
/// `list_source_object_ids` returns `Err` rather than a partial set.
210+
///
211+
/// Latches for the lifetime of the store.
212+
pub fn with_source_listing_broken(self) -> Self {
213+
self.faults
214+
.source_listing_broken
215+
.store(true, Ordering::Release);
216+
self
217+
}
218+
196219
/// Latches the destination-folder-readonly state. Every subsequent
197220
/// write-target request returns `drive.dest_folder_permission_denied`
198221
/// (SPEC s24). Read-only calls keep working - mirrors the user

0 commit comments

Comments
 (0)