App data: serialise restores of a package with a lock beside its data

Two clients restoring the same package could each swap directories and then
delete the other's pre-restore copy. The swap and retention cleanup now run
under flock on .<package>.restore.lock.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
saphidandClaude Opus 5.5 committed 2026-09-28 22:30:08 +10:00
1 parent a7261b1601
commit 6c41a341e5
2 files changed
+49 -11

No files matched your search

+25
View File
@@ -140,6 +140,31 @@ class BackupTests(unittest.TestCase):
self.assertFalse((source / 'lib').exists() or (source / 'lib').is_symlink())
self.assertEqual((source / 'files/save-link').read_bytes(), b'save')
def test_concurrent_restores_of_a_package_are_serialised(self):
import fcntl, threading
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
(root / PKG).mkdir()
(root / PKG / 'save').write_bytes(b'backup')
archive = io.BytesIO()
REMOTE['backup'](root, PKG, META['instance'], archive)
(root / PKG / 'save').write_bytes(b'current')
first = REMOTE['restore'](root, PKG, META['instance'], io.BytesIO(archive.getvalue()))['previous']
# Another client's restore is mid-swap: it holds the package lock.
fd = os.open(str(root / ('.' + PKG + '.restore.lock')), os.O_RDWR | os.O_CREAT, 0o600)
fcntl.flock(fd, fcntl.LOCK_EX)
results = []
second = threading.Thread(target=lambda: results.append(
REMOTE['restore'](root, PKG, META['instance'], io.BytesIO(archive.getvalue()))))
second.start()
second.join(.5)
self.assertTrue(second.is_alive()) # waiting, so it can't swap or delete the other's copy
self.assertEqual(sorted(root.glob('.' + PKG + '.before-restore-*')), [Path(first)])
os.close(fd)
second.join(5)
self.assertEqual(sorted(root.glob('.' + PKG + '.before-restore-*')), [Path(results[0]['previous'])])
self.assertEqual((root / PKG / 'save').read_bytes(), b'backup')
def test_restore_keeps_only_latest_previous_copy(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)