Python cache: one record per key, foreign carry, key-identity vector walked #122

Merged
libby merged 5 commits from hands/variants-leaf-key-identity-py into main 2026-11-12 00:45:12 +00:00
9 changed files with 458 additions and 16 deletions

View file

@ -33,8 +33,8 @@ Probes: scratch tests in this worktree, removed. C# filtered runs on Starfire (o
| Probe | C# | TypeScript | Rust | Python |
|---|---|---|---|---|
| P1 cache {A,B}: B at k, then A at k | **C**: B's record replaced, k now A's id | R `SchemaConflictError` (cultcache-ts: one record per key) | C, both live at k | C, both live at k |
| P2 cache {A} only, store holds B at k, write A at k | R `CultSchemaConflictException` (B is foreign, carried) | R `SchemaConflictError` at write (foreign loads, carried; cultcache-ts) | R at load, same | R at load: "Unknown persisted document type" |
| P1 cache {A,B}: B at k, then A at k | **C**: B's record replaced, k now A's id | R `SchemaConflictError` (cultcache-ts: one record per key) | C, both live at k | R `SchemaConflictError`, A's write refused, B kept (was C, both live) |
| P2 cache {A} only, store holds B at k, write A at k | R `CultSchemaConflictException` (B is foreign, carried) | R `SchemaConflictError` at write (foreign loads, carried; cultcache-ts) | R at load, same | R `SchemaConflictError` (B is foreign, carried; the load admits it; was R at load) |
| P3 cacheless `WriteDocument<A>` over B, registry null / {A} / {A,B} | R / R / R | no cacheless write | no cacheless write | no cacheless write |
| P4 cache {A,B}: remove k and upsert A at k in one batch | C (k is A's) | n/a | n/a | n/a |
| P5 cache {A,B}: remove, then add in a second commit | C | n/a | n/a | n/a |

View file

@ -9,6 +9,9 @@
### Breaking
- A cache holds one record per key whatever its type (`variants:ruling:cache-refuses-retype`). A `put`, `put_envelope`, `put_envelopes` or `put_global` of a second type under a key the cache holds, or under a key a store carries foreign, is refused with `SchemaConflictError` naming the key, even with no store, and changes nothing; retype a key by deleting it, then putting the new type. A write may replace another registered type's record only when the written type owns its schema id (declares it or lists it compatible) and both types share one home store. A load that reads one key from two stores, whatever the types, is refused with `CultCacheError` naming the key and both types, and admits nothing. A store that holds a record no registered type claims now loads and is carried: the cache does not serve it, the store keeps its bytes through every write, `delete` or `put` at its key is refused with `SchemaConflictError`, and registering a type that declares its schema id claims it at the next load. It raised `CultCacheError` "Unknown persisted document type". Two global types share the key `__global__`, so a second global type in one cache is now refused, and a store that already holds two global types at `__global__` (a JSONL store written by caches that never pulled could produce one) no longer loads: the pull raises `CultCacheError` and admits nothing. C# refuses a second persisted global on pull the same way (`cultcache-store-composition.md`, "Globals never invented"); repair such a file by deleting one of the two records with the runtime that wrote it.
- Added `foreign_records()` and the frozen `ForeignRecord(key, schema_id, schema_name, stored_at)`, sorted by key in UTF-16 code-unit order. `stores.retyped` and `stores.written_owns` lose their underscore: the cache judge and the single-file store's commit guard ask one predicate.
- A single-file store holding two records under one key is unreadable: the v1 reader and the legacy envelope-array reader refuse it with `StoreUnreadableError` (a key is compared by its exact code points, no case folding or normalisation). A catalog slot whose canonical schema is not a string is refused the same way. A store that loaded before can now be refused: repair it with the runtime that wrote the duplicate.
- A `put` or `push_all` that would land a second type under a key the store holds is refused with `SchemaConflictError`, and the file is left as it was. Retype a key by deleting it, then putting the new type. A write replaces the record at a key only when the written type owns it: its catalog entry declares the stored schema id (its own, or one it lists compatible), so a renamed type that lists the old id compatible may replace the record, or it has the stored record's schema name. This runtime does not compare shapes, so a same-name type is never refused for dropping persisted slots as C# refuses it.
- Writes of the same type replace by key: the last record wins, in a batch as in the file.

View file

@ -19,7 +19,11 @@ discovery, sessions, local server) build on it, mirroring `cultcache-rs` /
`[format, schemaCatalog, records]`
- records store raw MessagePack payload bytes under schema-catalog identity
- payloads decode only through the registered document definition for that type
- unknown persisted types fail closed
- a cache holds one record per key whatever its type: a write of a second type at a held key is refused with
`SchemaConflictError` (in memory too), and two stores holding one key refuse the load
- a store record no registered type claims loads and is carried as foreign: `cache.foreign_records()` lists it, the store
keeps its bytes through every write, a write or removal at its key is refused, and registering a type that declares its
schema id claims it at the next load
- global documents are singleton-style per type
- 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

