Skip to content

Commit edb9545

Browse files
committed
fix(gc): don't act on partial storage listings
Three compounding completeness bugs in GarbageCollector.list_hash_paths and list_schema_paths that could cause collect(dry_run=False) to silently delete live external-store files: 1. str.replace(full_root, '') strips every occurrence — corrupts the relative path when the store-root string recurs (e.g., S3 bucket name == schema name). Uses prefix slice instead. 2. fs.size() error handling diverged: list_hash_paths dropped the entry silently (invisible to orphan detection); list_schema_paths kept it at 0 silently. Now both keep at 0 and log a warning. 3. fs.walk errors returned a partial-or-empty stored dict with only a log-warning signal — indistinguishable from a clean store. collect() then computed orphaned = stored - referenced against incomplete data and permanently deleted the 'orphans'. Adds a _scan_errors tracker on the collector; collect(dry_run=False) refuses to delete when set, with a clear DataJointError. New scan_errors list added to the stats dict (empty on clean runs). Tests: 3 new TestScanErrorGuard cases; full local suite green (42/42 test_gc.py, 319/319 unit, 626 integration — no new failures). Follow-up to the completeness discussion on #1478.
1 parent af76ab9 commit edb9545

2 files changed

Lines changed: 102 additions & 5 deletions

File tree

src/datajoint/gc.py

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,10 @@ def __init__(self, *schemas: "Schema", store: str | None = None, config=None) ->
121121
spec = self.config.get_store_spec(store)
122122
self._hash_prefix = spec["hash_prefix"].strip("/") # settings applies the "_hash" default
123123
self._schema_prefix = spec["schema_prefix"].strip("/") # ... "_schema" default
124+
# Per-collect() scan errors (walk failures in list_*_paths). Non-empty
125+
# blocks destructive deletion in collect(dry_run=False). Reset at the
126+
# start of each collect() call.
127+
self._scan_errors: list[str] = []
124128

125129
# ------------------------------------------------------------------ #
126130
# References — extracted from database metadata (per-schema connection)
@@ -205,15 +209,17 @@ def list_hash_paths(self, schema_name: str) -> dict[str, int]:
205209
if not _BASE32_HASH.match(filename):
206210
continue
207211
file_path = f"{root}/{filename}"
212+
relative_path = file_path[len(full_root):].lstrip("/")
208213
try:
209-
relative_path = file_path.replace(full_root, "").lstrip("/")
210214
stored[relative_path] = self.backend.fs.size(file_path)
211-
except Exception:
212-
pass
215+
except Exception as e:
216+
logger.warning(f"Could not size {file_path}: {e}")
217+
stored[relative_path] = 0
213218
except FileNotFoundError:
214219
pass # the hash section does not exist yet
215220
except Exception as e:
216221
logger.warning(f"Error listing stored hashes: {e}")
222+
self._scan_errors.append(f"list_hash_paths({schema_name}): {e}")
217223
return stored
218224

219225
def list_schema_paths(self, schema_name: str) -> dict[str, int]:
@@ -252,15 +258,17 @@ def list_schema_paths(self, schema_name: str) -> dict[str, int]:
252258
# reclaimed with it. _is_covered() keeps a live object's
253259
# manifest out of the orphan set.
254260
file_path = f"{root}/{filename}"
255-
relative_path = file_path.replace(full_root, "").lstrip("/")
261+
relative_path = file_path[len(full_root):].lstrip("/")
256262
try:
257263
stored[relative_path] = self.backend.fs.size(file_path)
258-
except Exception:
264+
except Exception as e:
265+
logger.warning(f"Could not size {file_path}: {e}")
259266
stored[relative_path] = 0
260267
except FileNotFoundError:
261268
pass # this schema's section does not exist yet
262269
except Exception as e:
263270
logger.warning(f"Error listing stored schemas: {e}")
271+
self._scan_errors.append(f"list_schema_paths({schema_name}): {e}")
264272
return stored
265273

