1591 lines
85 KiB
Python
1591 lines
85 KiB
Python
from __future__ import annotations
|
||
|
||
import os
|
||
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,
|
||
StoreUnreadableError,
|
||
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_refuses_a_record_whose_schema_the_catalog_does_not_publish(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()
|
||
)
|
||
|
||
# The payload opens with a schema version, and the catalog is the store's own description: no recovery.
|
||
with self.assertRaises(StoreUnreadableError):
|
||
cache.pull_all_backing_stores()
|
||
|
||
def _foreign_id_document(self):
|
||
return define_database_entry_type(
|
||
"tests.foreign-id",
|
||
[("name", 0)],
|
||
schema_id="tests.foreign-id.current",
|
||
schema_name="tests.foreign-id",
|
||
schema_version="tests.foreign_id.v1",
|
||
compatible_schema_ids=["tests.foreign-id.current", "tests.foreign-id.older"],
|
||
)
|
||
|
||
def _open_foreign_id_store(self, document, path: Path) -> CultCache:
|
||
cache = CultCache.builder().register_document_type(document).add_generic_store(
|
||
SingleFileMessagePackBackingStore(path)
|
||
).build()
|
||
cache.pull_all_backing_stores()
|
||
return cache
|
||
|
||
# A record put under an id its schema only lists as compatible is stamped with the registered id, and the store the write
|
||
# leaves opens and takes the next write.
|
||
def test_put_envelope_under_a_compatible_schema_id_writes_a_store_that_reopens(self) -> None:
|
||
import msgpack # type: ignore
|
||
from cultcache_py import CultCacheSchemaCatalogEntry
|
||
|
||
document = self._foreign_id_document()
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
path = Path(tmp) / "store.msgpack"
|
||
cache = self._open_foreign_id_store(document, path)
|
||
payload = document.encode_payload({"name": "foreign"})
|
||
foreign_entry = CultCacheSchemaCatalogEntry(
|
||
schema_id="tests.foreign-id.older",
|
||
schema_name="tests.foreign-id",
|
||
schema_version="tests.foreign_id.v1",
|
||
content_hash="tests.foreign-id.older",
|
||
canonical_schema_json="",
|
||
compatible_schema_ids=("tests.foreign-id.older",),
|
||
members=(),
|
||
)
|
||
cache.put_envelope(
|
||
document,
|
||
CultCacheEnvelope(
|
||
key="foreign",
|
||
type="tests.foreign-id",
|
||
payload=payload,
|
||
stored_at="2026-09-30T00:00:00Z",
|
||
schema_id="tests.foreign-id.older",
|
||
catalog_entry=foreign_entry,
|
||
),
|
||
)
|
||
stored = msgpack.unpackb(path.read_bytes(), raw=False)
|
||
self.assertEqual([entry[0] for entry in stored[1]], ["tests.foreign-id.current"])
|
||
self.assertEqual(stored[2][0][1], "tests.foreign-id.current")
|
||
reopened = self._open_foreign_id_store(document, path)
|
||
self.assertEqual(reopened.get_required(document, "foreign")["name"], "foreign")
|
||
reopened.put(document, "next", {"name": "next"})
|
||
self.assertEqual(self._open_foreign_id_store(document, path).get_required(document, "next")["name"], "next")
|
||
|
||
def test_the_store_publishes_a_records_schema_id_even_when_the_envelopes_catalog_entry_does_not(self) -> None:
|
||
import msgpack # type: ignore
|
||
from cultcache_py import CultCacheSchemaCatalogEntry
|
||
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
path = Path(tmp) / "store.msgpack"
|
||
store = SingleFileMessagePackBackingStore(path)
|
||
store.push(CultCacheEnvelope(
|
||
key="k",
|
||
type="tests.store-publishes",
|
||
payload=msgpack.packb({"name": "k"}, use_bin_type=True),
|
||
stored_at="2026-09-30T00:00:00Z",
|
||
schema_id="tests.store-publishes.record",
|
||
catalog_entry=CultCacheSchemaCatalogEntry(
|
||
schema_id="tests.store-publishes.other",
|
||
schema_name="tests.store-publishes",
|
||
schema_version="tests.store_publishes.v1",
|
||
content_hash="tests.store-publishes.other",
|
||
canonical_schema_json="",
|
||
compatible_schema_ids=("tests.store-publishes.other",),
|
||
members=(),
|
||
),
|
||
))
|
||
stored = msgpack.unpackb(path.read_bytes(), raw=False)
|
||
self.assertEqual(stored[2][0][1], "tests.store-publishes.record")
|
||
self.assertEqual([entry[0] for entry in stored[1]], ["tests.store-publishes.record"])
|
||
self.assertEqual(len(SingleFileMessagePackBackingStore(path).pull_all()), 1)
|
||
|
||
# The catalog a write leaves is derived from its records: one entry per carried id, chosen and never merged, the same whatever
|
||
# order the records arrive in. A registered (supplied) entry wins over an arrived (read back) entry with the same own id.
|
||
@staticmethod
|
||
def _writer_entry(schema_id: str, name: str, content_hash: str, compatible: tuple[str, ...]):
|
||
from cultcache_py import CultCacheSchemaCatalogEntry
|
||
|
||
return CultCacheSchemaCatalogEntry(
|
||
schema_id=schema_id, schema_name=name, schema_version=name + ".v1", content_hash=content_hash,
|
||
canonical_schema_json="", compatible_schema_ids=compatible, members=(),
|
||
)
|
||
|
||
def _writer_record(self, key: str, type: str, schema_id: str, catalog_entry=None) -> CultCacheEnvelope:
|
||
import msgpack # type: ignore
|
||
|
||
return CultCacheEnvelope(
|
||
key=key, type=type, payload=msgpack.packb({"key": key}, use_bin_type=True),
|
||
stored_at="2026-09-30T00:00:00Z", schema_id=schema_id, catalog_entry=catalog_entry,
|
||
)
|
||
|
||
@staticmethod
|
||
def _raw_entry(entry) -> list:
|
||
return [entry.schema_id, entry.schema_name, entry.schema_version, entry.content_hash, "", list(entry.compatible_schema_ids), []]
|
||
|
||
def test_a_registered_entry_is_written_as_it_is_over_the_arrived_entries_with_the_same_own_id_in_either_order(self) -> None:
|
||
import msgpack # type: ignore
|
||
|
||
stale1 = self._writer_entry("tests.x", "tests.n", "a-stale-1", ("tests.x", "old"))
|
||
stale2 = self._writer_entry("tests.x", "tests.n", "a-stale-2", ("tests.x",))
|
||
registered = self._writer_record("a", "tests.n", "tests.x", self._writer_entry("tests.x", "tests.n", "fresh", ("tests.x",)))
|
||
for entries in ([stale1, stale2], [stale2, stale1]):
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
path = Path(tmp) / "store.msgpack"
|
||
path.write_bytes(msgpack.packb([
|
||
"cultcache.store.v1", [self._raw_entry(entry) for entry in entries],
|
||
[["b", "tests.x", "2026-09-30T00:00:00Z", msgpack.packb({"b": 1}, use_bin_type=True)]],
|
||
], use_bin_type=True))
|
||
SingleFileMessagePackBackingStore(path).push(registered)
|
||
catalog = msgpack.unpackb(path.read_bytes(), raw=False)[1]
|
||
self.assertEqual(len(catalog), 1)
|
||
self.assertEqual(catalog[0][3], "fresh")
|
||
self.assertEqual(catalog[0][5], ["tests.x"], "written as chosen, no union with the arrived entries' ids")
|
||
|
||
def test_a_rename_with_a_stable_schema_id_a_whole_view_write_names_the_registered_schema(self) -> None:
|
||
import msgpack # type: ignore
|
||
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
path = Path(tmp) / "store.msgpack"
|
||
path.write_bytes(msgpack.packb([
|
||
"cultcache.store.v1", [self._raw_entry(self._writer_entry("tests.x", "tests.old", "stale", ("tests.x",)))],
|
||
[["a", "tests.x", "2026-09-30T00:00:00Z", msgpack.packb({"a": 1}, use_bin_type=True)]],
|
||
], use_bin_type=True))
|
||
SingleFileMessagePackBackingStore(path).push_all(
|
||
[self._writer_record("a", "tests.old", "tests.x", self._writer_entry("tests.x", "tests.new", "fresh", ("tests.x",)))]
|
||
)
|
||
catalog = msgpack.unpackb(path.read_bytes(), raw=False)[1]
|
||
self.assertEqual([(entry[1], entry[3]) for entry in catalog], [("tests.new", "fresh")])
|
||
|
||
def test_two_entries_of_one_tier_that_share_an_own_id_and_disagree_on_the_schema_name_refuse_the_write_typed(self) -> None:
|
||
from cultcache_py import SchemaConflictError
|
||
|
||
first = self._writer_record("b", "tests.first", "tests.x", self._writer_entry("tests.x", "tests.first", "h1", ("tests.x",)))
|
||
second = self._writer_record("a", "tests.first", "tests.x", self._writer_entry("tests.x", "tests.second", "h2", ("tests.x",)))
|
||
for order in ([first, second], [second, first]):
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
path = Path(tmp) / "store.msgpack"
|
||
with self.assertRaises(SchemaConflictError) as refused:
|
||
SingleFileMessagePackBackingStore(path).push_all(order)
|
||
self.assertEqual(refused.exception.schema_id, "tests.x")
|
||
self.assertEqual(refused.exception.record_key, "a")
|
||
self.assertEqual(refused.exception.schema_names, ["tests.first", "tests.second"])
|
||
self.assertFalse(path.exists())
|
||
|
||
def test_records_of_different_types_under_one_schema_id_refuse_the_write_typed_and_no_record_changes_type(self) -> None:
|
||
from cultcache_py import SchemaConflictError
|
||
|
||
a = self._writer_record("a", "tests.n", "tests.y", self._writer_entry("tests.x", "tests.n", "h1", ("tests.x", "tests.y")))
|
||
b = self._writer_record("b", "tests.m", "tests.y", self._writer_entry("tests.y", "tests.m", "h2", ("tests.y",)))
|
||
for order in ([a, b], [b, a]):
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
with self.assertRaises(SchemaConflictError) as refused:
|
||
SingleFileMessagePackBackingStore(Path(tmp) / "store.msgpack").push_all(order)
|
||
self.assertEqual(refused.exception.schema_id, "tests.y")
|
||
self.assertEqual(refused.exception.schema_names, ["tests.m", "tests.n"])
|
||
|
||
def test_an_entry_that_owns_an_id_is_chosen_over_one_that_lists_it(self) -> None:
|
||
import msgpack # type: ignore
|
||
|
||
owner = self._writer_record("a", "tests.n", "tests.y", self._writer_entry("tests.y", "tests.n", "h1", ("tests.y",)))
|
||
lister = self._writer_record("b", "tests.n", "tests.x", self._writer_entry("tests.x", "tests.n", "h2", ("tests.x", "tests.y")))
|
||
for order in ([lister, owner], [owner, lister]):
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
path = Path(tmp) / "store.msgpack"
|
||
SingleFileMessagePackBackingStore(path).push_all(order)
|
||
catalog = msgpack.unpackb(path.read_bytes(), raw=False)[1]
|
||
self.assertEqual(sorted((entry[0], entry[5]) for entry in catalog), [("tests.x", ["tests.x", "tests.y"]), ("tests.y", ["tests.y"])])
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
path = Path(tmp) / "store.msgpack"
|
||
SingleFileMessagePackBackingStore(path).push_all([owner])
|
||
self.assertEqual([entry[0] for entry in msgpack.unpackb(path.read_bytes(), raw=False)[1]], ["tests.y"])
|
||
|
||
def test_entries_of_one_tier_that_tie_are_taken_in_one_fixed_order_and_a_registered_lister_beats_an_arrived_one(self) -> None:
|
||
import msgpack # type: ignore
|
||
|
||
# The content hash orders them against the order their compatible ids would give: h1 sorts first, its list sorts last.
|
||
one = self._writer_record("a", "tests.n", "tests.x", self._writer_entry("tests.x", "tests.n", "h2", ("tests.x",)))
|
||
two = self._writer_record("b", "tests.n", "tests.x", self._writer_entry("tests.x", "tests.n", "h1", ("tests.x", "tests.y")))
|
||
for order in ([one, two], [two, one]):
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
path = Path(tmp) / "store.msgpack"
|
||
SingleFileMessagePackBackingStore(path).push_all(order)
|
||
self.assertEqual([entry[3] for entry in msgpack.unpackb(path.read_bytes(), raw=False)[1]], ["h1"])
|
||
|
||
# z sits under old, which the arrived entry (id x) lists; the supplied entry (id r) lists it too: the registered one publishes it.
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
path = Path(tmp) / "store.msgpack"
|
||
path.write_bytes(msgpack.packb([
|
||
"cultcache.store.v1", [self._raw_entry(self._writer_entry("tests.x", "tests.n", "stale", ("tests.x", "old")))],
|
||
[["z", "old", "2026-09-30T00:00:00Z", msgpack.packb({"z": 1}, use_bin_type=True)]],
|
||
], use_bin_type=True))
|
||
SingleFileMessagePackBackingStore(path).push(
|
||
self._writer_record("a", "tests.n", "tests.r", self._writer_entry("tests.r", "tests.n", "fresh", ("tests.r", "old")))
|
||
)
|
||
self.assertEqual([entry[0] for entry in msgpack.unpackb(path.read_bytes(), raw=False)[1]], ["tests.r"])
|
||
|
||
# A registered entry that owns an id keeps it against an arrived entry that owns it too and lists the id another record sits
|
||
# under, whichever of the two ids sorts first: the arrived entry's list is not merged in, so the record under the listed id is
|
||
# published by no chosen entry.
|
||
def test_an_arrived_entry_that_owns_a_registered_id_does_not_replace_it_nor_publish_what_it_lists(self) -> None:
|
||
import msgpack # type: ignore
|
||
from cultcache_py import SchemaConflictError
|
||
|
||
for own_id, listed_id in (("tests.a", "tests.b"), ("tests.b", "tests.a")):
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
path = Path(tmp) / "store.msgpack"
|
||
path.write_bytes(msgpack.packb([
|
||
"cultcache.store.v1", [self._raw_entry(self._writer_entry(own_id, "tests.arrived", "stale", (own_id, listed_id)))],
|
||
[["k2", listed_id, "2026-09-30T00:00:00Z", msgpack.packb({"k": 2}, use_bin_type=True)]],
|
||
], use_bin_type=True))
|
||
before = path.read_bytes()
|
||
with self.assertRaises(SchemaConflictError) as refused:
|
||
SingleFileMessagePackBackingStore(path).push(
|
||
self._writer_record("k1", "tests.registered", own_id, self._writer_entry(own_id, "tests.registered", "fresh", (own_id,)))
|
||
)
|
||
self.assertEqual(refused.exception.schema_id, listed_id)
|
||
self.assertEqual(refused.exception.record_key, "k2")
|
||
self.assertEqual(path.read_bytes(), before, "the file is left as it was")
|
||
|
||
# Ids are taken in sorted order, so which entry survives, and so which id the refusal names, does not depend on the order the
|
||
# records arrive in.
|
||
def test_the_refusal_names_the_same_id_whatever_order_the_records_arrive_in(self) -> None:
|
||
from cultcache_py import SchemaConflictError
|
||
|
||
one = self._writer_record("r1", "tests.n", "tests.y", self._writer_entry("tests.x", "tests.n", "h1", ("tests.x", "tests.y")))
|
||
two = self._writer_record("r2", "tests.n", "tests.z", self._writer_entry("tests.x", "tests.n", "h2", ("tests.x", "tests.z")))
|
||
for order in ([one, two], [two, one]):
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
with self.assertRaises(SchemaConflictError) as refused:
|
||
SingleFileMessagePackBackingStore(Path(tmp) / "store.msgpack").push_all(order)
|
||
self.assertEqual(refused.exception.schema_id, "tests.y")
|
||
self.assertEqual(refused.exception.record_key, "r1")
|
||
|
||
# A write may not retype records already in the file: across tiers the id keeps the type its records resolve to, unless the
|
||
# entries of both tiers own it, which is a rename under a stable id and the registered descriptor wins.
|
||
def test_a_write_that_would_change_the_type_an_existing_record_resolves_to_refuses_typed_and_leaves_the_file(self) -> None:
|
||
import msgpack # type: ignore
|
||
from cultcache_py import SchemaConflictError
|
||
|
||
cases = [
|
||
("registered entry lists the id, arrived entry owns it",
|
||
self._writer_entry("tests.x", "tests.arrived", "h1", ("tests.x",)),
|
||
self._writer_record("a", "tests.registered", "tests.x", self._writer_entry("tests.r", "tests.registered", "h2", ("tests.r", "tests.x")))),
|
||
("registered entry owns the id, arrived entry lists it",
|
||
self._writer_entry("tests.r", "tests.arrived", "h1", ("tests.r", "tests.x")),
|
||
self._writer_record("a", "tests.registered", "tests.x", self._writer_entry("tests.x", "tests.registered", "h2", ("tests.x",)))),
|
||
]
|
||
payload = msgpack.packb({"z": 1}, use_bin_type=True)
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
for name, catalog, registered in cases:
|
||
path = Path(tmp) / (name.split(",")[0].replace(" ", "-") + ".msgpack")
|
||
path.write_bytes(msgpack.packb(
|
||
["cultcache.store.v1", [self._raw_entry(catalog)], [["z", "tests.x", "2026-09-30T00:00:00Z", payload]]], use_bin_type=True))
|
||
before = path.read_bytes()
|
||
with self.assertRaises(SchemaConflictError, msg=name) as refused:
|
||
SingleFileMessagePackBackingStore(path).push(registered)
|
||
self.assertEqual(refused.exception.schema_id, "tests.x", name)
|
||
self.assertEqual(sorted(refused.exception.schema_names), ["tests.arrived", "tests.registered"], name)
|
||
self.assertEqual(path.read_bytes(), before, name)
|
||
|
||
# The same registered record renames the schema when both tiers own the id: it writes, and the reader finds every record.
|
||
path = Path(tmp) / "rename.msgpack"
|
||
path.write_bytes(msgpack.packb([
|
||
"cultcache.store.v1", [self._raw_entry(self._writer_entry("tests.x", "tests.arrived", "h1", ("tests.x",)))],
|
||
[["z", "tests.x", "2026-09-30T00:00:00Z", payload]],
|
||
], use_bin_type=True))
|
||
SingleFileMessagePackBackingStore(path).push(
|
||
self._writer_record("a", "tests.registered", "tests.x", self._writer_entry("tests.x", "tests.registered", "h2", ("tests.x",)))
|
||
)
|
||
self.assertEqual([(entry[0], entry[1]) for entry in msgpack.unpackb(path.read_bytes(), raw=False)[1]], [("tests.x", "tests.registered")])
|
||
self.assertEqual(len(SingleFileMessagePackBackingStore(path).pull_all()), 2)
|
||
|
||
def test_a_record_no_chosen_entry_publishes_refuses_the_write_typed_and_leaves_the_file(self) -> None:
|
||
import msgpack # type: ignore
|
||
from cultcache_py import SchemaConflictError
|
||
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
path = Path(tmp) / "store.msgpack"
|
||
path.write_bytes(msgpack.packb([
|
||
"cultcache.store.v1", [self._raw_entry(self._writer_entry("tests.x", "tests.legacy", "stale", ("tests.x", "old")))],
|
||
[["z", "old", "2026-09-30T00:00:00Z", msgpack.packb({"z": 1}, use_bin_type=True)]],
|
||
], use_bin_type=True))
|
||
before = path.read_bytes()
|
||
with self.assertRaises(SchemaConflictError) as refused:
|
||
SingleFileMessagePackBackingStore(path).push(
|
||
self._writer_record("a", "tests.legacy", "tests.x", self._writer_entry("tests.x", "tests.legacy", "fresh", ("tests.x",)))
|
||
)
|
||
self.assertEqual(refused.exception.schema_id, "old")
|
||
self.assertEqual(refused.exception.record_key, "z")
|
||
self.assertEqual(path.read_bytes(), before)
|
||
|
||
def test_an_id_an_entry_owns_names_that_entry_not_one_that_lists_it_as_compatible(self) -> None:
|
||
for vector in ("own-id-over-compatible-v3.bin", "compatible-before-owner-v3.bin"):
|
||
store = SingleFileMessagePackBackingStore(self._C2A_VECTORS / "readability" / vector)
|
||
self.assertEqual([envelope.type for envelope in store.pull_all()], ["vectors.item", "vectors.item"], vector)
|
||
|
||
def test_a_record_loaded_under_a_compatible_schema_id_is_written_back_under_the_id_its_catalog_entry_carries(self) -> None:
|
||
import msgpack # type: ignore
|
||
|
||
document = self._foreign_id_document()
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
path = Path(tmp) / "store.msgpack"
|
||
path.write_bytes(msgpack.packb([
|
||
"cultcache.store.v1",
|
||
[["tests.foreign-id.current", "tests.foreign-id", "tests.foreign_id.v1", "tests.foreign-id.current", "",
|
||
["tests.foreign-id.current", "tests.foreign-id.older"], []]],
|
||
[["old", "tests.foreign-id.older", "2026-09-30T00:00:00Z", document.encode_payload({"name": "old"})]],
|
||
], use_bin_type=True))
|
||
cache = self._open_foreign_id_store(document, path)
|
||
self.assertEqual(cache.get_required_envelope(document, "old").schema_id, "tests.foreign-id.current")
|
||
cache.put(document, "next", {"name": "next"})
|
||
reopened = self._open_foreign_id_store(document, path)
|
||
self.assertEqual(reopened.get_required(document, "old")["name"], "old")
|
||
self.assertEqual(reopened.get_required(document, "next")["name"], "next")
|
||
|
||
# The cache restamps a record on load, so a store's writer never has to: a record loaded under an id the registered document
|
||
# only lists is held, and written back by a whole write, under the id its catalog entry carries. A document that does not list
|
||
# the id a record sits under cannot have that record published by a write that leaves it in place: the put is refused, typed.
|
||
def test_a_cache_that_does_not_list_the_id_a_loaded_record_sits_under_is_refused_a_put_and_the_file_is_left(self) -> None:
|
||
import msgpack # type: ignore
|
||
from cultcache_py import SchemaConflictError
|
||
|
||
document = define_database_entry_type(
|
||
"tests.foreign-id", [("name", 0)], schema_id="tests.foreign-id.current",
|
||
schema_name="tests.foreign-id", schema_version="tests.foreign_id.v1",
|
||
)
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
path = Path(tmp) / "store.msgpack"
|
||
path.write_bytes(msgpack.packb([
|
||
"cultcache.store.v1",
|
||
[["tests.foreign-id.current", "tests.foreign-id", "tests.foreign_id.v1", "tests.foreign-id.current", "",
|
||
["tests.foreign-id.current", "tests.foreign-id.older"], []]],
|
||
[["old", "tests.foreign-id.older", "2026-09-30T00:00:00Z", document.encode_payload({"name": "old"})]],
|
||
], use_bin_type=True))
|
||
before = path.read_bytes()
|
||
cache = self._open_foreign_id_store(document, path)
|
||
self.assertEqual(cache.get_required_envelope(document, "old").schema_id, "tests.foreign-id.current")
|
||
with self.assertRaises(SchemaConflictError) as refused:
|
||
cache.put(document, "next", {"name": "next"})
|
||
self.assertEqual(refused.exception.schema_id, "tests.foreign-id.older")
|
||
self.assertEqual(refused.exception.record_key, "old")
|
||
self.assertEqual(path.read_bytes(), before)
|
||
|
||
# A record resolves to a local document by its schema id, never by the name its catalog entry carries: a schema renamed under
|
||
# a stable id opens, and an id that names one document is not overruled by a name that names another.
|
||
def _store_of(self, path: Path, catalog: list, records: list) -> None:
|
||
import msgpack # type: ignore
|
||
|
||
path.write_bytes(msgpack.packb(["cultcache.store.v1", catalog, records], use_bin_type=True))
|
||
|
||
def test_a_schema_renamed_under_a_stable_id_opens_by_its_id(self) -> None:
|
||
document = define_database_entry_type(
|
||
"tests.old", [("name", 0)], schema_id="tests.stable", schema_name="tests.old", schema_version="tests.old.v1",
|
||
)
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
path = Path(tmp) / "store.msgpack"
|
||
self._store_of(
|
||
path,
|
||
[["tests.stable", "tests.renamed", "tests.renamed.v1", "renamed", "", ["tests.stable"], []]],
|
||
[["a", "tests.stable", "2026-09-30T00:00:00Z", document.encode_payload({"name": "a"})]],
|
||
)
|
||
cache = self._open_foreign_id_store(document, path)
|
||
self.assertEqual(cache.get_required(document, "a")["name"], "a")
|
||
|
||
def test_a_schema_id_that_names_one_document_is_not_overruled_by_a_name_that_names_another(self) -> None:
|
||
first = define_database_entry_type("tests.a", [("name", 0)], schema_id="id.a", schema_name="tests.a", schema_version="tests.a.v1")
|
||
second = define_database_entry_type(
|
||
"tests.b", [("name", 0)], schema_id="id.b", schema_name="tests.b", schema_version="tests.b.v1", compatible_schema_ids=["id.b", "id.b.older"],
|
||
)
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
for schema_id in ("id.b", "id.b.older"):
|
||
path = Path(tmp) / (schema_id + ".msgpack")
|
||
# The entry that publishes the id carries the other document's name.
|
||
self._store_of(
|
||
path,
|
||
[["id.b", "tests.a", "tests.b.v1", "h", "", ["id.b", "id.b.older"], []]],
|
||
[["k", schema_id, "2026-09-30T00:00:00Z", second.encode_payload({"name": "k"})]],
|
||
)
|
||
cache = CultCache.builder().register_document_type(first).register_document_type(second).add_generic_store(
|
||
SingleFileMessagePackBackingStore(path)
|
||
).build()
|
||
cache.pull_all_backing_stores()
|
||
self.assertEqual(cache.get_required(second, "k")["name"], "k", schema_id)
|
||
self.assertIsNone(cache.get(first, "k"), schema_id)
|
||
|
||
def _assert_second_claim_refused(self, first, second, schema_id: str) -> None:
|
||
"""A registration refusal is typed, names the id and both schema names and document types, and the cache keeps what it
|
||
had."""
|
||
from cultcache_py import SchemaConflictError
|
||
|
||
cache = CultCache()
|
||
cache.register_document_type(first)
|
||
with self.assertRaises(SchemaConflictError) as refused:
|
||
cache.register_document_type(second)
|
||
self.assertEqual(refused.exception.schema_id, schema_id)
|
||
self.assertEqual(refused.exception.schema_names, [first.catalog_entry().schema_name, second.catalog_entry().schema_name])
|
||
self.assertIn(first.type, str(refused.exception))
|
||
self.assertIn(second.type, str(refused.exception))
|
||
if second is not first:
|
||
with self.assertRaisesRegex(CultCacheError, "not registered"):
|
||
cache.put(second, "k", {"name": "k"})
|
||
|
||
def _pull(self, documents: list, catalog: list, records: list) -> CultCache:
|
||
tmp = tempfile.mkdtemp()
|
||
path = Path(tmp) / "store.msgpack"
|
||
self._store_of(path, catalog, records)
|
||
builder = CultCache.builder()
|
||
for document in documents:
|
||
builder = builder.register_document_type(document)
|
||
cache = builder.add_generic_store(SingleFileMessagePackBackingStore(path)).build()
|
||
cache.pull_all_backing_stores()
|
||
return cache
|
||
|
||
def test_a_document_listing_an_id_another_owns_registers_beside_it_in_either_order_and_the_owner_wins(self) -> None:
|
||
owner = define_database_entry_type("tests.owner", [("name", 0)], schema_id="id.owned", schema_name="tests.owner", schema_version="tests.owner.v1")
|
||
lister = define_database_entry_type(
|
||
"tests.lister", [("name", 0)], schema_id="id.lister", schema_name="tests.lister", schema_version="tests.lister.v1",
|
||
compatible_schema_ids=["id.lister", "id.owned"],
|
||
)
|
||
catalog = [
|
||
["id.owned", "tests.owner", "tests.owner.v1", "h1", "", ["id.owned"], []],
|
||
["id.lister", "tests.lister", "tests.lister.v1", "h2", "", ["id.lister", "id.owned"], []],
|
||
]
|
||
records = [
|
||
["o", "id.owned", "2026-09-30T00:00:00Z", owner.encode_payload({"name": "o"})],
|
||
["l", "id.lister", "2026-09-30T00:00:00Z", lister.encode_payload({"name": "l"})],
|
||
]
|
||
for order in ([owner, lister], [lister, owner]):
|
||
cache = self._pull(order, catalog, records)
|
||
self.assertEqual(cache.get_required(owner, "o")["name"], "o")
|
||
self.assertIsNone(cache.get(lister, "o"))
|
||
self.assertEqual(cache.get_required(lister, "l")["name"], "l")
|
||
self.assertEqual(self._pull([lister], catalog, records[:1]).get_required(lister, "o")["name"], "o")
|
||
|
||
def test_two_documents_listing_one_id_register_and_a_record_under_it_needs_an_owner(self) -> None:
|
||
from cultcache_py import SchemaConflictError
|
||
|
||
a = define_database_entry_type(
|
||
"tests.list-a", [("name", 0)], schema_id="id.a", schema_name="tests.list-a", compatible_schema_ids=["id.a", "id.shared"],
|
||
)
|
||
b = define_database_entry_type(
|
||
"tests.list-b", [("name", 0)], schema_id="id.b", schema_name="tests.list-b", compatible_schema_ids=["id.b", "id.shared"],
|
||
)
|
||
owner = define_database_entry_type("tests.shared", [("name", 0)], schema_id="id.shared", schema_name="tests.shared")
|
||
catalog = [["id.shared", "tests.shared", "tests.shared.v1", "h", "", ["id.shared"], []]]
|
||
records = [["k", "id.shared", "2026-09-30T00:00:00Z", owner.encode_payload({"name": "k"})]]
|
||
for listers in ([a, b], [b, a]):
|
||
with self.assertRaises(SchemaConflictError) as refused:
|
||
self._pull(listers, catalog, records)
|
||
self.assertEqual(refused.exception.schema_id, "id.shared")
|
||
self.assertEqual(refused.exception.record_key, "k")
|
||
self.assertEqual(refused.exception.schema_names, ["tests.list-a", "tests.list-b"])
|
||
for documents in ([*listers, owner], [owner, *listers]):
|
||
cache = self._pull(documents, catalog, records)
|
||
self.assertEqual(cache.get_required(owner, "k")["name"], "k")
|
||
|
||
def test_two_documents_owning_one_schema_id_are_refused_in_either_order(self) -> None:
|
||
# Each declares an older id and not its own, so the id they share is only ever their own.
|
||
a = define_database_entry_type("tests.own-a", [("name", 0)], schema_id="id.same", schema_name="tests.own-a", compatible_schema_ids=["id.a-older"])
|
||
b = define_database_entry_type("tests.own-b", [("name", 0)], schema_id="id.same", schema_name="tests.own-b", compatible_schema_ids=["id.b-older"])
|
||
self._assert_second_claim_refused(a, b, "id.same")
|
||
self._assert_second_claim_refused(b, a, "id.same")
|
||
|
||
def test_every_registration_refusal_is_a_schema_conflict(self) -> None:
|
||
first = define_database_entry_type("tests.named", [("name", 0)], schema_id="id.first", schema_name="tests.named")
|
||
same_name = define_database_entry_type("tests.renamed", [("name", 0)], schema_id="id.second", schema_name="tests.named")
|
||
self._assert_second_claim_refused(first, same_name, "id.second")
|
||
self._assert_second_claim_refused(first, first, "id.first")
|
||
|
||
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")
|
||
|
||
|
||
# Shared refusal vectors: tests/vectors/document-variants-c0, read by every runtime's tests.
|
||
_VECTORS = Path(__file__).resolve().parents[3] / "tests" / "vectors" / "document-variants-c0"
|
||
_ITEM_SCHEMA_ID = "sha256:88d3fdf0a927acf3b163940d8f8c7fe62b3316542ce771a67ec8bc038f594788"
|
||
|
||
def _pull_vector(self, name: str) -> list[CultCacheEnvelope]:
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
store_path = Path(tmp) / "store.msgpack"
|
||
store_path.write_bytes((self._VECTORS / name).read_bytes())
|
||
return SingleFileMessagePackBackingStore(store_path).pull_all()
|
||
|
||
def test_single_file_refuses_unknown_header_by_name(self) -> None:
|
||
with self.assertRaises(ValueError) as caught:
|
||
self._pull_vector("unknown-header.msgpack")
|
||
self.assertIn("cultcache.store.v9", str(caught.exception))
|
||
|
||
def test_single_file_refuses_extra_record_slot_naming_the_record(self) -> None:
|
||
with self.assertRaises(ValueError) as caught:
|
||
self._pull_vector("extra-slot-full-payload.msgpack")
|
||
self.assertIn("item:anvil", str(caught.exception))
|
||
self.assertIn(self._ITEM_SCHEMA_ID, str(caught.exception))
|
||
|
||
def test_single_file_refuses_variant_store_by_version_or_record(self) -> None:
|
||
with self.assertRaises(ValueError) as caught:
|
||
self._pull_vector("variant-v2.msgpack")
|
||
message = str(caught.exception)
|
||
self.assertTrue("cultcache.store.v2" in message or "item:anvil-big" in message, message)
|
||
|
||
def test_single_file_refuses_the_csharp_written_variant_store_by_version_or_record(self) -> None:
|
||
with self.assertRaises(ValueError) as caught:
|
||
self._pull_vector("../document-variants-c1/variant-store.msgpack")
|
||
message = str(caught.exception)
|
||
self.assertTrue("cultcache.store.v2" in message or "laser-big" in message, message)
|
||
|
||
def test_v1_store_written_at_the_base_commit_still_reads_byte_for_byte(self) -> None:
|
||
envelopes = self._pull_vector("v1-base.msgpack")
|
||
self.assertEqual([(e.key, e.type) for e in envelopes], [("alpha", "vectors.item"), ("beta", "vectors.item")])
|
||
self.assertEqual(envelopes[0].payload, b"\x92\xa5alpha\x01")
|
||
self.assertEqual(envelopes[1].payload, b"\x92\xa4beta\x02")
|
||
|
||
def test_single_file_refusals_say_what_was_found_without_blaming_variants(self) -> None:
|
||
with self.assertRaises(ValueError) as unsupported:
|
||
self._pull_vector("unknown-header.msgpack")
|
||
self.assertIn("not one this runtime reads", str(unsupported.exception))
|
||
self.assertNotIn("variant", str(unsupported.exception))
|
||
with self.assertRaises(ValueError) as invalid:
|
||
self._pull_vector("extra-slot-full-payload.msgpack")
|
||
self.assertIn("not a valid store", str(invalid.exception))
|
||
self.assertNotIn("variant", str(invalid.exception))
|
||
|
||
def test_single_file_only_a_missing_store_reads_as_empty(self) -> None:
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
self.assertEqual(SingleFileMessagePackBackingStore(Path(tmp) / "store.cc").pull_all(), [])
|
||
|
||
# An existing empty file is not a store: no CultCache writer leaves one.
|
||
empty = Path(tmp) / "empty.cc"
|
||
empty.write_bytes(b"")
|
||
with self.assertRaises(ValueError):
|
||
SingleFileMessagePackBackingStore(empty).pull_all()
|
||
self.assertEqual(empty.read_bytes(), b"")
|
||
|
||
# An empty array is the legacy envelope array with no envelopes: no header, no format claimed.
|
||
legacy_empty = Path(tmp) / "legacy-empty.cc"
|
||
legacy_empty.write_bytes(b"\x90")
|
||
self.assertEqual(SingleFileMessagePackBackingStore(legacy_empty).pull_all(), [])
|
||
|
||
def test_single_file_refusals_never_echo_a_value_from_the_store(self) -> None:
|
||
import msgpack # type: ignore
|
||
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
path = Path(tmp) / "store.cc"
|
||
cases = [
|
||
(["cultcache.store.v12", [], []], True),
|
||
(["cultcache.store.SECRET-HEADER", [], []], False),
|
||
(["cultcache.store.v12SECRET", [], []], False),
|
||
(["cultcache.store.v", [], []], False),
|
||
(["cultcache.store.v1", ["SECRET-CATALOG-TEXT"], []], None),
|
||
]
|
||
for value, echoed in cases:
|
||
path.write_bytes(msgpack.packb(value, use_bin_type=True))
|
||
with self.assertRaises(ValueError) as caught:
|
||
SingleFileMessagePackBackingStore(path).pull_all()
|
||
message = str(caught.exception)
|
||
if echoed:
|
||
self.assertIn(repr(value[0]), message)
|
||
else:
|
||
self.assertNotIn("SECRET", message)
|
||
if echoed is False:
|
||
self.assertNotIn(repr(value[0]), message)
|
||
self.assertIn(f"of {len(value[0])} bytes", message)
|
||
|
||
def test_single_file_refusals_say_exactly_what_was_found_and_what_is_read(self) -> None:
|
||
import msgpack # type: ignore
|
||
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
path = Path(tmp) / "store.cc"
|
||
reads = "it reads 'cultcache.store.v1' and 'cultcache.store.v3' only"
|
||
for header, found in (
|
||
("cultcache.store.v12", "'cultcache.store.v12'"),
|
||
("cultcache.store.SECRET", "an unrecognised cultcache.store.* header of 22 bytes"),
|
||
("cultcache.store.v", "an unrecognised cultcache.store.* header of 17 bytes"),
|
||
# Digits are ASCII only: two Arabic-Indic digits are four bytes and are not the known shape.
|
||
("cultcache.store.v١٢", "an unrecognised cultcache.store.* header of 21 bytes"),
|
||
):
|
||
path.write_bytes(msgpack.packb([header, [], []], use_bin_type=True))
|
||
with self.assertRaises(ValueError) as caught:
|
||
SingleFileMessagePackBackingStore(path).pull_all()
|
||
self.assertIn(f"CultCache store format {found} is not one this runtime reads; {reads}", str(caught.exception))
|
||
|
||
path.write_bytes(msgpack.packb(["cultcache.store.v1", [], [["k", "s", "t", b"\x90", "extra"]]], use_bin_type=True))
|
||
with self.assertRaises(ValueError) as caught:
|
||
SingleFileMessagePackBackingStore(path).pull_all()
|
||
self.assertIn(
|
||
"CultCache record 'k' (schema 's') has 5 slots, more than the 4 of a cultcache.store.v1 record, so this is not a valid store",
|
||
str(caught.exception),
|
||
)
|
||
|
||
def test_single_file_record_refusal_names_only_a_string_key(self) -> None:
|
||
import msgpack # type: ignore
|
||
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
path = Path(tmp) / "store.cc"
|
||
for key, schema in ((["SECRET-IN-LIST"], "s"), (b"SECRET-BYTES", "s"), ("k", {"SECRET": 1})):
|
||
path.write_bytes(msgpack.packb(
|
||
["cultcache.store.v1", [], [[key, schema, "t", b"\x90", "extra"]]], use_bin_type=True))
|
||
with self.assertRaises(ValueError) as caught:
|
||
SingleFileMessagePackBackingStore(path).pull_all()
|
||
self.assertNotIn("SECRET", str(caught.exception))
|
||
self.assertIn("not a valid store", str(caught.exception))
|
||
|
||
# What a write does through a symbolic link today. R3 decides whether a write resolves
|
||
# the link or refuses it; this pins the current behaviour so that change is deliberate.
|
||
@unittest.skipIf(os.name != "posix", "symbolic links need POSIX here")
|
||
def test_r3_decides_resolve_or_refuse_a_push_through_a_live_link_replaces_the_link(self) -> None:
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
volume = Path(tmp) / "volume"
|
||
volume.mkdir()
|
||
target = volume / "store.cc"
|
||
envelope = CultCacheEnvelope(key="a", type="t", payload=b"\x90", stored_at="2026-09-30T00:00:00Z")
|
||
SingleFileMessagePackBackingStore(target).push(envelope)
|
||
before = target.read_bytes()
|
||
link = Path(tmp) / "store.cc"
|
||
link.symlink_to(target)
|
||
SingleFileMessagePackBackingStore(link).push(replace(envelope, key="b"))
|
||
self.assertFalse(link.is_symlink())
|
||
self.assertTrue(link.is_file())
|
||
self.assertEqual(target.read_bytes(), before)
|
||
|
||
@unittest.skipIf(os.name != "posix", "symbolic links need POSIX here")
|
||
def test_a_dangling_symlink_at_a_store_path_is_an_os_error_and_nothing_is_written(self) -> None:
|
||
for store_type in (SingleFileMessagePackBackingStore, JsonLinesBackingStore):
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
volume = Path(tmp) / "volume"
|
||
volume.mkdir()
|
||
path = Path(tmp) / "store.cc"
|
||
path.symlink_to(volume / "store.cc")
|
||
store = store_type(path)
|
||
with self.assertRaises(FileNotFoundError):
|
||
store.pull_all()
|
||
with self.assertRaises(FileNotFoundError):
|
||
store.push(CultCacheEnvelope(key="k", type="t", payload=b"\x90", stored_at="2026-09-30T00:00:00Z"))
|
||
self.assertTrue(path.is_symlink())
|
||
self.assertEqual(list(volume.iterdir()), [])
|
||
|
||
@unittest.skipIf(os.name != "posix", "symlink loops and file-as-parent errors are POSIX errno cases")
|
||
def test_json_lines_store_that_cannot_be_reached_is_an_os_error(self) -> None:
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
self.assertEqual(JsonLinesBackingStore(Path(tmp) / "missing.jsonl").pull_all(), [])
|
||
loop = Path(tmp) / "loop.jsonl"
|
||
loop.symlink_to(loop)
|
||
parent = Path(tmp) / "body"
|
||
parent.write_bytes(b"not a directory")
|
||
for path in (loop, parent / "store.jsonl"):
|
||
with self.assertRaises(OSError) as caught:
|
||
JsonLinesBackingStore(path).pull_all()
|
||
self.assertNotIsInstance(caught.exception, FileNotFoundError)
|
||
|
||
@unittest.skipIf(os.name != "posix", "symlink loops and file-as-parent errors are POSIX errno cases")
|
||
def test_single_file_store_that_cannot_be_reached_is_an_os_error(self) -> None:
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
loop = Path(tmp) / "loop.cc"
|
||
loop.symlink_to(loop)
|
||
parent = Path(tmp) / "body"
|
||
parent.write_bytes(b"not a directory")
|
||
for path in (loop, parent / "store.cc"):
|
||
with self.assertRaises(OSError) as caught:
|
||
SingleFileMessagePackBackingStore(path).pull_all()
|
||
self.assertNotIsInstance(caught.exception, FileNotFoundError)
|
||
|
||
|
||
# The element-id marker: tests/vectors/document-variants-c2a/v3-base.msgpack is v1-base with its header replaced.
|
||
_C2A_VECTORS = Path(__file__).resolve().parents[3] / "tests" / "vectors" / "document-variants-c2a"
|
||
|
||
def _header_after_rewrite(self, vector: Path) -> str:
|
||
import msgpack
|
||
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
store_path = Path(tmp) / "store.msgpack"
|
||
store_path.write_bytes(vector.read_bytes())
|
||
store = SingleFileMessagePackBackingStore(store_path)
|
||
envelopes = store.pull_all()
|
||
self.assertEqual([e.key for e in envelopes], ["alpha", "beta"])
|
||
store.push(envelopes[0])
|
||
return msgpack.unpackb(store_path.read_bytes(), raw=False)[0]
|
||
|
||
def test_single_file_reads_a_v3_element_id_store_and_a_rewrite_keeps_the_marker(self) -> None:
|
||
self.assertEqual(self._header_after_rewrite(self._C2A_VECTORS / "v3-base.msgpack"), "cultcache.store.v3")
|
||
|
||
def test_single_file_rewrite_of_a_v1_store_stays_v1(self) -> None:
|
||
self.assertEqual(self._header_after_rewrite(self._VECTORS / "v1-base.msgpack"), "cultcache.store.v1")
|
||
|
||
def test_single_file_flush_after_the_marked_file_is_gone_writes_unmarked(self) -> None:
|
||
import msgpack
|
||
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
store_path = Path(tmp) / "store.msgpack"
|
||
store_path.write_bytes((self._C2A_VECTORS / "v3-base.msgpack").read_bytes())
|
||
store = SingleFileMessagePackBackingStore(store_path)
|
||
envelopes = store.pull_all()
|
||
store_path.unlink()
|
||
store.push_all(envelopes)
|
||
self.assertEqual(msgpack.unpackb(store_path.read_bytes(), raw=False)[0], "cultcache.store.v1")
|
||
|
||
def test_single_file_push_after_the_marked_file_is_gone_writes_unmarked(self) -> None:
|
||
import msgpack
|
||
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
store_path = Path(tmp) / "store.msgpack"
|
||
store_path.write_bytes((self._C2A_VECTORS / "v3-base.msgpack").read_bytes())
|
||
store = SingleFileMessagePackBackingStore(store_path)
|
||
envelopes = store.pull_all()
|
||
store_path.unlink()
|
||
store.push(envelopes[0])
|
||
self.assertEqual(msgpack.unpackb(store_path.read_bytes(), raw=False)[0], "cultcache.store.v1")
|
||
|
||
def test_single_file_push_all_refuses_a_store_whose_header_it_cannot_read_and_leaves_it_untouched(self) -> None:
|
||
envelopes = SingleFileMessagePackBackingStore(self._C2A_VECTORS / "v3-base.msgpack").pull_all()
|
||
vectors = self._VECTORS.parent
|
||
for vector in (
|
||
self._VECTORS / "unknown-header.msgpack",
|
||
self._VECTORS / "variant-v2.msgpack",
|
||
vectors / "document-variants-c1" / "variant-store.msgpack",
|
||
):
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
store_path = Path(tmp) / "store.msgpack"
|
||
store_path.write_bytes(vector.read_bytes())
|
||
before = store_path.read_bytes()
|
||
with self.assertRaisesRegex(StoreUnreadableError, "is not readable"):
|
||
SingleFileMessagePackBackingStore(store_path).push_all(envelopes)
|
||
self.assertEqual(store_path.read_bytes(), before, f"{vector.name} was rewritten")
|
||
|
||
# A file is replaced by a rewrite exactly when this runtime's own reader opens it: one verdict per file, asked by
|
||
# pull_all, push_all, push and delete alike. The bytes and every runtime's verdict:
|
||
# tests/vectors/document-variants-c2a/readability.
|
||
def test_single_file_replaces_a_file_exactly_when_it_reads(self) -> None:
|
||
import msgpack # type: ignore
|
||
|
||
root = self._C2A_VECTORS / "readability"
|
||
rows = [
|
||
line.split()
|
||
for line in (root / "manifest.txt").read_text(encoding="utf-8").splitlines()
|
||
if line and not line.startswith("#")
|
||
]
|
||
# Every vector in the folder has a manifest row.
|
||
self.assertEqual(
|
||
sorted(path.name for path in root.glob("*.bin")),
|
||
sorted(vector for vector, *_ in rows if not vector.startswith("..")),
|
||
)
|
||
envelopes = SingleFileMessagePackBackingStore(self._C2A_VECTORS / "v3-base.msgpack").pull_all()
|
||
for vector, _, _, _, python in rows:
|
||
content = (root / vector).read_bytes()
|
||
for operation in ("pull_all", "push_all", "push", "delete"):
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
store_path = Path(tmp) / "store.msgpack"
|
||
store_path.write_bytes(content)
|
||
store = SingleFileMessagePackBackingStore(store_path)
|
||
run = {
|
||
"pull_all": store.pull_all,
|
||
"push_all": lambda: store.push_all(envelopes),
|
||
"push": lambda: store.push(envelopes[0]),
|
||
"delete": lambda: store.delete(envelopes[0].type, envelopes[0].key),
|
||
}[operation]
|
||
if python == "reads":
|
||
run()
|
||
if operation == "pull_all":
|
||
continue
|
||
# The replaced file is a store: it carries the header its old content decides (a v3 store keeps its
|
||
# marker, all else is v1), and it holds what the operation wrote.
|
||
expected = "cultcache.store.v3" if "v3" in vector else "cultcache.store.v1"
|
||
self.assertEqual(msgpack.unpackb(store_path.read_bytes(), raw=False)[0], expected, f"{vector} {operation}")
|
||
keys = [envelope.key for envelope in SingleFileMessagePackBackingStore(store_path).pull_all()]
|
||
if operation == "push_all":
|
||
self.assertEqual(sorted(set(keys) & {e.key for e in envelopes}), sorted(e.key for e in envelopes), f"{vector} {operation}")
|
||
elif operation == "push":
|
||
self.assertIn(envelopes[0].key, keys, f"{vector} {operation}")
|
||
else:
|
||
self.assertNotIn(envelopes[0].key, keys, f"{vector} {operation}")
|
||
else:
|
||
with self.assertRaises(StoreUnreadableError, msg=f"{vector} {operation}") as refused:
|
||
run()
|
||
self.assertIsNotNone(refused.exception.__cause__, f"{vector} {operation}")
|
||
self.assertEqual(store_path.read_bytes(), content, f"{vector} {operation} rewrote a file it cannot read")
|
||
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|