View file

@ -1,7 +1,7 @@
from .backing_store import BackingStore, CultCacheEnvelope
from .backing_store import CultCacheSchemaCatalogEntry, CultCacheSchemaCatalogMember
from . import leaves
from .cache import CultCache, CultCacheBuilder
from .cache import CultCache, CultCacheBuilder, ForeignRecord
from .documents import (
MAX_PAYLOAD_DEPTH,
MAX_SLOT_KEY,
@ -29,6 +29,7 @@ __all__ = [
"CultCacheSchemaCatalogMember",
"DatabaseEntryField",
"DocumentDefinition",
"ForeignRecord",
"JsonLinesBackingStore",
"MAX_PAYLOAD_DEPTH",
"MAX_SLOT_KEY",

View file

@ -9,7 +9,7 @@ from typing import Any, Callable, Generic, Iterable, TypeVar
from .backing_store import BackingStore, CultCacheEnvelope
from .documents import DocumentDefinition, extract_value
from .stores import SchemaConflictError
from .stores import SchemaConflictError, retyped, written_owns
T = TypeVar("T")
F = TypeVar("F", bound=Callable[..., Any])
@ -120,6 +120,20 @@ class _State:
# of that global removes it from the home store. Remove once no store holds a global under a key
# other than GLOBAL_KEY.
legacy_global_keys: dict[str, str] = field(default_factory=dict)
# The records the last pull read that no registered type claims, sorted by key. Derived from the pull and swapped with the
# rest of the state, written nowhere else; it decides only refusals at its keys. A store keeps their bytes.
foreign: list["ForeignRecord"] = field(default_factory=list)
@dataclass(frozen=True)
class ForeignRecord:
"""A record a store holds that no registered type claims: the cache does not serve it, refuses a write or a removal at its
key, and a store keeps its bytes. Registering a type that declares its schema id claims it at the next load."""
key: str
schema_id: str
schema_name: str
stored_at: str
@dataclass(frozen=True)
@ -322,15 +336,23 @@ class CultCache:
loaded_legacy_global_keys: dict[str, str] = {}
routes, generic = self._state.stores_by_type, self._state.generic_store
seen_globals: set[str] = set()
foreign: list[ForeignRecord] = []
type_at_key: dict[str, tuple[str, int]] = {} # one record per key across every store: its type (or schema name) and store
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 position, store in enumerate(stores):
for envelope in store.pull_all():
document = self._resolve_document_for_envelope(envelope)
if document is None:
raise CultCacheError(f"Unknown persisted document type: {envelope.type}")
entry = envelope.catalog_entry
held_name = entry.schema_name if entry is not None else envelope.type
self._claim_key(type_at_key, envelope.key, held_name, position)
foreign.append(
ForeignRecord(envelope.key, envelope.schema_id or envelope.type, held_name, envelope.stored_at)
)
continue
if envelope.type != document.type:
envelope = CultCacheEnvelope(
key=envelope.key,
@ -353,6 +375,7 @@ class CultCache:
if envelope.key != GLOBAL_KEY:
loaded_legacy_global_keys[envelope.type] = envelope.key
envelope = replace(envelope, key=GLOBAL_KEY)
self._claim_key(type_at_key, envelope.key, envelope.type, position)
envelope = self._stamped(document, envelope)
value = document.decode_payload(envelope.payload)
loaded_values.setdefault(envelope.type, {})[envelope.key] = value
@ -369,7 +392,28 @@ class CultCache:
self._state.indexes,
self._state.lookup_keys,
self._state.legacy_global_keys,
) = (loaded_values, loaded_envelopes, loaded_names, loaded_indexes, loaded_lookup_keys, loaded_legacy_global_keys)
self._state.foreign,
) = (
loaded_values,
loaded_envelopes,
loaded_names,
loaded_indexes,
loaded_lookup_keys,
loaded_legacy_global_keys,
sorted(foreign, key=lambda record: _utf16_order(record.key)),
)
@staticmethod
def _claim_key(type_at_key: dict[str, tuple[str, int]], key: str, type: str, store: int) -> None:
"""A key holds one record whatever its type, across every store a load reads. The refusal names the key and both types,
never a payload."""
held, held_store = type_at_key.setdefault(key, (type, store))
if held != type or held_store != store:
raise CultCacheError(f"Record {key!r} is held by both {held} and {type}: a key holds one record; the load admitted nothing.")
def foreign_records(self) -> list[ForeignRecord]:
"""The records the last load read that no registered type claims, sorted by key (UTF-16 code-unit order)."""
return list(self._state.foreign)
def get(self, document: DocumentDefinition[T], key: str) -> T | None:
self._assert_registered(document)
@ -497,6 +541,7 @@ class CultCache:
(envelope, value, self._lookup_keys_for(value, name_extractor, index_extractors))
for envelope, value in records
]
displaced = self._refuse_retype(document, [envelope for envelope, _ in records])
self._refuse_index_sharing(
[(type, envelope.key, keys) for envelope, _, keys in admitted],
self._state.indexes,
@ -515,6 +560,8 @@ class CultCache:
values = self._state.values.setdefault(type, {})
envelopes = self._state.envelopes.setdefault(type, {})
lookup_keys = self._state.lookup_keys.setdefault(type, {})
for other, key in displaced:
self._drop(other, key)
for envelope, value, keys in admitted:
self._remove_lookups(type, envelope.key)
values[envelope.key] = value
@ -525,6 +572,39 @@ class CultCache:
for index, index_value in keys.indexes.items():
self._state.indexes[(type, index, index_value)] = envelope.key
def _refuse_retype(self, document: DocumentDefinition[Any], written: list[CultCacheEnvelope]) -> list[tuple[str, str]]:
"""The one key-identity judge (variants:ruling:cache-refuses-retype): a key holds one record whatever its type, so a write
may not land at a key this cache holds another type's record at, unless the written type owns that record's schema id
(the single-file store's commit guard asks the same question of the file), and may not land at a key a store carries
foreign. Returns the (type, key) records an owning write replaces; refuses, changing nothing, otherwise."""
replaced: list[tuple[str, str]] = []
foreign = {record.key: record for record in self._state.foreign}
for envelope in written:
carried = foreign.get(envelope.key)
if carried is not None:
raise self._foreign_refusal(carried)
for other, held_envelopes in self._state.envelopes.items():
held = held_envelopes.get(envelope.key)
if other == document.type or held is None:
continue
if not written_owns(held, envelope) or self._store_for_type(other) is not self._store_for_type(document.type):
raise retyped(held, envelope)
replaced.append((other, envelope.key))
return replaced
@staticmethod
def _foreign_refusal(record: ForeignRecord) -> SchemaConflictError:
return SchemaConflictError(
record.schema_id,
[record.schema_name],
record.key,
message=(
f"Record {record.key!r} is held by the store as a {record.schema_name} (schema id {record.schema_id!r}) that no "
"registered type claims; a write or removal there would replace what this cache cannot read. Register the type "
"that owns the schema id, or remove the record with the runtime that wrote it."
),
)
@staticmethod
def _retire_legacy_global(store: BackingStore, type: str, legacy_key: str) -> None:
"""The first write of a global loaded from a legacy key removes that record in the same write. If
@ -564,19 +644,27 @@ class CultCache:
def _delete(self, document: DocumentDefinition[Any], key: str) -> None:
self._assert_registered(document)
if key not in self._state.envelopes.get(document.type, {}):
carried = next((record for record in self._state.foreign if record.key == key), None)
if carried is not None:
raise self._foreign_refusal(carried)
store = self._store_for_type(document.type)
legacy_key = self._state.legacy_global_keys.get(document.type) if key == GLOBAL_KEY else None
if store is not None:
store.delete(document.type, key if legacy_key is None else legacy_key)
if legacy_key is not None:
del self._state.legacy_global_keys[document.type]
self._remove_lookups(document.type, key)
self._drop(document.type, key)
def _drop(self, type: str, key: str) -> None:
"""Removes the held record and its lookups from the cache's maps; the store is the caller's."""
self._remove_lookups(type, key)
for by_type in (self._state.values, self._state.envelopes, self._state.lookup_keys):
held = by_type.get(document.type)
held = by_type.get(type)
if held is not None:
held.pop(key, None)
if not held:
by_type.pop(document.type, None)
by_type.pop(type, None)
@_mutating
def delete_global(self, document: DocumentDefinition[Any]) -> None:

View file

@ -245,7 +245,7 @@ def _derive_catalog(records: list[tuple[CultCacheEnvelope, bool]]) -> list[CultC
return [entry for entry, _ in chosen.values()]
def _retyped(held: CultCacheEnvelope, written: CultCacheEnvelope) -> SchemaConflictError:
def retyped(held: CultCacheEnvelope, written: CultCacheEnvelope) -> SchemaConflictError:
"""The refusal of a write that would put a second type under a key the store already holds (C# CacheBackingStore.Retyped)."""
return SchemaConflictError(
_schema_id_for(held),
@ -258,7 +258,7 @@ def _retyped(held: CultCacheEnvelope, written: CultCacheEnvelope) -> SchemaConfl
)
def _written_owns(held: CultCacheEnvelope, written: CultCacheEnvelope) -> bool:
def written_owns(held: CultCacheEnvelope, written: CultCacheEnvelope) -> bool:
"""Whether a write of `written` may replace `held` at its key (C# WrittenOwns): the written type's entry declares the held
record's schema id (its own id or one it lists compatible), or it is the type of the same schema name. The same-name half
stands in for C#'s "reads the published entry whole": this runtime does not compare shapes, so it never refuses a
@ -277,8 +277,8 @@ def _encode_snapshot(
landing: dict[str, CultCacheEnvelope] = {}
for envelope in supplied:
for held in (landing.get(envelope.key), held_by_key.get(envelope.key)):
if held is not None and not _written_owns(held, envelope):
raise _retyped(held, envelope)
if held is not None and not written_owns(held, envelope):
raise retyped(held, envelope)
landing[envelope.key] = envelope
supplied = list(landing.values())
replaced = set(landing)

View file

@ -0,0 +1,316 @@
from __future__ import annotations
import re
import shutil
import tempfile
import unittest
from pathlib import Path
import msgpack
from cultcache_py.cache import CultCacheError
from cultcache_py import (
CultCache,
JsonLinesBackingStore,
SchemaConflictError,
SingleFileMessagePackBackingStore,
database_entry_field,
define_database_entry_type,
leaves as t,
)
# variants:ruling:cache-refuses-retype, write-ownership-by-written-type, unheld-removal-refused-at-commit. The vector in
# tests/vectors/key-identity is written only by the C# reference; this walks every row against the Python cache.
VECTORS = Path(__file__).resolve().parents[3] / "tests" / "vectors" / "key-identity"
IDS = {
"alpha": "sha256:b20703a98f49c253898404ab50ba2c630720f0b8a5e1a41614d5650be22799fe",
"beta": "sha256:dee4b727cf2ee04e61ab635ff0c5f3dca7345a411dc5f1018e583ab16009cf94",
"gamma": "sha256:fbc26e351e00c9e7185e8de171c7486aba707c229c949f0aae6b889288af5f6d",
}
DOCUMENTS = {
label: define_database_entry_type(
f"tests.ki_{label}",
[database_entry_field("text", 0, codec=t.string)],
schema_id=schema_id,
schema_name=f"tests.ki_{label}",
)
for label, schema_id in IDS.items()
}
LABEL_OF = {document.type: label for label, document in DOCUMENTS.items()}
def file_records(path: Path) -> list[list[str]]:
return sorted([envelope.key, envelope.type] for envelope in SingleFileMessagePackBackingStore(path).pull_all())
def stored(path: Path, key: str):
for envelope in SingleFileMessagePackBackingStore(path).pull_all():
if envelope.key == key:
return envelope.schema_id, envelope.stored_at, envelope.payload
return None
class KeyIdentityVectorTests(unittest.TestCase):
def test_rows(self) -> None:
rows = msgpack.unpackb((VECTORS / "rows.msgpack").read_bytes(), raw=False)
walked = 0
for label, registered, ops, expect in rows:
with self.subTest(row=label), tempfile.TemporaryDirectory() as tmp:
self.walk(Path(tmp), registered, ops, expect)
walked += 1
self.assertEqual(walked, 12)
def walk(self, tmp: Path, registered: list[str], ops: list, expect: list) -> None:
refusal, held, file, foreign, kept = expect
state: dict = {"cache": None, "path": None, "before": None, "attached": []}
def builder() -> CultCache.builder:
made = CultCache.builder()
for name in registered:
made.register_document_type(DOCUMENTS[name])
return made
def open_cache(target) -> None:
if target == "memory":
state["cache"], state["path"] = builder().build(), None
return
path = tmp / "store.cc"
if target is not None and target != path:
shutil.copyfile(VECTORS / target, path)
state["before"] = path.read_bytes()
state["path"] = path
made = builder().add_generic_store(SingleFileMessagePackBackingStore(path))
state["cache"] = made.build()
state["cache"].pull_all_backing_stores()
def perform(op: list) -> None:
cache = state["cache"]
if op[0] == "open":
open_cache(op[1])
elif op[0] == "attach":
path = tmp / f"attached-{len(state['attached'])}.cc"
shutil.copyfile(VECTORS / op[1], path)
state["attached"].append((path, path.read_bytes()))
cache.add_backing_store(
SingleFileMessagePackBackingStore(path), [DOCUMENTS[name].type for name in op[2]]
)
cache.pull_all_backing_stores()
elif op[0] == "reopen":
open_cache(state["path"]) # the same file, no fresh copy
elif op[0] == "put":
cache.put(DOCUMENTS[op[1]], op[2], {"text": op[3]})
elif op[0] == "delete":
cache.delete(DOCUMENTS[op[1]], op[2])
else:
self.fail(f"unknown op {op[0]}")
for op in ops[:-1]:
perform(op)
if refusal is None:
perform(ops[-1])
else:
key = refusal[0]
with self.assertRaises((SchemaConflictError, ValueError, RuntimeError)) as caught:
perform(ops[-1])
if ops[-1][0] == "attach":
# The key is read out of the refusal's own text (it opens "Record 'key' is held by") and occurs only as a
# delimited token, never inside a schema name or other word.
message = str(caught.exception)
named = re.match(r"Record '([^']*)' is held by", message)
self.assertIsNotNone(named, message)
self.assertEqual(named.group(1), key)
self.assertEqual(len(re.findall(rf"(?<![A-Za-z0-9_]){re.escape(key)}(?![A-Za-z0-9_])", message)), 1)
self.assertEqual(message.count(key), 1)
for path, before in state["attached"]:
self.assertEqual(path.read_bytes(), before)
else:
self.assertIsInstance(caught.exception, SchemaConflictError)
self.assertEqual(caught.exception.record_key, key)
self.assertIn(key, str(caught.exception))
cache = state["cache"]
got_held = sorted(
[LABEL_OF[type], key, value["text"]]
for type, values in cache.snapshot().items()
for key, value in values.items()
)
self.assertEqual(got_held, held)
path = state["path"]
if file is None:
self.assertIsNone(path)
else:
self.assertEqual(file_records(path), file)
self.assertEqual([record.key for record in cache.foreign_records()], foreign)
if kept:
vector = tmp / "vector-before.cc"
vector.write_bytes(state["before"])
for key in kept:
self.assertIsNotNone(stored(vector, key))
self.assertEqual(stored(path, key), stored(vector, key))
def alpha_beta(*names: str, store=None) -> CultCache:
made = CultCache.builder()
for name in names:
made.register_document_type(DOCUMENTS[name])
if store is not None:
made.add_generic_store(store)
return made.build()
class KeyIdentityTests(unittest.TestCase):
def test_two_global_types_are_refused_in_one_cache_and_a_store_holding_both_does_not_load(self) -> None:
# C# refuses a second persisted global on pull (cultcache-store-composition.md, "Globals never invented"); the Python
# JSONL store and a cache with no store used to let two global types share __global__, so such a file can exist.
ga = define_database_entry_type(
"tests.ki_ga", [database_entry_field("text", 0, codec=t.string)], schema_id="sha256:" + "1" * 64, global_document=True
)
gb = define_database_entry_type(
"tests.ki_gb", [database_entry_field("text", 0, codec=t.string)], schema_id="sha256:" + "2" * 64, global_document=True
)
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "store.jsonl"
for document in (ga, gb): # one cache per type that never pulled: how a file got two globals
writer = CultCache.builder().register_document_type(document).add_generic_store(JsonLinesBackingStore(path)).build()
writer.put_global(document, {"text": document.type})
both = CultCache.builder().register_document_type(ga).register_document_type(gb).add_generic_store(JsonLinesBackingStore(path)).build()
with self.assertRaises(CultCacheError) as caught:
both.pull_all_backing_stores()
self.assertIn("'__global__' is held by both", str(caught.exception))
self.assertEqual(both.snapshot(), {})
memory = CultCache.builder().register_document_type(ga).register_document_type(gb).build()
memory.put_global(ga, {"text": "a"})
with self.assertRaises(SchemaConflictError):
memory.put_global(gb, {"text": "b"})
self.assertEqual(memory.get_required_global(ga), {"text": "a"})
def test_every_write_path_refuses_a_second_type_and_never_echoes_the_payload(self) -> None:
alpha, beta = DOCUMENTS["alpha"], DOCUMENTS["beta"]
for name in ("put", "put_envelope", "put_envelopes"):
with self.subTest(path=name):
cache = alpha_beta("alpha", "beta")
cache.put(alpha, "k", {"text": "held"})
envelope = cache.get_envelope(alpha, "k")
other = type(envelope).create(
key="k", type=beta.type, payload=beta.encode_payload({"text": "CANARY"}), schema_id=IDS["beta"]
)
writes = {
"put": lambda: cache.put(beta, "k", {"text": "CANARY"}),
"put_envelope": lambda: cache.put_envelope(beta, other),
"put_envelopes": lambda: cache.put_envelopes(beta, [other]),
}
with self.assertRaises(SchemaConflictError) as caught:
writes[name]()
self.assertEqual(caught.exception.record_key, "k")
self.assertNotIn("CANARY", str(caught.exception))
self.assertEqual(cache.get(alpha, "k"), {"text": "held"})
self.assertIsNone(cache.get(beta, "k"))
def test_two_routed_stores_holding_one_key_refuse_the_pull_and_keep_the_earlier_state(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
one, two = Path(tmp) / "one.cc", Path(tmp) / "two.cc"
shutil.copyfile(VECTORS / "catalog-store.cc", one)
shutil.copyfile(VECTORS / "run-store.cc", two)
cache = alpha_beta("alpha", "beta")
cache.add_backing_store(SingleFileMessagePackBackingStore(one), [DOCUMENTS["alpha"].type])
cache.pull_all_backing_stores()
cache.add_backing_store(SingleFileMessagePackBackingStore(two), [DOCUMENTS["beta"].type])
with self.assertRaises(Exception) as caught:
cache.pull_all_backing_stores()
self.assertIn("'qx'", str(caught.exception))
self.assertEqual(cache.get(DOCUMENTS["alpha"], "qx"), {"text": "a"})
self.assertIsNone(cache.get(DOCUMENTS["beta"], "qx"))
def test_a_foreign_record_is_listed_and_its_bytes_survive_writes_and_removals_elsewhere(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "store.cc"
shutil.copyfile(VECTORS / "foreign-store.cc", path)
before = stored(path, "k")
cache = alpha_beta("alpha", store=SingleFileMessagePackBackingStore(path))
cache.pull_all_backing_stores()
[record] = cache.foreign_records()
self.assertEqual((record.key, record.schema_id, record.schema_name), ("k", IDS["gamma"], "tests.ki_gamma"))
self.assertEqual(record.stored_at, before[1])
cache.put(DOCUMENTS["alpha"], "m", {"text": "m"})
cache.delete(DOCUMENTS["alpha"], "j")
self.assertEqual(stored(path, "k"), before)
self.assertEqual(file_records(path), [["k", "tests.ki_gamma"], ["m", "tests.ki_alpha"]])
claimed = alpha_beta("alpha", "gamma", store=SingleFileMessagePackBackingStore(path))
claimed.pull_all_backing_stores()
self.assertEqual(claimed.foreign_records(), [])
self.assertEqual(claimed.get(DOCUMENTS["gamma"], "k"), {"text": "g"})
def test_the_foreign_list_is_sorted_by_utf16_code_unit_and_replaced_by_the_next_pull(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "store.cc"
store = SingleFileMessagePackBackingStore(path)
gamma = DOCUMENTS["gamma"]
writer = alpha_beta("gamma", store=store)
for key in ("\U00010400", "A", "b", "B"):
writer.put(gamma, key, {"text": "x"})
cache = alpha_beta("alpha", store=SingleFileMessagePackBackingStore(path))
cache.pull_all_backing_stores()
self.assertEqual([record.key for record in cache.foreign_records()], ["B", "b", "\U00010400", "A"])
for key in ("B", "b", "\U00010400", "A"):
writer.delete(gamma, key)
cache.pull_all_backing_stores()
self.assertEqual(cache.foreign_records(), [])
class OwnershipAndStoreKindTests(unittest.TestCase):
"""The cache judge answers on its own where the store has no commit guard (JSONL) or the records live in two stores."""
def test_a_jsonl_store_gets_no_help_from_a_disk_judge(self) -> None:
from cultcache_py import JsonLinesBackingStore
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "store.jsonl"
gamma = DOCUMENTS["gamma"]
alpha_beta("gamma", store=JsonLinesBackingStore(path)).put(gamma, "k", {"text": "g"})
before = path.read_bytes()
cache = alpha_beta("alpha", store=JsonLinesBackingStore(path))
cache.pull_all_backing_stores()
self.assertEqual([record.key for record in cache.foreign_records()], ["k"])
with self.assertRaises(SchemaConflictError):
cache.put(DOCUMENTS["alpha"], "k", {"text": "x"})
with self.assertRaises(SchemaConflictError):
cache.delete(DOCUMENTS["alpha"], "k")
self.assertEqual(path.read_bytes(), before)
cache.put(DOCUMENTS["alpha"], "j", {"text": "a"}) # the foreign line is carried through a write elsewhere
self.assertIn(b"tests.ki_gamma", path.read_bytes())
def test_a_foreign_key_in_two_stores_refuses_the_pull(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
one, two = Path(tmp) / "one.cc", Path(tmp) / "two.cc"
for path in (one, two):
alpha_beta("gamma", store=SingleFileMessagePackBackingStore(path)).put(DOCUMENTS["gamma"], "k", {"text": "g"})
cache = alpha_beta("alpha")
cache.add_backing_store(SingleFileMessagePackBackingStore(one), [DOCUMENTS["alpha"].type])
cache.add_backing_store(SingleFileMessagePackBackingStore(two), [DOCUMENTS["beta"].type])
with self.assertRaises(Exception) as caught:
cache.pull_all_backing_stores()
self.assertIn("'k'", str(caught.exception))
def test_a_type_that_owns_the_held_schema_id_replaces_the_record_and_the_key_keeps_one_type(self) -> None:
successor = define_database_entry_type(
"tests.ki_alpha2",
[database_entry_field("text", 0, codec=t.string)],
schema_id="sha256:" + "a" * 64,
schema_name="tests.ki_alpha2",
compatible_schema_ids=[IDS["alpha"]],
)
for routed in (False, True):
with self.subTest(routed=routed), tempfile.TemporaryDirectory() as tmp:
one, two = Path(tmp) / "one.cc", Path(tmp) / "two.cc"
made = CultCache.builder().register_document_type(DOCUMENTS["alpha"]).register_document_type(successor)
if routed:
made.add_backing_store(SingleFileMessagePackBackingStore(one), [DOCUMENTS["alpha"].type])
made.add_backing_store(SingleFileMessagePackBackingStore(two), [successor.type])
else:
made.add_generic_store(SingleFileMessagePackBackingStore(one))
cache = made.build()
cache.put(DOCUMENTS["alpha"], "k", {"text": "old"})
if routed:
with self.assertRaises(SchemaConflictError): # one key would live in two stores
cache.put(successor, "k", {"text": "new"})
self.assertEqual(cache.get(DOCUMENTS["alpha"], "k"), {"text": "old"})
else:
cache.put(successor, "k", {"text": "new"})
self.assertIsNone(cache.get(DOCUMENTS["alpha"], "k"))
self.assertEqual(cache.get(successor, "k"), {"text": "new"})
self.assertEqual(file_records(one), [["k", "tests.ki_alpha2"]])

View file

@ -2,6 +2,7 @@
## Unreleased (breaking; version 0.x, so the next minor)
- `apply_raw_document_record` and `apply_raw_snapshot` raise `SchemaConflictError` when a remote record is of another type than the one the cache holds at its key (`cultcache-py` holds one record per key whatever its type); the cache is unchanged, and the caller decides whether to drop the record or the peer. `cultmesh-py`'s server answers such a put with `message_invalid`; its client's `read_next_change` lets the error out.
- `CultNetDatabaseChange` carries `commit_id` and `commit_count` (`commitId`/`commitCount`; None outside a multi-record commit, as C# writes nil): `from_wire` refuses an empty or non-string id and a count that is not an integer of at least 2, `to_wire` writes them when set, and the published `database_change_raw` schema lists both. Python applies no batch. The `simulation_fact` document's `shard`, `frame`, `subject` and `claim_kind` aliases are `roles` (many facts share each); the catalog bytes do not move.
- Decodes the write receipt frames of `contracts/cultnet/interop/write-receipt.md`: `parse_message` validates `cultnet.write_admitted.v0` and `cultnet.error.v1` against their closed schemas (unknown keys, empty or non-string ids, an unknown operation or code are refused; `error.v1` requires `messageId`, string or null; `limit_reached` is `error.v1` only). Added `CULTNET_ERROR_V1_CODES`, `CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS`, `write_content_hash` and `hello_advertises_write_receipts`; `hello` takes `supported_message_versions`. `tests/test_write_receipt.py` replays the vectors' `contentHash`, `wire` and `schemaInvalid` sections. Not implemented here: the client write tracker (`scenarios`) and the server rules (`dedupe`, `blankFields`); this package has no receipt server and no write client.
- Added `message_invalid` to `CULTNET_ERROR_CODES` and `message_invalid_error(field, reason)`, byte-identical to the C# `ForMessageInvalid`. `cultmesh-py` refuses a malformed put or delete with it instead of the out-of-schema `malformed_document_put` and `malformed_document_delete`, which are removed.

View file

@ -14,7 +14,7 @@ from pathlib import Path
from typing import Callable
from uuid import uuid4
import msgpack
from cultcache_py import CultCache, define_database_entry_type
from cultcache_py import CultCache, SchemaConflictError, define_database_entry_type
from cultnet_py.benchmark import run_benchmark
from cultnet_py.compare_csharp import (
DEFAULT_PARITY_THRESHOLD,
@ -1942,6 +1942,35 @@ class CultNetTests(unittest.TestCase):
self.assertEqual(raised.exception.response["messageId"], "subscription-error")
thread.join(2.0)
def test_replication_refuses_a_remote_record_of_another_type_at_a_held_key_and_changes_nothing(self) -> None:
# variants:ruling:cache-refuses-retype: a cache holds one record per key whatever its type, so a peer that reuses a
# key across types is refused where the record is applied; replication does not catch it.
first = define_database_entry_type("replica.first", [("name", 0), ("category", 1), ("value", 2, 0)], cls=Item)
second = define_database_entry_type("replica.second", [("name", 0), ("category", 1), ("value", 2, 0)], cls=Item)
cache = CultCache()
cache.register_document_type(first)
cache.register_document_type(second)
cache.put(first, "shared", Item("held", "", 0))
def raw(document):
return {
"schemaId": document.catalog_entry().schema_id,
"recordKey": "shared",
"storedAt": "2026-06-13T00:00:00Z",
"payloadEncoding": "messagepack",
"payload": document.encode_payload(Item("remote", "", 0)),
}
with self.assertRaises(SchemaConflictError):
apply_raw_document_record(cache, schema_document_map([first, second]), raw(second))
snapshot = {"schemaVersion": "cultnet.snapshot_response_raw.v0", "messageId": "s", "documents": [raw(second)]}
with self.assertRaises(SchemaConflictError):
apply_raw_snapshot(cache, [first, second], snapshot)
self.assertEqual(cache.get_required(first, "shared").name, "held")
self.assertIsNone(cache.get(second, "shared"))
apply_raw_document_record(cache, schema_document_map([first, second]), raw(first)) # the same type still replaces
self.assertEqual(cache.get_required(first, "shared").name, "remote")
def test_cultnet_replication_helpers_apply_raw_snapshot_and_shard_log(self) -> None:
document = define_database_entry_type(
"replica.item",