Skip to content

Commit a92fd2a

Browse files
sir-sigurdclaude
andauthored
Don't use a process pool to delete push temp files (#5156)
Cleanup ran after the data upload but before the manifest push, and Pool.map re-raises the first worker exception, so a temp file that was already gone -- including when two logical keys share one serialized object, deleting it twice -- failed an otherwise-complete push. Cleanup is now a sequential best-effort loop: an already-gone file is ignored, any other removal error is logged. Dropping the pool also lets push run from a daemonic process, where multiprocessing.Pool raises outright, and stops the library imposing an `if __name__ == "__main__":` guard on callers via spawn's re-import of __main__. Concurrency bought nothing: the pool spent ~93 ms of fixed setup on ~0.2 ms of unlink() work. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent fcb0f58 commit a92fd2a

3 files changed

Lines changed: 66 additions & 15 deletions

File tree

api/python/quilt3/packages.py

Lines changed: 11 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@
1616
import uuid
1717
import warnings
1818
from collections import deque
19-
from multiprocessing import Pool
2019

2120
import botocore.exceptions
2221
import jsonlines
@@ -104,11 +103,6 @@ def f(wrapped):
104103
)
105104

106105

107-
def _delete_local_physical_key(pk):
108-
assert pk.is_local(), "This function only works on files that live on a local disk"
109-
pathlib.Path(pk.path).unlink()
110-
111-
112106
def _filesystem_safe_encode(key):
113107
"""Returns the sha256 of the key. This ensures there are no slashes, uppercase/lowercase conflicts,
114108
avoids `OSError: [Errno 36] File name too long:`, etc."""
@@ -1645,17 +1639,19 @@ def physical_key_is_temp_file(pk):
16451639
return False
16461640
return pathlib.Path(pk.path).parent.resolve() == APP_DIR_TEMPFILE_DIR.resolve()
16471641

1642+
# Materialized first: _set() below mutates what walk() iterates.
16481643
temp_file_logical_keys = [lk for lk, entry in self.walk() if physical_key_is_temp_file(entry.physical_key)]
1649-
if temp_file_logical_keys:
1650-
temp_file_physical_keys = [self[lk].physical_key for lk in temp_file_logical_keys]
1651-
1652-
# Now that data has been pushed, delete tmp files created by pkg.set('KEY', obj)
1653-
with Pool(10) as p:
1654-
p.map(_delete_local_physical_key, temp_file_physical_keys)
1644+
for lk in temp_file_logical_keys:
1645+
# Delete tmp files created by pkg.set('KEY', obj). Cleanup is best-effort: a file we
1646+
# cannot remove is a leaked scratch file, not a reason to fail a completed push.
1647+
temp_pk = self[lk].physical_key
1648+
try:
1649+
pathlib.Path(temp_pk.path).unlink(missing_ok=True)
1650+
except OSError as e:
1651+
logger.warning("Failed to remove temporary file %s: %s", temp_pk.path, e)
16551652

1656-
# Update old package to point to the materialized location of the file since the tempfile no longest exists
1657-
for lk in temp_file_logical_keys:
1658-
self._set(lk, pkg[lk])
1653+
# Point the entry at the materialized location.
1654+
self._set(lk, pkg[lk])
16591655

16601656
# Check top hash again just before pushing, to minimize the race condition.
16611657
if not force:

api/python/tests/integration/test_packages.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -809,6 +809,59 @@ def test_set_package_entry_as_object(self):
809809
file_path = pkg[lk].physical_key.path
810810
assert not pathlib.Path(file_path).exists(), "These temp files should have been deleted during push()"
811811