266274
# ------------------------------------------------------------------ #
@@ -327,6 +335,7 @@ def collect(self, dry_run: bool = True, verbose: bool = False) -> dict[str, Any]
327335
- hash_paths_deleted / schema_paths_deleted / deleted / bytes_freed (0 when
328336
dry_run) / errors / dry_run
329337
"""
338+
self._scan_errors = [] # reset per-call; populated by list_*_paths below
330339
# References can be gathered across all schemas at once: because paths
331340
# embed the schema, a file under schema X is only ever covered by an X
332341
# reference, so combined coverage equals per-schema coverage.
@@ -350,6 +359,12 @@ def collect(self, dry_run: bool = True, verbose: bool = False) -> dict[str, Any]
350359
errors = 0
351360

352361
if not dry_run:
362+
if self._scan_errors:
363+
raise DataJointError(
364+
f"Refusing to delete: {len(self._scan_errors)} scan error(s) — "
365+
f"partial listing risks classifying live files as orphaned. "
366+
f"Errors: {self._scan_errors}"
367+
)
353368
# The size maps from the scan above are reused for the byte tally —
354369
# no second listing pass.
355370
for path in orphaned_hash_paths:
@@ -393,5 +408,6 @@ def collect(self, dry_run: bool = True, verbose: bool = False) -> dict[str, Any]
393408
"deleted": hash_paths_deleted + schema_paths_deleted,
394409
"bytes_freed": bytes_freed,
395410
"errors": errors,
411+
"scan_errors": list(self._scan_errors),
396412
"dry_run": dry_run,
397413
}

tests/integration/test_gc.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -762,3 +762,84 @@ def test_collect_one_schema_never_touches_the_other(self, two_schemas):
762762
assert set(collector.list_hash_paths(b.database)) == b_hashes_before
763763
assert set(collector.list_schema_paths(b.database)) == b_paths_before
764764
assert len(b_obj()) == 1 # b's row (and its object) untouched
765+
766+
767+
class TestScanErrorGuard:
768+
"""A walk failure during list_*_paths (anything other than FileNotFoundError,
769+
which just means the section doesn't exist yet) leaves the stored listing
770+
partial. Comparing partial listings against complete reference sets would
771+
classify live files as orphaned — silent data loss under dry_run=False.
772+
The scan-error guard captures such errors and refuses to delete."""
773+
774+
def test_scan_errors_reset_between_collect_calls(self):
775+
"""_scan_errors is reset at the start of each collect() call, so an
776+
error from a prior collect() never carries over into the next report."""
777+
with ExitStack() as es:
778+
for p in TestCollect._patch_data(TestCollect):
779+
es.enter_context(p)
780+
es.enter_context(patch("datajoint.gc.delete_path"))
781+
collector = _mock_collector()
782+
# Simulate a leftover error from a previous run.
783+
collector._scan_errors = ["stale error from previous call"]
784+
785+
stats = collector.collect() # dry_run=True
786+
787+
assert stats["scan_errors"] == [], "scan_errors must be reset at the start of collect()"
788+
assert collector._scan_errors == [], "the collector's _scan_errors must be cleared for the new run"
789+
790+
def test_dry_run_false_raises_when_scan_errors_present(self):
791+
"""collect(dry_run=False) must refuse to delete when list_*_paths hit a
792+
non-FileNotFoundError walk failure — partial listing means live files
793+
could be misclassified as orphans."""
794+
# Real walk failure: mock fs.walk on list_hash_paths to raise a
795+
# non-FileNotFoundError, exercising the outer except that records the
796+
# scan error.
797+
with ExitStack() as es:
798+
es.enter_context(patch.object(gc.GarbageCollector, "hash_references", return_value=set()))
799+
es.enter_context(patch.object(gc.GarbageCollector, "schema_references", return_value=set()))
800+
801+
# Real list_hash_paths runs and hits fs.walk failure; list_schema_paths
802+
# is stubbed to a clean empty listing so only the hash walk errors.
803+
es.enter_context(patch.object(gc.GarbageCollector, "list_schema_paths", return_value={}))
804+
805+
collector = _mock_collector()
806+
collector.backend.fs.walk.side_effect = PermissionError("s3 access denied")
807+
collector.backend._full_path.return_value = "/root"
808+
809+
mock_delete = es.enter_context(patch("datajoint.gc.delete_path"))
810+
with pytest.raises(DataJointError, match="Refusing to delete"):
811+
collector.collect(dry_run=False)
812+
813+
# And no deletion happened.
814+
mock_delete.assert_not_called()
815+
816+
def test_scan_errors_in_stats_dict(self):
817+
"""The returned stats dict always includes a 'scan_errors' key: empty
818+
list on a clean scan, list of "<method>(<schema>): <exception>" strings
819+
when a walk failed."""
820+
# Clean case — scan_errors is an empty list.
821+
with ExitStack() as es:
822+
for p in TestCollect._patch_data(TestCollect):
823+
es.enter_context(p)
824+
es.enter_context(patch("datajoint.gc.delete_path"))
825+
stats = _mock_collector().collect()
826+
827+
assert "scan_errors" in stats
828+
assert stats["scan_errors"] == []
829+
830+
# Error case — list of strings identifying which listing failed.
831+
with ExitStack() as es:
832+
es.enter_context(patch.object(gc.GarbageCollector, "hash_references", return_value=set()))
833+
es.enter_context(patch.object(gc.GarbageCollector, "schema_references", return_value=set()))
834+
es.enter_context(patch.object(gc.GarbageCollector, "list_schema_paths", return_value={}))
835+
836+
collector = _mock_collector()
837+
collector.schemas[0].database = "s"
838+
collector.backend.fs.walk.side_effect = PermissionError("s3 access denied")
839+
collector.backend._full_path.return_value = "/root"
840+
841+
stats = collector.collect() # dry_run=True → returns rather than raising
842+
843+
assert len(stats["scan_errors"]) == 1
844+
assert stats["scan_errors"][0].startswith("list_hash_paths(s):")
845+
assert "s3 access denied" in stats["scan_errors"][0]

0 commit comments

Comments
 (0)