diff --git a/packages/cultcache-py/README.md b/packages/cultcache-py/README.md index f8ac551f..23effdbd 100644 --- a/packages/cultcache-py/README.md +++ b/packages/cultcache-py/README.md @@ -21,7 +21,11 @@ discovery, sessions, local server) build on it, mirroring `cultcache-rs` / - payloads decode only through the registered document definition for that type - unknown persisted types fail closed - global documents are singleton-style per type -- type-specific backing stores beat generic backing stores +- one home store per document type: a store added with `add_backing_store` + owns its types, otherwise the generic store is the home; a type claimed by a + second store, or a second generic store, raises `CultCacheError`; a write + touches exactly one store and nothing is mirrored; once any store is + attached, a write whose type has no home raises - the JSONL store uses only the Python standard library for bootstrap consumers - `define_database_entry_type(...)` emits Rust/C#-style slot-indexed MessagePack array payloads for cross-runtime `DatabaseEntry` contracts diff --git a/packages/cultcache-py/pyproject.toml b/packages/cultcache-py/pyproject.toml index 72573cf2..6c46d102 100644 --- a/packages/cultcache-py/pyproject.toml +++ b/packages/cultcache-py/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "cultcache-py" -version = "0.2.0" +version = "0.3.0" description = "Python CultCache runtime for GameCult typed state." readme = "README.md" requires-python = ">=3.10" diff --git a/packages/cultcache-py/src/cultcache_py/cache.py b/packages/cultcache-py/src/cultcache_py/cache.py index f7ad1c12..5fe60b1e 100644 --- a/packages/cultcache-py/src/cultcache_py/cache.py +++ b/packages/cultcache-py/src/cultcache_py/cache.py @@ -20,8 +20,8 @@ class _State: documents_by_schema_name: dict[str, DocumentDefinition[Any]] = field(default_factory=dict) values: dict[str, dict[str, Any]] = field(default_factory=dict) envelopes: dict[str, dict[str, CultCacheEnvelope]] = field(default_factory=dict) - stores_by_type: dict[str, list[BackingStore]] = field(default_factory=dict) - generic_stores: list[BackingStore] = field(default_factory=list) + stores_by_type: dict[str, BackingStore] = field(default_factory=dict) + generic_store: BackingStore | None = None name_extractors: dict[str, str | Any] = field(default_factory=dict) index_extractors: dict[str, dict[str, str | Any]] = field(default_factory=dict) names: dict[tuple[str, str], str] = field(default_factory=dict) @@ -98,17 +98,32 @@ class CultCache: self._rebuild_indexes() def add_backing_store(self, store: BackingStore, types: list[str] | tuple[str, ...] | set[str]) -> None: + """Makes the store home to the given types. A type has exactly one home store.""" for type in types: - self._state.stores_by_type.setdefault(type, []).append(store) + if type in self._state.stores_by_type: + raise CultCacheError( + f"Document type {type} is already routed to another backing store; it cannot also route to this one" + ) + for type in types: + self._state.stores_by_type[type] = store def add_generic_store(self, store: BackingStore) -> None: - self._state.generic_stores.append(store) + """Makes the store home to every type no other store claims. A cache has at most one.""" + if self._state.generic_store is not None: + raise CultCacheError( + "Backing store would be a second generic store; name the types it is home to" + ) + self._state.generic_store = store def pull_all_backing_stores(self) -> None: self._state.values.clear() self._state.envelopes.clear() seen_globals: set[str] = set() - for store in [*self._all_specific_stores(), *self._state.generic_stores]: + stores: list[BackingStore] = [] + for candidate in [*self._state.stores_by_type.values(), self._state.generic_store]: + if candidate is not None and all(candidate is not store for store in stores): + stores.append(candidate) + for store in stores: for envelope in store.pull_all(): document = self._resolve_document_for_envelope(envelope) if document is None: @@ -196,8 +211,8 @@ class CultCache: schema_id=catalog_entry.schema_id, catalog_entry=catalog_entry, ) - stores = self._stores_for_type(document.type) - for store in stores: + store = self._store_for_type(document.type) + if store is not None: store.push(envelope) if old_value is not None: self._remove_value_indexes(document.type, key, old_value) @@ -215,7 +230,8 @@ class CultCache: envelopes = self._state.envelopes.setdefault(document.type, {}) old_value = values.get(envelope.key) value = document.decode_payload(envelope.payload) - for store in self._stores_for_type(document.type): + store = self._store_for_type(document.type) + if store is not None: store.push(envelope) if old_value is not None: self._remove_value_indexes(document.type, envelope.key, old_value) @@ -236,8 +252,8 @@ class CultCache: raise CultCacheError(f"Global document {document.type} must use key {GLOBAL_KEY}") values.append(document.decode_payload(envelope.payload)) - stores = self._stores_for_type(document.type) - for store in stores: + store = self._store_for_type(document.type) + if store is not None: store.push_all(envelopes) values_by_key = self._state.values.setdefault(document.type, {}) envelopes_by_key = self._state.envelopes.setdefault(document.type, {}) @@ -266,7 +282,8 @@ class CultCache: def delete(self, document: DocumentDefinition[Any], key: str) -> None: self._assert_registered(document) - for store in self._stores_for_type(document.type): + store = self._store_for_type(document.type) + if store is not None: store.delete(document.type, key) values = self._state.values.get(document.type) old_value = None if values is None else values.pop(key, None) @@ -297,20 +314,15 @@ class CultCache: for envelope in envelopes.values() ] - def _stores_for_type(self, type: str) -> list[BackingStore]: - specific = self._state.stores_by_type.get(type) - return specific if specific else self._state.generic_stores + def _store_for_type(self, type: str) -> BackingStore | None: + """The store that claims the type, else the generic store. - def _all_specific_stores(self) -> list[BackingStore]: - stores: list[BackingStore] = [] - seen: set[int] = set() - for routed in self._state.stores_by_type.values(): - for store in routed: - marker = id(store) - if marker not in seen: - stores.append(store) - seen.add(marker) - return stores + Returns None only for a cache with no stores, which is an in-memory cache. + """ + store = self._state.stores_by_type.get(type, self._state.generic_store) + if store is None and self._state.stores_by_type: + raise CultCacheError(f"No backing store is home to document type: {type}") + return store def _rebuild_indexes(self) -> None: self._state.names.clear() diff --git a/packages/cultcache-py/tests/test_cultcache.py b/packages/cultcache-py/tests/test_cultcache.py index 2c23fbf7..8e45b7ae 100644 --- a/packages/cultcache-py/tests/test_cultcache.py +++ b/packages/cultcache-py/tests/test_cultcache.py @@ -4,6 +4,7 @@ import tempfile import unittest from dataclasses import asdict, dataclass from pathlib import Path +from cultcache_py.cache import CultCacheError from cultcache_py import ( CultCache, JsonLinesBackingStore, @@ -238,6 +239,57 @@ class CultCacheTests(unittest.TestCase): 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_cache_put_without_backing_store_keeps_in_memory_value(self) -> None: document = define_database_entry_type( "bench.memory_item",