fix: fall back to direct write when atomic state save hits EPERM on NFS

This commit is contained in:
Matt Van Horn
2026-07-05 03:42:07 -07:00
parent 363f159a0a
commit c2c129db61
2 changed files with 83 additions and 2 deletions
+33 -2
View File
@@ -62,6 +62,7 @@ class AtomicJsonStore:
self.path = path self.path = path
self.kind = kind self.kind = kind
self.schema_version = schema_version self.schema_version = schema_version
self._direct_write_fallback_warned = False
def _ensure_parent(self) -> None: def _ensure_parent(self) -> None:
parent = os.path.dirname(self.path) parent = os.path.dirname(self.path)
@@ -96,6 +97,13 @@ class AtomicJsonStore:
def save(self, data: dict[str, Any]) -> None: def save(self, data: dict[str, Any]) -> None:
self._ensure_parent() self._ensure_parent()
payload = self._build_payload(data) payload = self._build_payload(data)
try:
self._atomic_write(payload)
except OSError as exc:
self._warn_direct_write_fallback(exc)
self._direct_write(payload)
def _atomic_write(self, payload: dict[str, Any]) -> None:
parent = os.path.dirname(self.path) or "." parent = os.path.dirname(self.path) or "."
fd, tmp_path = tempfile.mkstemp( fd, tmp_path = tempfile.mkstemp(
prefix=f".{os.path.basename(self.path)}.", prefix=f".{os.path.basename(self.path)}.",
@@ -105,8 +113,7 @@ class AtomicJsonStore:
) )
try: try:
with os.fdopen(fd, "w", encoding="utf-8") as f: with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(payload, f, ensure_ascii=False, separators=(",", ":")) self._write_payload(payload, f)
f.write("\n")
f.flush() f.flush()
os.fsync(f.fileno()) os.fsync(f.fileno())
os.replace(tmp_path, self.path) os.replace(tmp_path, self.path)
@@ -118,6 +125,30 @@ class AtomicJsonStore:
pass pass
raise raise
def _direct_write(self, payload: dict[str, Any]) -> None:
with open(self.path, "w", encoding="utf-8") as f:
self._write_payload(payload, f)
f.flush()
try:
os.fsync(f.fileno())
except OSError:
pass
@staticmethod
def _write_payload(payload: dict[str, Any], f: Any) -> None:
json.dump(payload, f, ensure_ascii=False, separators=(",", ":"))
f.write("\n")
def _warn_direct_write_fallback(self, exc: OSError) -> None:
if self._direct_write_fallback_warned:
return
self._direct_write_fallback_warned = True
log.warning(
"Atomic state write failed for %s (%s); falling back to direct write",
self.path,
exc,
)
def quarantine_invalid_file(self, exc: Exception) -> None: def quarantine_invalid_file(self, exc: Exception) -> None:
if not os.path.exists(self.path): if not os.path.exists(self.path):
return return
+50
View File
@@ -4,6 +4,7 @@ import os
import tempfile import tempfile
import unittest import unittest
from datetime import datetime from datetime import datetime
from unittest.mock import patch
from state_store import AtomicJsonStore, from_json_compatible, to_json_compatible from state_store import AtomicJsonStore, from_json_compatible, to_json_compatible
@@ -21,6 +22,55 @@ class StateStoreTests(unittest.TestCase):
self.assertEqual(payload["schema_version"], 2) self.assertEqual(payload["schema_version"], 2)
self.assertEqual(payload["items"][0]["info"]["title"], "hello") self.assertEqual(payload["items"][0]["info"]["title"], "hello")
def test_save_falls_back_to_direct_write_when_mkstemp_fails(self):
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, "queue.json")
store = AtomicJsonStore(path, kind="persistent_queue:queue")
with self.assertLogs("state_store", level="WARNING") as logs:
with patch(
"state_store.tempfile.mkstemp",
side_effect=PermissionError(1, "Operation not permitted"),
):
store.save({"items": [{"key": "a"}]})
self.assertTrue(os.path.exists(path))
self.assertTrue(any(path in message for message in logs.output))
payload = store.load()
self.assertEqual(payload["items"], [{"key": "a"}])
def test_save_falls_back_to_direct_write_when_replace_fails(self):
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, "queue.json")
store = AtomicJsonStore(path, kind="persistent_queue:queue")
with patch(
"state_store.os.replace",
side_effect=PermissionError(1, "Operation not permitted"),
):
store.save({"items": [{"key": "a"}]})
self.assertTrue(os.path.exists(path))
payload = store.load()
self.assertEqual(payload["items"], [{"key": "a"}])
self.assertEqual([], [name for name in os.listdir(tmp) if name.endswith(".tmp")])
def test_save_reraises_when_atomic_and_direct_write_fail(self):
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, "queue.json")
store = AtomicJsonStore(path, kind="persistent_queue:queue")
with patch(
"state_store.tempfile.mkstemp",
side_effect=PermissionError(1, "Operation not permitted"),
):
with patch("builtins.open", side_effect=PermissionError(13, "Permission denied")):
with self.assertRaises(PermissionError) as ctx:
store.save({"items": [{"key": "a"}]})
self.assertEqual(ctx.exception.errno, 13)
self.assertFalse(os.path.exists(path))
def test_invalid_file_is_quarantined(self): def test_invalid_file_is_quarantined(self):
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, "queue.json") path = os.path.join(tmp, "queue.json")