diff --git a/docs/updates.md b/docs/updates.md index 32f284b..05410df 100644 --- a/docs/updates.md +++ b/docs/updates.md @@ -133,7 +133,8 @@ Writes use adjacent temporary files, file fsync, atomic replacement and director fsync. Recovery is independent of the runtime package. The public read-only status summary is `/usr/share/hydra/update-status.json`; private journals are authoritative, and an abrupt loss of power may leave the summary briefly older than those journals. -Run recovery as administrator when an update was interrupted. +Run recovery as administrator when an update was interrupted; it refreshes the +public summary from authoritative state even when no transaction remains pending. This is a per-file recoverable transaction, not a simultaneous filesystem snapshot. A running process can observe a mixture during apply. Do not launch new Hydra diff --git a/hydra/update_engine.py b/hydra/update_engine.py index 1de65ac..e4d22f1 100644 --- a/hydra/update_engine.py +++ b/hydra/update_engine.py @@ -463,7 +463,11 @@ class Updater: pending = self.pending() if len(pending) > 1: raise UpdateError('Multiple pending journals require manual inspection') - return [self.restore(transaction, False) for transaction in pending] + results = [self.restore(transaction, False) for transaction in pending] + # A crash can leave a terminal journal durable but its public summary stale. + # Reconcile even when there is no transaction left to restore. + self.publish() + return results def checked_url(url): diff --git a/tests/test_update.py b/tests/test_update.py index 1620438..d8e30a4 100644 --- a/tests/test_update.py +++ b/tests/test_update.py @@ -429,3 +429,36 @@ CALL updater.recover() self.assertEqual(updater.status()['highest_sequence'], 1) self.assertEqual(self.target.read_bytes(), b'old runtime\n') + + def test_fresh_recovery_reconciles_public_status_after_terminal_journal_before_publish(self): + self.package() + updater = self.updater() + from unittest.mock import patch + original_publish = updater.publish + def interrupted_publish(): + if not updater.pending(): + raise OSError('power lost after terminal journal before public status') + return original_publish() + for operation in ('apply', 'rollback'): + with self.subTest(operation=operation): + with patch.object(updater, 'publish', side_effect=interrupted_publish): + with self.assertRaisesRegex(OSError, 'terminal journal'): + if operation == 'apply': + updater.apply(self.artifacts) + else: + updater.rollback(updater.state()['current']['transaction']) + authoritative = updater.status() + self.assertEqual(authoritative['pending'], []) + stale = json.loads(updater.public_status.read_text()) + self.assertNotEqual(stale, authoritative) + result = subprocess.run([sys.executable, '-I', str(ENGINE.parent.parent / 'bin/hydra-update-recover'), + '--root', str(self.root), '--public-key', str(self.pub)], capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(json.loads(result.stdout), []) + self.assertEqual(json.loads(updater.public_status.read_text()), authoritative) + self.assertEqual(self.target.read_bytes(), b'new runtime\n' if operation == 'apply' else b'old runtime\n') + updater.base.chmod(0) + try: + self.assertEqual(updater.status(), authoritative) + finally: + updater.base.chmod(0o700)