Give every cultcache-py document type one home store

Cut 5 (Python). stores_by_type maps each type to one store and generic_stores
becomes a single generic_store. add_backing_store raises on a type already
claimed and add_generic_store on a second generic store; a refused
registration claims nothing.

_stores_for_type and _all_specific_stores are deleted. _store_for_type returns
the home store and raises when stores are attached but none is home to the
type. It returns None only for a cache with no stores, which the contract
keeps as an in-memory cache. put, put_envelope, put_envelopes and delete each
write one store; pull_all_backing_stores pulls each distinct store once.

Tests: test_second_generic_store_rejected, test_type_claimed_twice_rejected,
test_types_route_to_home_store. No bytes change.

Version 0.2.0 -> 0.3.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-14 00:37:31 +02:00
commit 5967a1f25d
4 changed files with 94 additions and 26 deletions

View file

@ -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

View file

@ -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"

View file

@ -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()

View file

@ -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",