mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 03:00:18 +02:00
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:
1 parent
a7261b1601
commit
6c41a341e5
2 files changed
+49
-11
No files matched your search
+24
-11
@@ -1,4 +1,5 @@
|
||||
"""Private-data archives, run under podman unshare on the Frame. Stdlib only."""
|
||||
import contextlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path, PurePosixPath
|
||||
@@ -113,21 +114,33 @@ def restore(root, package, instance, input_stream):
|
||||
apply_metadata(target, member)
|
||||
for target, member in reversed(directories):
|
||||
apply_metadata(target, member)
|
||||
previous = root / ('.' + package + '.before-restore-' + str(time.time_ns()))
|
||||
source.rename(previous)
|
||||
try:
|
||||
(stage / 'data').rename(source)
|
||||
except BaseException:
|
||||
previous.rename(source)
|
||||
raise
|
||||
# Keep only the newest pre-restore copy of this package's data.
|
||||
for old in root.glob('.' + package + '.before-restore-*'):
|
||||
if old != previous and not old.is_symlink():
|
||||
shutil.rmtree(str(old), ignore_errors=True)
|
||||
with package_lock(root, package): # another restore of this package must not delete our copy
|
||||
previous = root / ('.' + package + '.before-restore-' + str(time.time_ns()))
|
||||
source.rename(previous)
|
||||
try:
|
||||
(stage / 'data').rename(source)
|
||||
except BaseException:
|
||||
previous.rename(source)
|
||||
raise
|
||||
# Keep only the newest pre-restore copy of this package's data.
|
||||
for old in root.glob('.' + package + '.before-restore-*'):
|
||||
if old != previous and not old.is_symlink():
|
||||
shutil.rmtree(str(old), ignore_errors=True)
|
||||
result['previous'] = str(previous)
|
||||
return result
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def package_lock(root, package):
|
||||
import fcntl
|
||||
fd = os.open(str(root / ('.' + package + '.restore.lock')), os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600)
|
||||
try:
|
||||
fcntl.flock(fd, fcntl.LOCK_EX)
|
||||
yield
|
||||
finally:
|
||||
os.close(fd) # releases the lock
|
||||
|
||||
|
||||
def apply_metadata(path, member):
|
||||
os.chown(str(path), member.uid, member.gid)
|
||||
os.chmod(str(path), member.mode & 0o777)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user