812+
@patch('quilt3.workflows.validate', mock.MagicMock(return_value=None))
813+
def test_push_survives_missing_temp_file(self):
814+
self.patch_s3_registry('shorten_top_hash', return_value='7a67ff4')
815+
pkg = Package()
816+
df = pd.DataFrame({'col_num': [11, 22, 33]})
817+
pkg.set("mydataframe1.parquet", df)
818+
pkg.set("mydataframe2.parquet", df)
819+
pkg._calculate_missing_hashes()
820+
821+
# Something else removed one temp file already: a tempdir sweep, a retried push, another process.
822+
gone = pathlib.Path(pkg["mydataframe1.parquet"].physical_key.path)
823+
remaining = pathlib.Path(pkg["mydataframe2.parquet"].physical_key.path)
824+
gone.unlink()
825+
assert remaining.exists(), "the two entries must not share a temp file, or this test proves nothing"
826+
827+
with (
828+
patch('quilt3.Package._push_manifest'),
829+
patch('quilt3.packages.copy_file_list', _mock_copy_file_list),
830+
self.assertNoLogs('quilt3.packages', level='WARNING'),
831+
):
832+
pkg.push('Quilt/test_pkg_name', 's3://test-bucket', force=True)
833+
834+
assert not remaining.exists(), "Cleanup should continue past a temp file that is already gone"
835+
836+
@patch('quilt3.workflows.validate', mock.MagicMock(return_value=None))
837+
def test_push_logs_when_temp_file_cannot_be_removed(self):
838+
self.patch_s3_registry('shorten_top_hash', return_value='7a67ff4')
839+
pkg = Package()
840+
pkg.set("mydataframe1.parquet", pd.DataFrame({'col_num': [11, 22, 33]}))
841+
pkg._calculate_missing_hashes()
842+
# temp_key, not str(temp_path): the log formats the physical key, whose separators differ
843+
# from pathlib's on Windows.
844+
temp_key = pkg["mydataframe1.parquet"].physical_key.path
845+
temp_path = pathlib.Path(temp_key)
846+
real_unlink = pathlib.Path.unlink
847+
848+
def unlink(self, *args, **kwargs):
849+
if self == temp_path:
850+
raise PermissionError("file is in use")
851+
return real_unlink(self, *args, **kwargs)
852+
853+
with (
854+
patch('quilt3.Package._push_manifest'),
855+
patch('quilt3.packages.copy_file_list', _mock_copy_file_list),
856+
patch('pathlib.Path.unlink', unlink),
857+
self.assertLogs('quilt3.packages', level='WARNING') as logs,
858+
):
859+
pkg.push('Quilt/test_pkg_name', 's3://test-bucket', force=True)
860+
861+
assert any(f"Failed to remove temporary file {temp_key}" in line for line in logs.output)
862+
assert temp_path.exists(), "a cleanup failure leaves the temp file behind"
863+
temp_path.unlink()
864+
812865
@patch("quilt3.packages.get_size_and_version", mock.Mock(return_value=(123, "v1")))
813866
def test_set_package_entry_unversioned_flag(self):
814867
for flag_value, version_id in {

docs/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ Entries inside each section should be ordered by type:
2323
* [Removed] Drop support for Python 3.9 (end-of-life); `quilt3` now requires Python >= 3.10 ([#4941](https://github.com/quiltdata/quilt/pull/4941))
2424
* [Fixed] `quilt3.admin.buckets.list` no longer raises `TypeError` when its type hints are introspected on Python 3.14 ([#4940](https://github.com/quiltdata/quilt/pull/4940))
2525
* [Fixed] `quilt3.delete_package()` on a local registry no longer deletes other packages sharing the same namespace ([#5140](https://github.com/quiltdata/quilt/pull/5140))
26+
* [Fixed] `Package.push()` no longer fails after the data is uploaded when cleaning up a temporary file created by `Package.set()`: a file that is already gone is ignored, which is also what happens when two logical keys share one serialized object and its temporary file is deleted twice, and any other removal error is logged instead of raised ([#5156](https://github.com/quiltdata/quilt/pull/5156))
27+
* [Fixed] `Package.push()` no longer uses a process pool to delete temporary files, so it works from a daemonic process (e.g. a prefork worker) and no longer requires callers to guard their entry point with `if __name__ == "__main__":` ([#5156](https://github.com/quiltdata/quilt/pull/5156))
2628

2729
### CLI
2830

0 commit comments

Comments
 (0)