Attach, pull, registration, writes and deletes raise when started on a thread already inside a mutation of the same cache (a store call, decoder, encoder, extractor or updater), instead of running inside it and corrupting the state being built. Reads stay reentrant. Internal delegation (put_global, update, delete_global, register_registry, add_backing_store) goes through private helpers so it is not re-entry. A pull adopts a global persisted under a key other than __global__ as that type's global without writing. The first write or delete of it removes the legacy record in the same call; a failed removal takes the new record back out. Two globals of one type on disk are still refused. The lock and unlocked-read comments now state the per-call guarantee. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
806 lines
38 KiB
Python
806 lines
38 KiB
Python
from __future__ import annotations
|
|
|
|
import tempfile
|
|
import threading
|
|
import unittest
|
|
from dataclasses import asdict, dataclass, replace
|
|
from pathlib import Path
|
|
from cultcache_py.backing_store import CultCacheEnvelope
|
|
from cultcache_py.cache import CultCacheError
|
|
from cultcache_py import (
|
|
CultCache,
|
|
JsonLinesBackingStore,
|
|
SingleFileMessagePackBackingStore,
|
|
define_database_entry_type,
|
|
define_document_type,
|
|
)
|
|
from cultcache_py.interop import read_note, write_note
|
|
|
|
|
|
@dataclass
|
|
class Item:
|
|
name: str
|
|
category: str
|
|
value: int
|
|
|
|
|
|
def item_doc():
|
|
return define_document_type(
|
|
"item",
|
|
encode=lambda item: asdict(item),
|
|
decode=lambda raw: Item(**raw),
|
|
name="name",
|
|
indexes={"category": "category"},
|
|
)
|
|
|
|
|
|
class CultCacheTests(unittest.TestCase):
|
|
def test_round_trips_registered_documents(self) -> None:
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
document = item_doc()
|
|
cache = (
|
|
CultCache.builder()
|
|
.register_document_type(document)
|
|
.add_generic_store(JsonLinesBackingStore(Path(tmp) / "cache.jsonl"))
|
|
.build()
|
|
)
|
|
cache.pull_all_backing_stores()
|
|
cache.put(document, "item:potion", Item(name="Potion", category="Consumable", value=50))
|
|
|
|
loaded = (
|
|
CultCache.builder()
|
|
.register_document_type(document)
|
|
.add_generic_store(JsonLinesBackingStore(Path(tmp) / "cache.jsonl"))
|
|
.build()
|
|
)
|
|
loaded.pull_all_backing_stores()
|
|
|
|
self.assertEqual(loaded.get_required(document, "item:potion").value, 50)
|
|
self.assertEqual(loaded.get_key_by_name(document, "Potion"), "item:potion")
|
|
self.assertEqual(loaded.get_by_index(document, "category", "Consumable").name, "Potion")
|
|
|
|
def test_global_document_uses_singleton_key(self) -> None:
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
settings = define_document_type("settings", global_document=True)
|
|
cache = (
|
|
CultCache.builder()
|
|
.register_document_type(settings)
|
|
.add_generic_store(JsonLinesBackingStore(Path(tmp) / "cache.jsonl"))
|
|
.build()
|
|
)
|
|
cache.pull_all_backing_stores()
|
|
cache.put_global(settings, {"theme": "ash"})
|
|
self.assertEqual(cache.get_required_global(settings)["theme"], "ash")
|
|
|
|
def test_update_and_delete(self) -> None:
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
document = item_doc()
|
|
cache = (
|
|
CultCache.builder()
|
|
.register_document_type(document)
|
|
.add_generic_store(JsonLinesBackingStore(Path(tmp) / "cache.jsonl"))
|
|
.build()
|
|
)
|
|
cache.pull_all_backing_stores()
|
|
cache.put(document, "item:potion", Item(name="Potion", category="Consumable", value=50))
|
|
cache.update(document, "item:potion", lambda item: Item(item.name, item.category, item.value + 5))
|
|
self.assertEqual(cache.get_required(document, "item:potion").value, 55)
|
|
cache.delete(document, "item:potion")
|
|
self.assertIsNone(cache.get(document, "item:potion"))
|
|
|
|
def test_incremental_lookup_updates_replace_stale_name_and_index_entries(self) -> None:
|
|
document = item_doc()
|
|
cache = CultCache()
|
|
cache.register_document_type(document)
|
|
|
|
cache.put(document, "item:potion", Item(name="Potion", category="Consumable", value=50))
|
|
cache.put(document, "item:potion", Item(name="Elixir", category="Rare", value=80))
|
|
|
|
self.assertIsNone(cache.get_key_by_name(document, "Potion"))
|
|
self.assertIsNone(cache.get_key_by_index(document, "category", "Consumable"))
|
|
self.assertEqual(cache.get_key_by_name(document, "Elixir"), "item:potion")
|
|
self.assertEqual(cache.get_by_index(document, "category", "Rare").name, "Elixir")
|
|
|
|
def test_incremental_lookup_delete_restores_duplicate_owner(self) -> None:
|
|
document = item_doc()
|
|
cache = CultCache()
|
|
cache.register_document_type(document)
|
|
|
|
cache.put(document, "item:first", Item(name="Potion", category="Consumable", value=50))
|
|
cache.put(document, "item:second", Item(name="Potion", category="Consumable", value=60))
|
|
self.assertEqual(cache.get_key_by_name(document, "Potion"), "item:second")
|
|
self.assertEqual(cache.get_key_by_index(document, "category", "Consumable"), "item:second")
|
|
|
|
cache.delete(document, "item:second")
|
|
|
|
self.assertEqual(cache.get_key_by_name(document, "Potion"), "item:first")
|
|
self.assertEqual(cache.get_key_by_index(document, "category", "Consumable"), "item:first")
|
|
|
|
def test_database_entry_formatter_uses_slot_indexed_messagepack_array(self) -> None:
|
|
try:
|
|
import msgpack # type: ignore
|
|
except ModuleNotFoundError:
|
|
self.skipTest("msgpack optional dependency is not installed")
|
|
|
|
@dataclass
|
|
class Settings:
|
|
theme: str
|
|
retries: int = 0
|
|
|
|
document = define_database_entry_type(
|
|
"settings",
|
|
[
|
|
("theme", 0),
|
|
("retries", 2, 0),
|
|
],
|
|
cls=Settings,
|
|
)
|
|
|
|
payload = document.encode_payload(Settings(theme="ash", retries=3))
|
|
self.assertEqual(msgpack.unpackb(payload, raw=False), ["ash", None, 3])
|
|
self.assertEqual(document.decode_payload(msgpack.packb(["ash"], use_bin_type=True)).retries, 0)
|
|
|
|
def test_document_catalog_entry_is_cached_after_first_derivation(self) -> None:
|
|
document = define_database_entry_type(
|
|
"settings",
|
|
[
|
|
("theme", 0),
|
|
("retries", 2, 0),
|
|
],
|
|
)
|
|
|
|
first = document.catalog_entry()
|
|
second = document.catalog_entry()
|
|
|
|
self.assertIs(first, second)
|
|
self.assertEqual([member.slot for member in first.members], [0, 2])
|
|
|
|
def test_messagepack_store_writes_v1_snapshot(self) -> None:
|
|
try:
|
|
import msgpack # type: ignore
|
|
except ModuleNotFoundError:
|
|
self.skipTest("msgpack optional dependency is not installed")
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
document = define_database_entry_type(
|
|
"settings",
|
|
[("theme", 0)],
|
|
schema_id="settings",
|
|
schema_name="settings",
|
|
schema_version="settings.v1",
|
|
)
|
|
store_path = Path(tmp) / "cache.msgpack"
|
|
cache = (
|
|
CultCache.builder()
|
|
.register_document_type(document)
|
|
.add_generic_store(SingleFileMessagePackBackingStore(store_path))
|
|
.build()
|
|
)
|
|
cache.pull_all_backing_stores()
|
|
cache.put(document, "app", {"theme": "ash"})
|
|
|
|
raw = msgpack.unpackb(store_path.read_bytes(), raw=False)
|
|
self.assertEqual(raw[0], "cultcache.store.v1")
|
|
self.assertEqual(raw[1][0][0], "settings")
|
|
self.assertEqual(raw[2][0][0], "app")
|
|
self.assertEqual(raw[2][0][1], "settings")
|
|
|
|
def test_messagepack_store_recovers_schema_stamped_record_missing_catalog_entry(self) -> None:
|
|
try:
|
|
import msgpack # type: ignore
|
|
except ModuleNotFoundError:
|
|
self.skipTest("msgpack optional dependency is not installed")
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
document = define_database_entry_type(
|
|
"runtime-policy",
|
|
[
|
|
("schema_version", 0),
|
|
("name", 1),
|
|
("value", 2),
|
|
],
|
|
schema_id="schema-current",
|
|
schema_name="tests.schema_stamped_entry",
|
|
schema_version="tests.schema_stamped_entry.v1",
|
|
)
|
|
store_path = Path(tmp) / "missing-catalog.msgpack"
|
|
store_path.write_bytes(msgpack.packb([
|
|
"cultcache.store.v1",
|
|
[],
|
|
[[
|
|
"record-1",
|
|
"sha256:stale-schema-id-from-cold-record",
|
|
"2026-06-25T12:00:00Z",
|
|
msgpack.packb([
|
|
"tests.schema_stamped_entry.v1",
|
|
"schema-stamped",
|
|
"still readable",
|
|
], use_bin_type=True),
|
|
]],
|
|
], use_bin_type=True))
|
|
|
|
cache = (
|
|
CultCache.builder()
|
|
.register_document_type(document)
|
|
.add_generic_store(SingleFileMessagePackBackingStore(store_path))
|
|
.build()
|
|
)
|
|
|
|
cache.pull_all_backing_stores()
|
|
self.assertEqual(cache.get_required(document, "record-1")["value"], "still readable")
|
|
envelope = cache.get_required_envelope(document, "record-1")
|
|
self.assertEqual(envelope.type, "runtime-policy")
|
|
self.assertEqual(envelope.schema_id, "sha256:stale-schema-id-from-cold-record")
|
|
|
|
def test_interop_cli_helpers_round_trip_v1_store(self) -> None:
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
store_path = str(Path(tmp) / "cache.msgpack")
|
|
written = write_note(store_path, "python-test")
|
|
loaded = read_note(store_path)
|
|
self.assertEqual(loaded["documentId"], written["documentId"])
|
|
self.assertEqual(loaded["authorRuntimeId"], "python-test")
|
|
self.assertIn("interop", loaded["tags"])
|
|
|
|
def test_second_generic_store_rejected(self) -> None:
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
cache = CultCache()
|
|
cache.add_generic_store(SingleFileMessagePackBackingStore(Path(tmp) / "a.cc"))
|
|
with self.assertRaisesRegex(CultCacheError, "second generic store"):
|
|
cache.add_generic_store(SingleFileMessagePackBackingStore(Path(tmp) / "b.cc"))
|
|
|
|
def test_type_claimed_twice_rejected(self) -> None:
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
cache = CultCache()
|
|
cache.add_backing_store(SingleFileMessagePackBackingStore(Path(tmp) / "a.cc"), ["item"])
|
|
with self.assertRaisesRegex(CultCacheError, "already routed"):
|
|
cache.add_backing_store(SingleFileMessagePackBackingStore(Path(tmp) / "b.cc"), ["settings", "item"])
|
|
# A refused registration claims nothing, so "settings" is still free.
|
|
cache.add_backing_store(SingleFileMessagePackBackingStore(Path(tmp) / "c.cc"), ["settings"])
|
|
|
|
def test_types_route_to_home_store(self) -> None:
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
item = item_doc()
|
|
settings = define_database_entry_type("settings", [("theme", 0)])
|
|
generic_path = Path(tmp) / "generic.cc"
|
|
settings_path = Path(tmp) / "settings.cc"
|
|
|
|
def build() -> CultCache:
|
|
return (
|
|
CultCache.builder()
|
|
.register_document_type(item)
|
|
.register_document_type(settings)
|
|
.add_backing_store(SingleFileMessagePackBackingStore(settings_path), ["settings"])
|
|
.add_generic_store(SingleFileMessagePackBackingStore(generic_path))
|
|
.build()
|
|
)
|
|
|
|
cache = build()
|
|
cache.put(item, "item:potion", Item(name="Potion", category="Consumable", value=50))
|
|
cache.put(settings, "app", {"theme": "ash"})
|
|
|
|
generic = SingleFileMessagePackBackingStore(generic_path).pull_all()
|
|
routed = SingleFileMessagePackBackingStore(settings_path).pull_all()
|
|
self.assertEqual([(e.key, e.type) for e in generic], [("item:potion", "item")])
|
|
self.assertEqual([(e.key, e.type) for e in routed], [("app", "settings")])
|
|
|
|
cache.delete(settings, "app")
|
|
self.assertEqual(SingleFileMessagePackBackingStore(settings_path).pull_all(), [])
|
|
self.assertEqual(len(SingleFileMessagePackBackingStore(generic_path).pull_all()), 1)
|
|
|
|
reloaded = build()
|
|
reloaded.pull_all_backing_stores()
|
|
self.assertEqual(reloaded.get_required(item, "item:potion").value, 50)
|
|
self.assertIsNone(reloaded.get(settings, "app"))
|
|
|
|
def test_attach_that_would_move_a_held_record_home_is_refused(self) -> None:
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
settings = define_database_entry_type("settings", [("theme", 0)])
|
|
generic_path = Path(tmp) / "generic.cc"
|
|
settings_path = Path(tmp) / "settings.cc"
|
|
cache = (
|
|
CultCache.builder()
|
|
.register_document_type(settings)
|
|
.add_generic_store(SingleFileMessagePackBackingStore(generic_path))
|
|
.build()
|
|
)
|
|
cache.put(settings, "app", {"theme": "v1-generic"})
|
|
|
|
with self.assertRaisesRegex(
|
|
CultCacheError, "would move settings from the generic store to the store routed to settings"
|
|
):
|
|
cache.add_backing_store(SingleFileMessagePackBackingStore(settings_path), ["settings"])
|
|
# Nothing attached: the next write still lands in the generic store.
|
|
cache.put(settings, "app", {"theme": "v2-generic"})
|
|
generic = SingleFileMessagePackBackingStore(generic_path).pull_all()
|
|
self.assertEqual([(e.key, e.type) for e in generic], [("app", "settings")])
|
|
self.assertFalse(settings_path.exists())
|
|
|
|
def test_load_from_a_store_that_is_not_the_record_home_is_refused(self) -> None:
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
settings = define_database_entry_type("settings", [("theme", 0)])
|
|
generic_path = Path(tmp) / "generic.cc"
|
|
settings_path = Path(tmp) / "settings.cc"
|
|
writer = CultCache.builder().register_document_type(settings).add_generic_store(
|
|
SingleFileMessagePackBackingStore(generic_path)
|
|
).build()
|
|
writer.put(settings, "app", {"theme": "stray"})
|
|
generic_bytes = generic_path.read_bytes()
|
|
|
|
cache = (
|
|
CultCache.builder()
|
|
.register_document_type(settings)
|
|
.add_backing_store(SingleFileMessagePackBackingStore(settings_path), ["settings"])
|
|
.add_generic_store(SingleFileMessagePackBackingStore(generic_path))
|
|
.build()
|
|
)
|
|
# The cache already holds a record, in its home store, before the refused load.
|
|
cache.put(settings, "held", {"theme": "home"})
|
|
before = cache.snapshot_envelopes()
|
|
settings_bytes = settings_path.read_bytes()
|
|
with self.assertRaisesRegex(
|
|
CultCacheError,
|
|
"settings record app was loaded from the generic store, but its home is the store routed to settings",
|
|
):
|
|
cache.pull_all_backing_stores()
|
|
self.assertIsNone(cache.get(settings, "app"))
|
|
self.assertEqual(cache.snapshot_envelopes(), before)
|
|
self.assertEqual(cache.get_required(settings, "held"), {"theme": "home"})
|
|
self.assertEqual(generic_path.read_bytes(), generic_bytes)
|
|
self.assertEqual(settings_path.read_bytes(), settings_bytes)
|
|
|
|
def test_pull_with_duplicate_global_leaves_cache_unchanged(self) -> None:
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
item = item_doc()
|
|
settings = define_document_type("settings", global_document=True)
|
|
store_path = Path(tmp) / "generic.cc"
|
|
cache = (
|
|
CultCache.builder()
|
|
.register_document_type(item)
|
|
.register_document_type(settings)
|
|
.add_generic_store(SingleFileMessagePackBackingStore(store_path))
|
|
.build()
|
|
)
|
|
cache.put(item, "item:potion", Item(name="Potion", category="Consumable", value=50))
|
|
cache.put_global(settings, {"theme": "ash"})
|
|
before = cache.snapshot_envelopes()
|
|
|
|
# A second record of the global type lands in the store file behind the cache's back.
|
|
stray = replace(cache.get_required_envelope(settings, CultCache.GLOBAL_KEY), key="stray")
|
|
SingleFileMessagePackBackingStore(store_path).push(stray)
|
|
|
|
with self.assertRaisesRegex(CultCacheError, "Duplicate global document for type: settings"):
|
|
cache.pull_all_backing_stores()
|
|
self.assertEqual(cache.snapshot_envelopes(), before)
|
|
self.assertEqual(cache.get_required(item, "item:potion").value, 50)
|
|
self.assertEqual(cache.get_key_by_name(item, "Potion"), "item:potion")
|
|
self.assertEqual(cache.get_required_global(settings)["theme"], "ash")
|
|
|
|
def test_pull_with_throwing_index_accessor_leaves_cache_unchanged(self) -> None:
|
|
def category(item: Item) -> str:
|
|
if item.name == "Bomb":
|
|
raise ValueError("category accessor refuses Bomb")
|
|
return item.category
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
item = define_document_type(
|
|
"item",
|
|
encode=lambda value: asdict(value),
|
|
decode=lambda raw: Item(**raw),
|
|
name="name",
|
|
indexes={"category": category},
|
|
)
|
|
settings = define_document_type("settings", global_document=True)
|
|
store_path = Path(tmp) / "generic.cc"
|
|
cache = (
|
|
CultCache.builder()
|
|
.register_document_type(item)
|
|
.register_document_type(settings)
|
|
.add_generic_store(SingleFileMessagePackBackingStore(store_path))
|
|
.build()
|
|
)
|
|
cache.put(item, "item:potion", Item(name="Potion", category="Consumable", value=50))
|
|
cache.put_global(settings, {"theme": "ash"})
|
|
before_envelopes = cache.snapshot_envelopes()
|
|
before_values = cache.snapshot()
|
|
|
|
# A record whose index accessor throws lands in the store file behind the cache's back.
|
|
bomb = replace(
|
|
cache.get_required_envelope(item, "item:potion"),
|
|
key="item:bomb",
|
|
payload=item.encode_payload(Item(name="Bomb", category="Explosive", value=1)),
|
|
)
|
|
SingleFileMessagePackBackingStore(store_path).push(bomb)
|
|
|
|
with self.assertRaisesRegex(ValueError, "category accessor refuses Bomb"):
|
|
cache.pull_all_backing_stores()
|
|
self.assertEqual(cache.snapshot_envelopes(), before_envelopes)
|
|
self.assertEqual(cache.snapshot(), before_values)
|
|
self.assertEqual(cache.get_required(item, "item:potion").value, 50)
|
|
self.assertIsNone(cache.get(item, "item:bomb"))
|
|
self.assertEqual(cache.get_key_by_name(item, "Potion"), "item:potion")
|
|
self.assertIsNone(cache.get_key_by_name(item, "Bomb"))
|
|
self.assertEqual(cache.get_key_by_index(item, "category", "Consumable"), "item:potion")
|
|
self.assertEqual(cache.get_required_global(settings)["theme"], "ash")
|
|
|
|
def _store_state(self, path: Path) -> list[tuple[str, str, bytes]]:
|
|
return sorted((e.type, e.key, e.payload) for e in SingleFileMessagePackBackingStore(path).pull_all())
|
|
|
|
def test_put_with_throwing_name_extractor_changes_neither_store_nor_cache(self) -> None:
|
|
def name(value: dict) -> str:
|
|
if value["n"] == "bad":
|
|
raise ValueError("extractor refuses bad")
|
|
return value["n"]
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
item = define_document_type("item", name=name)
|
|
path = Path(tmp) / "generic.cc"
|
|
cache = CultCache.builder().register_document_type(item).add_generic_store(
|
|
SingleFileMessagePackBackingStore(path)
|
|
).build()
|
|
cache.put(item, "k", {"n": "good"})
|
|
store_before, cache_before = self._store_state(path), cache.snapshot_envelopes()
|
|
|
|
with self.assertRaisesRegex(ValueError, "extractor refuses bad"):
|
|
cache.put(item, "k", {"n": "bad"})
|
|
self.assertEqual(self._store_state(path), store_before)
|
|
self.assertEqual(cache.snapshot_envelopes(), cache_before)
|
|
self.assertEqual(cache.get_required(item, "k"), {"n": "good"})
|
|
self.assertEqual(cache.get_key_by_name(item, "good"), "k")
|
|
|
|
def test_overwrite_under_a_registered_extractor_that_now_raises_changes_neither_store_nor_cache(self) -> None:
|
|
refusing = {"on": False}
|
|
|
|
def category(value: dict) -> str:
|
|
if refusing["on"]:
|
|
raise ValueError("extractor refuses now")
|
|
return value["c"]
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
item = define_document_type("item")
|
|
path = Path(tmp) / "generic.cc"
|
|
cache = CultCache.builder().register_document_type(item).add_generic_store(
|
|
SingleFileMessagePackBackingStore(path)
|
|
).build()
|
|
cache.put(item, "k", {"c": "old"})
|
|
cache.register_index(item, "category", category)
|
|
refusing["on"] = True
|
|
store_before, cache_before = self._store_state(path), cache.snapshot_envelopes()
|
|
|
|
with self.assertRaisesRegex(ValueError, "extractor refuses now"):
|
|
cache.put(item, "k", {"c": "new"})
|
|
self.assertEqual(self._store_state(path), store_before)
|
|
self.assertEqual(cache.snapshot_envelopes(), cache_before)
|
|
self.assertEqual(cache.get_key_by_index(item, "category", "old"), "k")
|
|
# Deleting runs no extractor: it uses the lookup keys stored when the record was admitted.
|
|
cache.delete(item, "k")
|
|
self.assertIsNone(cache.get_key_by_index(item, "category", "old"))
|
|
|
|
def test_registering_an_extractor_that_raises_on_a_held_value_installs_nothing(self) -> None:
|
|
def name(value: dict) -> str:
|
|
if value["n"] == "bad":
|
|
raise ValueError("extractor refuses bad")
|
|
return value["n"]
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
item = define_document_type("item")
|
|
path = Path(tmp) / "generic.cc"
|
|
cache = CultCache.builder().register_document_type(item).add_generic_store(
|
|
SingleFileMessagePackBackingStore(path)
|
|
).build()
|
|
cache.put(item, "old", {"n": "bad"})
|
|
with self.assertRaisesRegex(ValueError, "extractor refuses bad"):
|
|
cache.register_name_lookup(item, name)
|
|
cache.put(item, "old", {"n": "fine"})
|
|
self.assertEqual(cache.get_required(item, "old"), {"n": "fine"})
|
|
self.assertIsNone(cache.get_key_by_name(item, "fine"))
|
|
self.assertEqual([item.decode_payload(e.payload) for e in SingleFileMessagePackBackingStore(path).pull_all()], [{"n": "fine"}])
|
|
|
|
def test_put_envelopes_with_throwing_extractor_changes_neither_store_nor_cache(self) -> None:
|
|
def name(value: dict) -> str:
|
|
if value["n"] == "bad":
|
|
raise ValueError("extractor refuses bad")
|
|
return value["n"]
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
item = define_document_type("item", name=name)
|
|
path = Path(tmp) / "generic.cc"
|
|
cache = CultCache.builder().register_document_type(item).add_generic_store(
|
|
SingleFileMessagePackBackingStore(path)
|
|
).build()
|
|
cache.put(item, "seed", {"n": "seed"})
|
|
seed = cache.get_required_envelope(item, "seed")
|
|
first = replace(seed, key="a", payload=item.encode_payload({"n": "a"}))
|
|
second = replace(seed, key="b", payload=item.encode_payload({"n": "bad"}))
|
|
store_before, cache_before = self._store_state(path), cache.snapshot_envelopes()
|
|
|
|
with self.assertRaisesRegex(ValueError, "extractor refuses bad"):
|
|
cache.put_envelopes(item, [first, second])
|
|
self.assertEqual(self._store_state(path), store_before)
|
|
self.assertEqual(cache.snapshot_envelopes(), cache_before)
|
|
self.assertIsNone(cache.get_key_by_name(item, "a"))
|
|
|
|
def test_global_replace_with_failing_store_push_keeps_old_global(self) -> None:
|
|
class FailingPushStore(SingleFileMessagePackBackingStore):
|
|
fail = False
|
|
|
|
def push(self, envelope):
|
|
if self.fail:
|
|
raise OSError("disk full")
|
|
super().push(envelope)
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
settings = define_document_type("settings", global_document=True)
|
|
path = Path(tmp) / "generic.cc"
|
|
store = FailingPushStore(path)
|
|
cache = CultCache.builder().register_document_type(settings).add_generic_store(store).build()
|
|
cache.put_global(settings, {"theme": "base"})
|
|
store_before, cache_before = self._store_state(path), cache.snapshot_envelopes()
|
|
store.fail = True
|
|
|
|
with self.assertRaisesRegex(OSError, "disk full"):
|
|
cache.put_global(settings, {"theme": "new"})
|
|
with self.assertRaisesRegex(CultCacheError, "must use key __global__"):
|
|
cache.put(settings, "other", {"theme": "new"})
|
|
self.assertEqual(self._store_state(path), store_before)
|
|
self.assertEqual(cache.snapshot_envelopes(), cache_before)
|
|
self.assertEqual(cache.get_required_global(settings), {"theme": "base"})
|
|
|
|
@staticmethod
|
|
def _pausing_store(path: Path, barrier: threading.Barrier, resume: threading.Event):
|
|
"""A store whose first push writes, meets the other thread at the barrier, then waits until that
|
|
thread's call has returned (or a timeout: a locked cache makes the other thread wait on us)."""
|
|
|
|
class PausingStore(SingleFileMessagePackBackingStore):
|
|
pushes = 0
|
|
paused = False
|
|
other_reached_store_while_paused = False
|
|
|
|
def push(self, envelope):
|
|
self.other_reached_store_while_paused |= self.paused
|
|
self.pushes += 1
|
|
super().push(envelope)
|
|
if self.pushes == 1:
|
|
self.paused = True
|
|
barrier.wait(timeout=5)
|
|
resume.wait(timeout=0.5)
|
|
self.paused = False
|
|
|
|
return PausingStore(path)
|
|
|
|
@staticmethod
|
|
def _run_racing(first, second, resume: threading.Event) -> list[BaseException]:
|
|
errors: list[BaseException] = []
|
|
|
|
def run(action, signal):
|
|
try:
|
|
action()
|
|
except BaseException as error: # noqa: BLE001 - reported to the test
|
|
errors.append(error)
|
|
finally:
|
|
if signal:
|
|
resume.set()
|
|
|
|
threads = [threading.Thread(target=run, args=(first, False)), threading.Thread(target=run, args=(second, True))]
|
|
for thread in threads:
|
|
thread.start()
|
|
for thread in threads:
|
|
thread.join(timeout=10)
|
|
return errors
|
|
|
|
def test_concurrent_put_and_attach_cannot_land_record_in_non_home_store(self) -> None:
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
settings = define_database_entry_type("settings", [("theme", 0)])
|
|
generic_path, settings_path = Path(tmp) / "generic.cc", Path(tmp) / "settings.cc"
|
|
barrier, resume = threading.Barrier(2), threading.Event()
|
|
generic = self._pausing_store(generic_path, barrier, resume)
|
|
cache = CultCache.builder().register_document_type(settings).add_generic_store(generic).build()
|
|
|
|
def attach() -> None:
|
|
barrier.wait(timeout=5)
|
|
cache.add_backing_store(SingleFileMessagePackBackingStore(settings_path), ["settings"])
|
|
|
|
errors = self._run_racing(lambda: cache.put(settings, "app", {"theme": "t"}), attach, resume)
|
|
|
|
# Either the attach was refused (the record is held, so its home cannot move) or it won;
|
|
# in every case each held record must be in the store that is its home.
|
|
home = SingleFileMessagePackBackingStore(generic_path if errors else settings_path)
|
|
self.assertEqual([(e.key, e.type) for e in home.pull_all()], [("app", "settings")])
|
|
self.assertTrue(all(isinstance(error, CultCacheError) for error in errors), errors)
|
|
|
|
def test_concurrent_global_puts_cannot_both_reach_disk(self) -> None:
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
settings = define_document_type("settings", global_document=True)
|
|
path = Path(tmp) / "generic.cc"
|
|
barrier, resume = threading.Barrier(2), threading.Event()
|
|
store = self._pausing_store(path, barrier, resume)
|
|
cache = CultCache.builder().register_document_type(settings).add_generic_store(store).build()
|
|
|
|
def second() -> None:
|
|
barrier.wait(timeout=5)
|
|
cache.put_global(settings, {"theme": "second"})
|
|
|
|
errors = self._run_racing(lambda: cache.put_global(settings, {"theme": "first"}), second, resume)
|
|
|
|
self.assertEqual(errors, [])
|
|
# While the first write was between its store push and its apply, the second never reached disk.
|
|
self.assertFalse(store.other_reached_store_while_paused)
|
|
on_disk = SingleFileMessagePackBackingStore(path).pull_all()
|
|
self.assertEqual(len(on_disk), 1)
|
|
self.assertEqual(on_disk[0].payload, cache.get_required_envelope(settings, CultCache.GLOBAL_KEY).payload)
|
|
self.assertEqual(cache.get_required_global(settings), {"theme": "second"})
|
|
|
|
@staticmethod
|
|
def _envelope(document, key: str, value) -> CultCacheEnvelope:
|
|
catalog_entry = document.catalog_entry()
|
|
return CultCacheEnvelope.create(
|
|
key=key,
|
|
type=document.type,
|
|
payload=document.encode_payload(value),
|
|
schema_id=catalog_entry.schema_id,
|
|
catalog_entry=catalog_entry,
|
|
)
|
|
|
|
def test_legacy_key_global_loads_without_writing_and_first_write_leaves_one_global(self) -> None:
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
settings = define_document_type("settings", global_document=True)
|
|
path = Path(tmp) / "generic.cc"
|
|
|
|
def build() -> CultCache:
|
|
return CultCache.builder().register_document_type(settings).add_generic_store(
|
|
SingleFileMessagePackBackingStore(path)
|
|
).build()
|
|
|
|
SingleFileMessagePackBackingStore(path).push(self._envelope(settings, "legacy", {"theme": "old"}))
|
|
stored_bytes = path.read_bytes()
|
|
|
|
cache = build()
|
|
cache.pull_all_backing_stores()
|
|
self.assertEqual(cache.get_global(settings), {"theme": "old"})
|
|
self.assertEqual(cache.get(settings, CultCache.GLOBAL_KEY), {"theme": "old"})
|
|
self.assertEqual(path.read_bytes(), stored_bytes)
|
|
|
|
cache.put_global(settings, {"theme": "new"})
|
|
self.assertEqual([e.key for e in SingleFileMessagePackBackingStore(path).pull_all()], [CultCache.GLOBAL_KEY])
|
|
reloaded = build()
|
|
reloaded.pull_all_backing_stores()
|
|
self.assertEqual(reloaded.get_required_global(settings), {"theme": "new"})
|
|
|
|
def test_deleting_a_legacy_key_global_removes_the_legacy_record(self) -> None:
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
settings = define_document_type("settings", global_document=True)
|
|
path = Path(tmp) / "generic.cc"
|
|
SingleFileMessagePackBackingStore(path).push(self._envelope(settings, "legacy", {"theme": "old"}))
|
|
cache = CultCache.builder().register_document_type(settings).add_generic_store(
|
|
SingleFileMessagePackBackingStore(path)
|
|
).build()
|
|
cache.pull_all_backing_stores()
|
|
|
|
cache.delete_global(settings)
|
|
self.assertEqual(SingleFileMessagePackBackingStore(path).pull_all(), [])
|
|
self.assertIsNone(cache.get_global(settings))
|
|
|
|
def test_two_globals_of_one_type_on_disk_are_refused_under_any_keys(self) -> None:
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
settings = define_document_type("settings", global_document=True)
|
|
for keys in (["one", "two"], ["legacy", CultCache.GLOBAL_KEY]):
|
|
path = Path(tmp) / f"{keys[0]}.cc"
|
|
store = SingleFileMessagePackBackingStore(path)
|
|
store.push_all([self._envelope(settings, key, {"theme": key}) for key in keys])
|
|
cache = CultCache.builder().register_document_type(settings).add_generic_store(store).build()
|
|
with self.assertRaisesRegex(CultCacheError, "Duplicate global document for type: settings"):
|
|
cache.pull_all_backing_stores()
|
|
self.assertIsNone(cache.get_global(settings))
|
|
|
|
def test_decoder_writing_during_pull_raises_and_changes_neither_store_nor_cache(self) -> None:
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
path = Path(tmp) / "generic.cc"
|
|
holder: dict[str, CultCache] = {}
|
|
|
|
def decode(raw):
|
|
holder["cache"].put(item, "side", {"name": "side"})
|
|
return raw
|
|
|
|
item = define_document_type("item", decode=decode, encode=lambda value: value)
|
|
store = SingleFileMessagePackBackingStore(path)
|
|
store.push(self._envelope(item, "a", {"name": "a"}))
|
|
stored_bytes = path.read_bytes()
|
|
cache = holder["cache"] = CultCache.builder().register_document_type(item).add_generic_store(store).build()
|
|
|
|
with self.assertRaisesRegex(CultCacheError, "re-entrant put"):
|
|
cache.pull_all_backing_stores()
|
|
self.assertEqual(path.read_bytes(), stored_bytes)
|
|
self.assertEqual(cache.snapshot_envelopes(), [])
|
|
self.assertIsNone(cache.get(item, "side"))
|
|
# The refusal released the cache: an ordinary write still works.
|
|
cache.put(item, "b", {"name": "b"})
|
|
self.assertEqual(cache.get(item, "b"), {"name": "b"})
|
|
|
|
def test_index_extractor_writing_during_register_index_raises_and_installs_nothing(self) -> None:
|
|
thing = define_document_type("thing")
|
|
cache = CultCache.builder().register_document_type(thing).build()
|
|
cache.put(thing, "k1", {"n": "one", "cat": "x"})
|
|
|
|
def category(value: dict) -> str:
|
|
cache.put(thing, "k2", {"n": "two", "cat": "y"})
|
|
return value["cat"]
|
|
|
|
with self.assertRaisesRegex(CultCacheError, "re-entrant put"):
|
|
cache.register_index(thing, "cat", category)
|
|
self.assertIsNone(cache.get(thing, "k2"))
|
|
self.assertNotIn("thing", cache._state.index_extractors)
|
|
self.assertIsNone(cache.get_key_by_index(thing, "cat", "x"))
|
|
|
|
def test_put_envelope_refuses_a_global_under_another_key(self) -> None:
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
settings = define_document_type("settings", global_document=True)
|
|
path = Path(tmp) / "generic.cc"
|
|
cache = CultCache.builder().register_document_type(settings).add_generic_store(
|
|
SingleFileMessagePackBackingStore(path)
|
|
).build()
|
|
cache.put_global(settings, {"theme": "base"})
|
|
store_before, cache_before = self._store_state(path), cache.snapshot_envelopes()
|
|
stray = replace(cache.get_required_envelope(settings, CultCache.GLOBAL_KEY), key="stray")
|
|
|
|
with self.assertRaisesRegex(CultCacheError, "must use key __global__"):
|
|
cache.put_envelope(settings, stray)
|
|
with self.assertRaisesRegex(CultCacheError, "must be non-empty"):
|
|
cache.put_envelope(settings, replace(stray, key=""))
|
|
self.assertEqual(self._store_state(path), store_before)
|
|
self.assertEqual(cache.snapshot_envelopes(), cache_before)
|
|
|
|
def test_refused_put_leaves_no_empty_type_entry(self) -> None:
|
|
def refuse(value: dict) -> dict:
|
|
raise ValueError("encode refuses")
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
other = define_document_type("other", encode=refuse)
|
|
path = Path(tmp) / "generic.cc"
|
|
cache = CultCache.builder().register_document_type(other).add_generic_store(
|
|
SingleFileMessagePackBackingStore(path)
|
|
).build()
|
|
with self.assertRaisesRegex(ValueError, "encode refuses"):
|
|
cache.put(other, "x", {"a": 1})
|
|
self.assertEqual(cache.snapshot(), {})
|
|
self.assertFalse(path.exists())
|
|
|
|
def test_attach_with_empty_type_list_is_the_generic_store(self) -> None:
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
settings = define_database_entry_type("settings", [("theme", 0)])
|
|
store_path = Path(tmp) / "generic.cc"
|
|
cache = CultCache.builder().register_document_type(settings).build()
|
|
cache.add_backing_store(SingleFileMessagePackBackingStore(store_path), [])
|
|
cache.put(settings, "y", {"theme": "t"})
|
|
self.assertEqual(
|
|
[(e.key, e.type) for e in SingleFileMessagePackBackingStore(store_path).pull_all()],
|
|
[("y", "settings")],
|
|
)
|
|
with self.assertRaisesRegex(CultCacheError, "second generic store"):
|
|
cache.add_backing_store(SingleFileMessagePackBackingStore(Path(tmp) / "b.cc"), [])
|
|
|
|
def test_zero_store_cache_writes_and_deletes_in_memory(self) -> None:
|
|
settings = define_database_entry_type("settings", [("theme", 0)])
|
|
cache = CultCache.builder().register_document_type(settings).build()
|
|
cache.put(settings, "x", {"theme": "mem"})
|
|
self.assertEqual(cache.get_required(settings, "x"), {"theme": "mem"})
|
|
cache.delete(settings, "x")
|
|
self.assertIsNone(cache.get(settings, "x"))
|
|
|
|
def test_cache_put_without_backing_store_keeps_in_memory_value(self) -> None:
|
|
document = define_database_entry_type(
|
|
"bench.memory_item",
|
|
[
|
|
("name", 0),
|
|
("value", 1, 0),
|
|
],
|
|
)
|
|
cache = CultCache()
|
|
cache.register_document_type(document)
|
|
|
|
cache.put(document, "item:one", {"name": "one", "value": 1})
|
|
|
|
self.assertEqual(cache.get(document, "item:one"), {"name": "one", "value": 1})
|
|
self.assertEqual(cache.get_required_envelope(document, "item:one").key, "item:one")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|