diff --git a/packages/cultcache-py/src/cultcache_py/stores.py b/packages/cultcache-py/src/cultcache_py/stores.py index 3c6d61c6..f06104b1 100644 --- a/packages/cultcache-py/src/cultcache_py/stores.py +++ b/packages/cultcache-py/src/cultcache_py/stores.py @@ -165,24 +165,37 @@ def _encode_snapshot(envelopes: list[CultCacheEnvelope], format_version: str) -> # own id or a compatible one), else a default entry under the record's id. The catalog is keyed by the entry's own id, so a # record read under a compatible id is written back beside the entry that lists it. catalog_by_schema_id: dict[str, CultCacheSchemaCatalogEntry] = {} + + def place(envelope: CultCacheEnvelope, candidates: list[CultCacheSchemaCatalogEntry]) -> None: + for candidate in candidates: + existing = catalog_by_schema_id.get(candidate.schema_id) + if existing is None: + catalog_by_schema_id[candidate.schema_id] = candidate + return + # Entries that share an id and a schema name are one schema seen by different writers: the entry written lists every id + # any of them lists. An entry of another schema is never merged: the record gets a default entry under its own id. + if existing.schema_name == candidate.schema_name: + catalog_by_schema_id[candidate.schema_id] = replace( + existing, + compatible_schema_ids=tuple(dict.fromkeys((*existing.compatible_schema_ids, *candidate.compatible_schema_ids))), + ) + return + raise ValueError(f"CultCache schema id {_schema_id_for(envelope)!r} cannot identify two different schemas") + + # A record's own id is claimed first, so an entry that lists it as compatible cannot take it from the schema that owns it, + # whatever order the records arrive in. + listed: list[tuple[CultCacheEnvelope, CultCacheSchemaCatalogEntry]] = [] for envelope in envelopes: schema_id = _schema_id_for(envelope) supplied = envelope.catalog_entry - published = ( - supplied - if supplied is not None and (supplied.schema_id == schema_id or schema_id in supplied.compatible_schema_ids) - else _default_catalog_entry(envelope) - ) - # Entries that share an id are one schema seen by different writers: the entry written lists every id any of them lists. - existing = catalog_by_schema_id.get(published.schema_id) - catalog_by_schema_id[published.schema_id] = ( - published - if existing is None - else replace( - existing, - compatible_schema_ids=tuple(dict.fromkeys((*existing.compatible_schema_ids, *published.compatible_schema_ids))), - ) - ) + if supplied is not None and supplied.schema_id != schema_id and schema_id in supplied.compatible_schema_ids: + listed.append((envelope, supplied)) + elif supplied is not None and supplied.schema_id == schema_id: + place(envelope, [supplied]) + else: + place(envelope, [_default_catalog_entry(envelope)]) + for envelope, supplied in listed: + place(envelope, [supplied, _default_catalog_entry(envelope)]) catalog = [ _encode_catalog_entry(entry) diff --git a/packages/cultcache-py/tests/test_cultcache.py b/packages/cultcache-py/tests/test_cultcache.py index 4596c5ab..ffbc2706 100644 --- a/packages/cultcache-py/tests/test_cultcache.py +++ b/packages/cultcache-py/tests/test_cultcache.py @@ -347,8 +347,35 @@ class CultCacheTests(unittest.TestCase): self.assertEqual(len(SingleFileMessagePackBackingStore(path).pull_all()), 2) def test_an_id_an_entry_owns_names_that_entry_not_one_that_lists_it_as_compatible(self) -> None: - store = SingleFileMessagePackBackingStore(self._C2A_VECTORS / "readability" / "own-id-over-compatible-v3.bin") - self.assertEqual([envelope.type for envelope in store.pull_all()], ["vectors.item", "vectors.item"]) + 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) + + # Entries of different schemas that share an id are not merged: the record gets a default entry under its own id, so no record + # is read as another schema, whichever order the records arrive in. + def test_the_store_writer_never_merges_an_entry_of_another_schema(self) -> None: + import msgpack # type: ignore + from cultcache_py import CultCacheSchemaCatalogEntry + from cultcache_py.stores import _decode_snapshot, _encode_snapshot + + def entry(name: str, compatible: tuple[str, ...]) -> CultCacheSchemaCatalogEntry: + return CultCacheSchemaCatalogEntry( + schema_id="tests.retype.x", schema_name=name, schema_version=name + ".v1", + content_hash="tests.retype.x", canonical_schema_json="", compatible_schema_ids=compatible, members=(), + ) + + def record(key: str, type: str, schema_id: str, catalog_entry: CultCacheSchemaCatalogEntry) -> CultCacheEnvelope: + 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, + ) + + a = record("a", "tests.retype.mine", "tests.retype.x", entry("tests.retype.mine", ("tests.retype.x",))) + b = record("b", "tests.retype.theirs", "tests.retype.y", entry("tests.retype.theirs", ("tests.retype.x", "tests.retype.y"))) + for order in ([a, b], [b, a]): + decoded = msgpack.unpackb(msgpack.packb(_encode_snapshot(order, "cultcache.store.v1"), use_bin_type=True), raw=False) + _, envelopes = _decode_snapshot(decoded) + self.assertEqual(sorted((e.key, e.type) for e in envelopes), [("a", "tests.retype.mine"), ("b", "tests.retype.theirs")]) 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 diff --git a/packages/cultcache-rs/src/lib.rs b/packages/cultcache-rs/src/lib.rs index 2fa649b0..0be95fed 100644 --- a/packages/cultcache-rs/src/lib.rs +++ b/packages/cultcache-rs/src/lib.rs @@ -4826,16 +4826,19 @@ mod tests { // An id one entry owns and a later entry lists as compatible names the entry that owns it. #[test] fn an_id_an_entry_owns_names_that_entry_not_one_that_lists_it_as_compatible() -> Result<()> { - let temp = tempfile::tempdir()?; - let path = temp.path().join("store.msgpack"); - std::fs::copy( - std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .join("../../tests/vectors/document-variants-c2a/readability/own-id-over-compatible-v3.bin"), - &path, - )?; - let envelopes = SingleFileMessagePackBackingStore::new(&path).pull_all()?; - assert_eq!(envelopes.len(), 2); - assert!(envelopes.iter().all(|envelope| envelope.r#type == "vectors.item"), "{envelopes:?}"); + for vector in ["own-id-over-compatible-v3.bin", "compatible-before-owner-v3.bin"] { + let temp = tempfile::tempdir()?; + let path = temp.path().join("store.msgpack"); + std::fs::copy( + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../tests/vectors/document-variants-c2a/readability") + .join(vector), + &path, + )?; + let envelopes = SingleFileMessagePackBackingStore::new(&path).pull_all()?; + assert_eq!(envelopes.len(), 2, "{vector}"); + assert!(envelopes.iter().all(|envelope| envelope.r#type == "vectors.item"), "{vector}: {envelopes:?}"); + } Ok(()) } diff --git a/packages/cultcache-ts/src/single-file-messagepack-backing-store.ts b/packages/cultcache-ts/src/single-file-messagepack-backing-store.ts index 46af9bbc..b8227ba2 100644 --- a/packages/cultcache-ts/src/single-file-messagepack-backing-store.ts +++ b/packages/cultcache-ts/src/single-file-messagepack-backing-store.ts @@ -220,13 +220,9 @@ function encodeSnapshot(entries: CultCacheEnvelope[], format: StoreFormat): unkn // record read under a compatible id is written back beside the entry that lists it. function catalogEntriesFor(entries: CultCacheEnvelope[]): Map { const catalog = new Map(); - for (const entry of entries) { + const defaultEntryFor = (entry: CultCacheEnvelope): CultCacheSchemaCatalogEntry => { const schemaId = schemaIdForEnvelope(entry); - const supplied = entry.catalogEntry; - const publishes = - supplied !== undefined && - (supplied.schemaId === schemaId || (supplied.compatibleSchemaIds ?? []).includes(schemaId)); - const published: CultCacheSchemaCatalogEntry = publishes ? supplied : { + return { schemaId, schemaName: entry.type, schemaVersion: `${entry.type}.v1`, @@ -239,19 +235,48 @@ function catalogEntriesFor(entries: CultCacheEnvelope[]): Map { + for (const candidate of candidates) { + const existing = catalog.get(candidate.schemaId); + if (existing === undefined) { + catalog.set(candidate.schemaId, candidate); + return; + } + + // Entries that share an id and a schema name are one schema seen by different writers: the entry written lists every id any + // of them lists. An entry of another schema is never merged: the record gets a default entry under its own id. + if (existing.schemaName === candidate.schemaName) { + catalog.set(candidate.schemaId, { + ...existing, + compatibleSchemaIds: [ + ...new Set([...(existing.compatibleSchemaIds ?? [existing.schemaId]), ...(candidate.compatibleSchemaIds ?? [candidate.schemaId])]), + ], + }); + return; + } + } + + throw new Error(`CultCache schema id "${schemaIdForEnvelope(entry)}" cannot identify two different schemas.`); + }; + + // A record's own id is claimed first, so an entry that lists it as compatible cannot take it from the schema that owns it, + // whatever order the records arrive in. + const listed: Array<[CultCacheEnvelope, CultCacheSchemaCatalogEntry]> = []; + for (const entry of entries) { + const schemaId = schemaIdForEnvelope(entry); + const supplied = entry.catalogEntry; + if (supplied !== undefined && supplied.schemaId !== schemaId && (supplied.compatibleSchemaIds ?? []).includes(schemaId)) { + listed.push([entry, supplied]); + } else if (supplied !== undefined && supplied.schemaId === schemaId) { + place(entry, [supplied]); + } else { + place(entry, [defaultEntryFor(entry)]); + } + } + + for (const [entry, supplied] of listed) { + place(entry, [supplied, defaultEntryFor(entry)]); } return catalog; diff --git a/packages/cultcache-ts/test/cult-cache.test.ts b/packages/cultcache-ts/test/cult-cache.test.ts index 951b3896..21b37e1b 100644 --- a/packages/cultcache-ts/test/cult-cache.test.ts +++ b/packages/cultcache-ts/test/cult-cache.test.ts @@ -1383,13 +1383,48 @@ test("the store writer merges the compatible ids of entries that share a schema assert.equal((await new SingleFileMessagePackBackingStore(file).pullAll()).length, 2); }); +// Entries of different schemas that share an id are not merged: the record gets a default entry under its own id, so no record is +// read as another schema, whichever order the records arrive in. +test("the store writer never merges an entry of another schema into a record's catalog entry", async () => { + const entry = (schemaName: string, compatibleSchemaIds: string[]) => ({ + schemaId: "tests.retype.x", + schemaName, + schemaVersion: `${schemaName}.v1`, + contentHash: "tests.retype.x", + canonicalSchemaJson: "", + compatibleSchemaIds, + members: [], + }); + const record = (key: string, type: string, schemaId: string, catalogEntry: ReturnType) => ({ + key, + type, + schemaId, + payload: encode({ key }), + storedAt: "2026-09-30T00:00:00.0000000Z", + catalogEntry, + }); + // a is schema "mine" under x; b is schema "theirs" under y, carrying an entry with x's id that lists y. + const a = record("a", "tests.retype.mine", "tests.retype.x", entry("tests.retype.mine", ["tests.retype.x"])); + const b = record("b", "tests.retype.theirs", "tests.retype.y", entry("tests.retype.theirs", ["tests.retype.x", "tests.retype.y"])); + const dir = await mkdtemp(join(tmpdir(), "cultcache-retype-")); + + for (const [name, order] of [["ab", [a, b]], ["ba", [b, a]]] as const) { + const file = join(dir, `${name}.msgpack`); + await new SingleFileMessagePackBackingStore(file).pushAll([...order]); + const back = await new SingleFileMessagePackBackingStore(file).pullAll(); + assert.deepEqual(back.map((envelope) => [envelope.key, envelope.type]).sort(), [["a", "tests.retype.mine"], ["b", "tests.retype.theirs"]], name); + } +}); + test("an id an entry owns names that entry, not one that lists it as compatible", async () => { const dir = await mkdtemp(join(tmpdir(), "cultcache-own-id-")); - const file = await copyVector(dir, join(c2aVectors, "readability", "own-id-over-compatible-v3.bin"), "own.msgpack"); - const envelopes = await new SingleFileMessagePackBackingStore(file).pullAll(); - assert.deepEqual(envelopes.map((envelope) => envelope.type), ["vectors.item", "vectors.item"]); - const inspection = inspectCultCacheBytes("own.msgpack", await readFile(file)); - assert.deepEqual(inspection.records.map((record) => record.schemaName), ["vectors.item", "vectors.item"]); + for (const vector of ["own-id-over-compatible-v3.bin", "compatible-before-owner-v3.bin"]) { + const file = await copyVector(dir, join(c2aVectors, "readability", vector), "own.msgpack"); + const envelopes = await new SingleFileMessagePackBackingStore(file).pullAll(); + assert.deepEqual(envelopes.map((envelope) => envelope.type), ["vectors.item", "vectors.item"], vector); + const inspection = inspectCultCacheBytes("own.msgpack", await readFile(file)); + assert.deepEqual(inspection.records.map((record) => record.schemaName), ["vectors.item", "vectors.item"], vector); + } }); // The inspector reads what the reader reads: a record under an id its catalog lists only as compatible is inspected. diff --git a/packages/cultmesh-py/src/cultmesh_py/node.py b/packages/cultmesh-py/src/cultmesh_py/node.py index 09ac2012..9af9f624 100644 --- a/packages/cultmesh-py/src/cultmesh_py/node.py +++ b/packages/cultmesh-py/src/cultmesh_py/node.py @@ -26,7 +26,7 @@ from cultnet_py import ( apply_shard_log_response as apply_cultnet_shard_log_response, document_delete, document_put_raw, - resolve_document_and_schema_id_for_raw_record, + resolve_document_for_raw_record, schema_document_map, ) @@ -657,13 +657,11 @@ class CultMeshDatabase: schema_id = str(document_record.get("schemaId") or "") documents_by_schema_id = schema_document_map(self.documents) try: - document, resolved_schema_id = resolve_document_and_schema_id_for_raw_record( - documents_by_schema_id, - schema_id, - document_record, - ) + document = resolve_document_for_raw_record(documents_by_schema_id, schema_id, document_record) except KeyError: return None + # One id: the local schema id, under which the cache stores the record and previous values are keyed. + resolved_schema_id = document.catalog_entry().schema_id record_key = str(document_record["recordKey"]) previous = self.cache.get(document, record_key) envelope = CultCacheEnvelope( @@ -1142,12 +1140,8 @@ class CultMeshDatabase: documents_by_schema_id = schema_document_map(self.documents) if raw_record is not None: try: - _, resolved_schema_id = resolve_document_and_schema_id_for_raw_record( - documents_by_schema_id, - schema_id, - raw_record, - ) - return resolved_schema_id, record_key + document = resolve_document_for_raw_record(documents_by_schema_id, schema_id, raw_record) + return document.catalog_entry().schema_id, record_key except KeyError: pass document = documents_by_schema_id.get(schema_id) @@ -1307,15 +1301,11 @@ class CultMeshDatabase: continue schema_id = str(record.get("schemaId")) try: - document, resolved_schema_id = resolve_document_and_schema_id_for_raw_record( - documents_by_schema_id, - schema_id, - record, - ) + document = resolve_document_for_raw_record(documents_by_schema_id, schema_id, record) except KeyError: continue record_key = str(record.get("recordKey")) - previous[(resolved_schema_id, record_key)] = self.cache.get(document, record_key) + previous[(document.catalog_entry().schema_id, record_key)] = self.cache.get(document, record_key) return previous @staticmethod diff --git a/packages/cultmesh-py/tests/test_cultmesh.py b/packages/cultmesh-py/tests/test_cultmesh.py index 9a697370..5e2befcf 100644 --- a/packages/cultmesh-py/tests/test_cultmesh.py +++ b/packages/cultmesh-py/tests/test_cultmesh.py @@ -338,6 +338,46 @@ class CultMeshTests(unittest.TestCase): self.assertEqual(observed[0].value["value"], "new") self.assertEqual(node.cache.get_required_envelope(document, "policy:1").schema_id, local_schema_id) + # A record arriving under a compatible id or the schema name is the same record: one id, the local one, so an existing + # record is updated with its previous value. + def test_cultmesh_node_reports_an_update_when_a_record_arrives_under_a_compatible_id_or_the_schema_name(self) -> None: + document = define_database_entry_type( + "mesh.compatible-policy", + [("schema_version", 0), ("name", 1), ("value", 2)], + schema_id="mesh.compatible-policy.current", + schema_name="mesh.compatible_policy", + schema_version="mesh.compatible_policy.v1", + compatible_schema_ids=["mesh.compatible-policy.current", "mesh.compatible-policy.older"], + ) + for wire_schema_id in ("mesh.compatible-policy.older", "mesh.compatible_policy"): + node = create_node(runtime_id="python-node") + node.register_document(document) + node.put(document, "policy:1", {"schema_version": "mesh.compatible_policy.v1", "name": "policy", "value": "old"}) + observed: list[CultMeshDatabaseChange] = [] + unsubscribe = node.database.watch_record(document, "policy:1", observed.append) + try: + applied = node.database.apply_snapshot_response({ + "schemaVersion": "cultnet.snapshot_response_raw.v0", + "messageId": "compatible-snapshot", + "documents": [{ + "schemaId": wire_schema_id, + "recordKey": "policy:1", + "storedAt": "2026-06-25T00:00:00Z", + "payloadEncoding": "messagepack", + "payload": document.encode_payload({ + "schema_version": "mesh.compatible_policy.v1", "name": "policy", "value": "new", + }), + }], + }) + finally: + unsubscribe() + + local_schema_id = document.catalog_entry().schema_id + self.assertEqual(applied[0].schema_id, local_schema_id, wire_schema_id) + self.assertEqual([change.change_kind for change in observed], ["updated"], wire_schema_id) + self.assertEqual(observed[0].previous_value["value"], "old", wire_schema_id) + self.assertEqual(observed[0].value["value"], "new", wire_schema_id) + def test_cultmesh_node_syncs_snapshot_and_shard_log_through_cultnet_client(self) -> None: import msgpack # type: ignore from cultnet_py import read_frame, write_frame diff --git a/packages/cultnet-py/src/cultnet_py/__init__.py b/packages/cultnet-py/src/cultnet_py/__init__.py index b1734f25..61c81668 100644 --- a/packages/cultnet-py/src/cultnet_py/__init__.py +++ b/packages/cultnet-py/src/cultnet_py/__init__.py @@ -65,7 +65,7 @@ from .replication import ( apply_raw_document_record, apply_raw_snapshot, apply_shard_log_response, - resolve_document_and_schema_id_for_raw_record, + resolve_document_for_raw_record, schema_document_map, ) from .schema_catalog import ( @@ -223,7 +223,7 @@ __all__ = [ "apply_raw_document_record", "apply_raw_snapshot", "apply_shard_log_response", - "resolve_document_and_schema_id_for_raw_record", + "resolve_document_for_raw_record", "compute_simulation_claim_hash", "compute_reconnect_delay_ms", "create_reconnect_policy", diff --git a/packages/cultnet-py/src/cultnet_py/replication.py b/packages/cultnet-py/src/cultnet_py/replication.py index bb26a8c8..531c0012 100644 --- a/packages/cultnet-py/src/cultnet_py/replication.py +++ b/packages/cultnet-py/src/cultnet_py/replication.py @@ -154,28 +154,19 @@ def can_resolve_document_for_raw_record( return False +# routes a replicated raw record to a local binding; the store always persists the local schema id; this is not store-reader recovery. def resolve_document_for_raw_record( documents_by_schema_id: dict[str, DocumentDefinition[Any]], schema_id: str, record: dict[str, Any], ) -> DocumentDefinition[Any]: - return resolve_document_and_schema_id_for_raw_record(documents_by_schema_id, schema_id, record)[0] - - -# routes a replicated raw record to a local binding; the store always persists the local schema id; this is not store-reader recovery. -def resolve_document_and_schema_id_for_raw_record( - documents_by_schema_id: dict[str, DocumentDefinition[Any]], - schema_id: str, - record: dict[str, Any], -) -> tuple[DocumentDefinition[Any], str]: document = documents_by_schema_id.get(schema_id) if document is not None: - return document, schema_id + return document schema_version = _infer_schema_version_from_payload(bytes(record["payload"])) schema_name = _infer_schema_name(schema_version) if schema_version is not None else None if schema_name is not None and schema_name in documents_by_schema_id: - document = documents_by_schema_id[schema_name] - return document, document.catalog_entry().schema_id + return documents_by_schema_id[schema_name] raise KeyError(schema_id) diff --git a/packages/cultnet-py/tests/test_cultnet.py b/packages/cultnet-py/tests/test_cultnet.py index b8aad39d..45315e1c 100644 --- a/packages/cultnet-py/tests/test_cultnet.py +++ b/packages/cultnet-py/tests/test_cultnet.py @@ -2190,7 +2190,7 @@ class CultNetTests(unittest.TestCase): runtime_kind="python", display_name="Python Interop Test", agent_id="python-interop-test-agent", - schema_path=str(Path("contracts/cultnet/interop/cultnet.interop-note.schema.json")), + schema_path=str(Path(__file__).resolve().parents[3] / "contracts" / "cultnet" / "interop" / "cultnet.interop-note.schema.json"), ) binding = state.bindings["note"] logged_value = { diff --git a/src/GameCult.Caching/CultCache.cs b/src/GameCult.Caching/CultCache.cs index ff753ce8..28884e10 100644 --- a/src/GameCult.Caching/CultCache.cs +++ b/src/GameCult.Caching/CultCache.cs @@ -29,15 +29,18 @@ namespace GameCult.Caching public CultSchemaMemberCatalogEntry[] Members { get; set; } = Array.Empty(); - // One entry per schema id, whatever order the candidates come in: entries that share an id are one schema seen by different - // writers, so the entry written lists every id any of them lists, and its other fields are the first's (or the last's). + // One entry per schema id, whatever order the candidates come in. Entries that share an id and a schema name are one schema seen + // by different writers: the merged entry takes its name, content hash and members from the entry being written now (the + // last with preferLast, else the first) and lists every id any of them lists. An entry of another schema under the same id is + // dropped, never merged: its ids are not this schema's. internal static IEnumerable MergeById(IEnumerable entries, bool preferLast = false) { foreach (var group in entries.GroupBy(entry => entry.SchemaId, StringComparer.Ordinal)) { var members = group.ToArray(); var chosen = preferLast ? members[^1] : members[0]; - if (members.Length == 1) + var sameSchema = members.Where(entry => string.Equals(entry.SchemaName, chosen.SchemaName, StringComparison.Ordinal)).ToArray(); + if (sameSchema.Length == 1) { yield return chosen; continue; @@ -50,7 +53,7 @@ namespace GameCult.Caching SchemaVersion = chosen.SchemaVersion, ContentHash = chosen.ContentHash, CanonicalSchemaJson = chosen.CanonicalSchemaJson, - CompatibleSchemaIds = members.SelectMany(entry => entry.CompatibleSchemaIds).Distinct(StringComparer.Ordinal).ToArray(), + CompatibleSchemaIds = sameSchema.SelectMany(entry => entry.CompatibleSchemaIds).Distinct(StringComparer.Ordinal).ToArray(), Members = chosen.Members }; } @@ -3664,7 +3667,7 @@ namespace GameCult.Caching var snapshot = new CultPersistedStoreSnapshot { FormatVersion = HeaderFor(holdsElementIds, existingHeader, wholeStore, directoryStore: false, holdsVariants: ordered.Any(record => record.Variant != null)), - SchemaCatalog = CultSchemaCatalogEntry.MergeById(catalog) + SchemaCatalog = CultSchemaCatalogEntry.MergeById(catalog, preferLast: true) // An entry stays while it publishes a schema id some record carries, as its own id or a compatible one. .Where(entry => used.Contains(entry.SchemaId) || entry.CompatibleSchemaIds.Any(used.Contains)) .OrderBy(entry => entry.SchemaName, StringComparer.Ordinal) diff --git a/tests/GameCult.Caching.Tests/StoreReadabilityTests.cs b/tests/GameCult.Caching.Tests/StoreReadabilityTests.cs index d6931b69..abbc3864 100644 --- a/tests/GameCult.Caching.Tests/StoreReadabilityTests.cs +++ b/tests/GameCult.Caching.Tests/StoreReadabilityTests.cs @@ -156,11 +156,12 @@ namespace GameCult.Caching.Tests } // An id one entry owns and a later entry lists as compatible names the entry that owns it. - [Test] - public void AnIdAnEntryOwnsNamesThatEntryNotOneThatListsItAsCompatible() + [TestCase("own-id-over-compatible-v3.bin")] + [TestCase("compatible-before-owner-v3.bin")] + public void AnIdAnEntryOwnsNamesThatEntryNotOneThatListsItAsCompatible(string vector) { var path = Path.Combine(_directory, "own-id.cc"); - File.WriteAllBytes(path, File.ReadAllBytes(Path.Combine(VectorRoot(), "own-id-over-compatible-v3.bin"))); + File.WriteAllBytes(path, File.ReadAllBytes(Path.Combine(VectorRoot(), vector))); using var cache = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Registry }); Assert.That(cache.Get(new CultRecordKey("alpha")), Is.Not.Null, "the record read as the schema that owns its id"); @@ -200,6 +201,59 @@ namespace GameCult.Caching.Tests var rewritten = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path)); Assert.That(rewritten.SchemaCatalog.Single().CompatibleSchemaIds, Does.Contain("vectors.old.id")); + Assert.That(rewritten.SchemaCatalog.Single().ContentHash, Is.Not.EqualTo("stale"), "the merged entry's content hash is the schema being written's"); + } + + // The single-file writer merges the same way: a commit onto a file keeps the ids the file's entry lists, and the entry's + // content hash is the registered schema's, not the file's stale one. + [Test] + public void ASingleFileCommitMergesTheFilesEntryWithTheSchemaBeingWritten() + { + var path = Seed("merged-single.cc"); + var manifest = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path)); + var entry = manifest.SchemaCatalog.Single(); + manifest.Records.Single().SchemaId = "vectors.old.id"; + entry.ContentHash = "stale"; + entry.CompatibleSchemaIds = new[] { entry.SchemaId, "vectors.old.id" }; + File.WriteAllBytes(path, CultDocumentMessagePackSerialization.SerializeSnapshot(manifest)); + + using (var cache = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Registry })) + { + Assert.That(cache.Commit(batch => + { + batch.Expect(new CultRecordKey("e"), null); + batch.Upsert(typeof(IdDeck), new IdDeck { Name = "e" }, new CultRecordKey("e")); + }), Is.True); + } + + var rewritten = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path)); + Assert.That(rewritten.SchemaCatalog.Single().CompatibleSchemaIds, Does.Contain("vectors.old.id")); + Assert.That(rewritten.SchemaCatalog.Single().ContentHash, Is.Not.EqualTo("stale")); + } + + // Entries that share an id merge only when they are the same schema: another schema's entry is dropped, its ids never + // listed, and the entry written is the one being written now. + [Test] + public void EntriesOfDifferentSchemasThatShareAnIdAreNotMerged() + { + CultSchemaCatalogEntry Entry(string name, string hash, params string[] compatible) => new() + { + SchemaId = "x", SchemaName = name, SchemaVersion = name + ".v1", ContentHash = hash, CompatibleSchemaIds = compatible + }; + var mine = Entry("mine", "h1", "x"); + var theirs = Entry("theirs", "h2", "x", "z"); + + foreach (var order in new[] { new[] { mine, theirs }, new[] { theirs, mine } }) + { + var first = CultSchemaCatalogEntry.MergeById(order, preferLast: false).Single(); + var last = CultSchemaCatalogEntry.MergeById(order, preferLast: true).Single(); + Assert.That(first, Is.SameAs(order[0])); + Assert.That(last, Is.SameAs(order[1])); + } + + var sameSchema = CultSchemaCatalogEntry.MergeById(new[] { Entry("mine", "old", "x", "y"), Entry("mine", "new", "x") }, preferLast: true).Single(); + Assert.That(sameSchema.ContentHash, Is.EqualTo("new")); + Assert.That(sameSchema.CompatibleSchemaIds, Is.EquivalentTo(new[] { "x", "y" })); } // Entries that share an id are merged whatever order they arrive in: every id any of them lists survives. diff --git a/tests/vectors/document-variants-c2a/readability/compatible-before-owner-v3.bin b/tests/vectors/document-variants-c2a/readability/compatible-before-owner-v3.bin new file mode 100644 index 00000000..2c32e0bb --- /dev/null +++ b/tests/vectors/document-variants-c2a/readability/compatible-before-owner-v3.bin @@ -0,0 +1 @@ +“²cultcache.store.v3’—±vectors.item.next­vectors.other°vectors.other.v1±vectors.item.next¢{}‘¬vectors.item�—¬vectors.item¬vectors.item¯vectors.item.v1¬vectors.itemÙL{"schemaName":"vectors.item","schemaVersion":"vectors.item.v1","members":[]}��’”¥alpha¬vectors.item¹2026-09-29T00:00:00+00:00Ä’¥alpha”¤beta¬vectors.item¹2026-09-29T00:00:01+00:00Ä’¤beta \ No newline at end of file diff --git a/tests/vectors/document-variants-c2a/readability/generate.py b/tests/vectors/document-variants-c2a/readability/generate.py index 2b455225..f44d2a20 100644 --- a/tests/vectors/document-variants-c2a/readability/generate.py +++ b/tests/vectors/document-variants-c2a/readability/generate.py @@ -43,6 +43,9 @@ vectors = { "compatible-id-only-v3": pack([base[0], [[base[1][0][0] + ".next"] + base[1][0][1:]], base[2]]), # An id that one entry owns and a later entry lists as compatible names the entry that owns it (here, vectors.item, which does not list itself as compatible). "own-id-over-compatible-v3": pack([base[0], [base[1][0][:5] + [[]] + base[1][0][6:]] + [["vectors.item.next", "vectors.other", "vectors.other.v1", "vectors.item.next", "{}", ["vectors.item"], []]], base[2]]), + # The same, with the entry that lists the id as compatible before the entry that owns it: a reader that takes the first entry + # naming an id in one pass reads the record as vectors.other. + "compatible-before-owner-v3": pack([base[0], [["vectors.item.next", "vectors.other", "vectors.other.v1", "vectors.item.next", "{}", ["vectors.item"], []], base[1][0][:5] + [[]] + base[1][0][6:]], base[2]]), # v3-base plus a variant of alpha: a fifth record slot, [baseKey, overrides[]], over an empty payload, overriding the name. Only C# reads it. "variant-slot-v3": pack([base[0], base[1], base[2] + [["gamma"] + base[2][0][1:3] + [b"", ["alpha", [[0, [[0, ""]], "", "gamma"]]]]]]), # v3-base with a fourth top-level slot after the records. diff --git a/tests/vectors/document-variants-c2a/readability/manifest.txt b/tests/vectors/document-variants-c2a/readability/manifest.txt index 841c4ec4..7d9b4224 100644 --- a/tests/vectors/document-variants-c2a/readability/manifest.txt +++ b/tests/vectors/document-variants-c2a/readability/manifest.txt @@ -25,3 +25,4 @@ variant-slot-v3.bin reads refuses refuses refuses missing-schema-versioned-v3.bin refuses refuses refuses refuses compatible-id-only-v3.bin reads reads reads reads own-id-over-compatible-v3.bin reads reads reads reads +compatible-before-owner-v3.bin reads reads reads reads