@@ -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