diff --git a/packages/cultcache-py/src/cultcache_py/backing_store.py b/packages/cultcache-py/src/cultcache_py/backing_store.py index 5a21e496..7a93bbd0 100644 --- a/packages/cultcache-py/src/cultcache_py/backing_store.py +++ b/packages/cultcache-py/src/cultcache_py/backing_store.py @@ -67,10 +67,12 @@ class BackingStore(Protocol): def delete(self, type: str, key: str) -> None: ... - def push_all(self, envelopes: list[CultCacheEnvelope]) -> None: + def push_all(self, envelopes: list[CultCacheEnvelope], soft: bool = False) -> None: existing = { (envelope.type, envelope.key): envelope for envelope in self.pull_all() } + if soft and existing: + return for envelope in envelopes: existing[(envelope.type, envelope.key)] = envelope self._replace_all(list(existing.values())) diff --git a/packages/cultcache-py/src/cultcache_py/stores.py b/packages/cultcache-py/src/cultcache_py/stores.py index c15ac32e..35b53af3 100644 --- a/packages/cultcache-py/src/cultcache_py/stores.py +++ b/packages/cultcache-py/src/cultcache_py/stores.py @@ -106,9 +106,11 @@ class JsonLinesBackingStore: existing[(envelope.type, envelope.key)] = envelope self._replace_all(list(existing.values())) - def push_all(self, envelopes: list[CultCacheEnvelope]) -> None: + def push_all(self, envelopes: list[CultCacheEnvelope], soft: bool = False) -> None: with self._lock: existing = {(item.type, item.key): item for item in self.pull_all()} + if soft and self.path.exists(): + return for envelope in envelopes: existing[(envelope.type, envelope.key)] = envelope self._replace_all(list(existing.values())) @@ -163,9 +165,14 @@ class SingleFileMessagePackBackingStore: with self._write_transaction(): self._replace_all([envelope], self.pull_all()) - def push_all(self, envelopes: list[CultCacheEnvelope]) -> None: + def push_all(self, envelopes: list[CultCacheEnvelope], soft: bool = False) -> None: + """A soft push writes only where nothing is at the path: a store the reader reads is left byte for byte, and a file it + cannot read is refused by the same read, as in TS and Rust.""" with self._write_transaction(): - self._replace_all(list(envelopes), self.pull_all()) + arrived = self.pull_all() + if soft and self.path.exists(): + return + self._replace_all(list(envelopes), arrived) def delete(self, type: str, key: str) -> None: with self._write_transaction(): diff --git a/packages/cultcache-py/tests/test_soft_push_all.py b/packages/cultcache-py/tests/test_soft_push_all.py new file mode 100644 index 00000000..a1874c7c --- /dev/null +++ b/packages/cultcache-py/tests/test_soft_push_all.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +import shutil +import tempfile +import unittest +from pathlib import Path + +from cultcache_py import JsonLinesBackingStore, SingleFileMessagePackBackingStore, StoreUnreadableError +from cultcache_py.backing_store import CultCacheEnvelope + + +def envelope(key: str) -> CultCacheEnvelope: + return CultCacheEnvelope(key=key, type="t", payload=b"\x01", stored_at="2026-11-06T00:00:00Z") + + +class SoftPushAllTests(unittest.TestCase): + """A soft push writes where nothing is, skips a store it reads, and refuses one it cannot read (TS and Rust parity).""" + + def setUp(self) -> None: + self.dir = Path(tempfile.mkdtemp()) + self.addCleanup(shutil.rmtree, self.dir, ignore_errors=True) + self.path = self.dir / "state.cc" + + def test_over_a_readable_store_it_leaves_the_file_byte_for_byte(self) -> None: + for make in (SingleFileMessagePackBackingStore, JsonLinesBackingStore): + with self.subTest(make.__name__): + path = self.dir / make.__name__ + store = make(path) + store.push_all([envelope("a")]) + before = path.read_bytes() + store.push_all([envelope("b")], soft=True) + self.assertEqual(before, path.read_bytes()) + self.assertEqual(["a"], [item.key for item in make(path).pull_all()]) + store.push_all([envelope("b")]) # a plain push still lands what the caller holds + self.assertEqual(["a", "b"], sorted(item.key for item in make(path).pull_all())) + + def test_over_an_absent_path_it_writes(self) -> None: + for make in (SingleFileMessagePackBackingStore, JsonLinesBackingStore): + with self.subTest(make.__name__): + path = self.dir / make.__name__ + make(path).push_all([envelope("a")], soft=True) + self.assertEqual(["a"], [item.key for item in make(path).pull_all()]) + + def test_over_an_unreadable_file_it_refuses_and_leaves_it(self) -> None: + junk = b"\xc1\x01\x02\x03" + self.path.write_bytes(junk) + with self.assertRaises(StoreUnreadableError): + SingleFileMessagePackBackingStore(self.path).push_all([envelope("a")], soft=True) + self.assertEqual(junk, self.path.read_bytes()) + + +if __name__ == "__main__": + unittest.main() diff --git a/packages/cultnet-py/src/cultnet_py/benchmark.py b/packages/cultnet-py/src/cultnet_py/benchmark.py index 83834635..0793bfc7 100644 --- a/packages/cultnet-py/src/cultnet_py/benchmark.py +++ b/packages/cultnet-py/src/cultnet_py/benchmark.py @@ -137,7 +137,9 @@ class InMemoryBackingStore: def push(self, envelope: CultCacheEnvelope) -> None: self._records[(envelope.type, envelope.key)] = envelope - def push_all(self, envelopes: list[CultCacheEnvelope]) -> None: + def push_all(self, envelopes: list[CultCacheEnvelope], soft: bool = False) -> None: + if soft and self._records: + return for envelope in envelopes: self.push(envelope)