diff --git a/app/state_store.py b/app/state_store.py index 2548ad6..2d2b2b7 100644 --- a/app/state_store.py +++ b/app/state_store.py @@ -2,6 +2,7 @@ from __future__ import annotations import base64 import collections.abc +import errno import json import logging import os @@ -17,6 +18,25 @@ STATE_SCHEMA_VERSION = 2 _BYTES_MARKER = "__metube_bytes__" _DATETIME_MARKER = "__metube_datetime__" +# Errnos that signal the filesystem cannot support the temp-file + rename +# atomic-write strategy (for example an NFS-backed state dir returning EPERM on +# mkstemp). These are safe to fall back on because they mean the atomic +# mechanism is unavailable, not that the data write itself failed. Errors like +# ENOSPC/EIO are deliberately excluded so a genuine storage failure surfaces +# instead of silently truncating an existing good state file. +_ATOMIC_UNSUPPORTED_ERRNOS = frozenset( + e + for e in ( + errno.EPERM, + errno.EACCES, + errno.ENOSYS, + errno.EINVAL, + getattr(errno, "EOPNOTSUPP", None), + getattr(errno, "ENOTSUP", None), + ) + if e is not None +) + def to_json_compatible(value: Any) -> Any: if value is None or isinstance(value, (bool, int, float, str)): @@ -100,6 +120,8 @@ class AtomicJsonStore: try: self._atomic_write(payload) except OSError as exc: + if exc.errno not in _ATOMIC_UNSUPPORTED_ERRNOS: + raise self._warn_direct_write_fallback(exc) self._direct_write(payload) diff --git a/app/tests/test_state_store.py b/app/tests/test_state_store.py index 759d89a..089ee95 100644 --- a/app/tests/test_state_store.py +++ b/app/tests/test_state_store.py @@ -71,6 +71,28 @@ class StateStoreTests(unittest.TestCase): self.assertEqual(ctx.exception.errno, 13) self.assertFalse(os.path.exists(path)) + def test_save_reraises_and_preserves_state_on_non_atomic_errno(self): + # A storage failure such as ENOSPC is not an "atomic unavailable" + # signal, so it must surface instead of falling back to a direct write + # that would truncate the existing good state file. + import errno as _errno + + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "queue.json") + store = AtomicJsonStore(path, kind="persistent_queue:queue") + store.save({"items": [{"key": "good"}]}) + + with patch( + "state_store.tempfile.mkstemp", + side_effect=OSError(_errno.ENOSPC, "No space left on device"), + ): + with self.assertRaises(OSError) as ctx: + store.save({"items": [{"key": "new"}]}) + + self.assertEqual(ctx.exception.errno, _errno.ENOSPC) + # Existing state is untouched. + self.assertEqual(store.load()["items"], [{"key": "good"}]) + def test_invalid_file_is_quarantined(self): with tempfile.TemporaryDirectory() as tmp: path = os.path.join(tmp, "queue.json")