diff --git a/packages/cultcache-py/src/cultcache_py/stores.py b/packages/cultcache-py/src/cultcache_py/stores.py index 0e925bfe..653127e8 100644 --- a/packages/cultcache-py/src/cultcache_py/stores.py +++ b/packages/cultcache-py/src/cultcache_py/stores.py @@ -218,12 +218,8 @@ def _derive_catalog(records: list[tuple[CultCacheEnvelope, bool]]) -> list[CultC if pick is None: continue prior = chosen.get(pick.schema_id) - if prior is not None: - if prior[1] and not registered_pick: - continue - # Two entries of one tier that share an own id must be one schema: otherwise a record would be read as the other. - if prior[1] == registered_pick and prior[0].schema_name != pick.schema_name: - raise SchemaConflictError(pick.schema_id, sorted({prior[0].schema_name, pick.schema_name}), record_key) + if prior is not None and prior[1] and not registered_pick: + continue chosen[pick.schema_id] = (pick, registered_pick) for schema_id in sorted(by_id): diff --git a/packages/cultcache-py/tests/test_cultcache.py b/packages/cultcache-py/tests/test_cultcache.py index 9be21418..71e89690 100644 --- a/packages/cultcache-py/tests/test_cultcache.py +++ b/packages/cultcache-py/tests/test_cultcache.py @@ -415,6 +415,29 @@ class CultCacheTests(unittest.TestCase): 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 + + one = self._writer_record("a", "tests.n", "tests.x", self._writer_entry("tests.x", "tests.n", "h1", ("tests.x",))) + two = self._writer_record("b", "tests.n", "tests.x", self._writer_entry("tests.x", "tests.n", "h2", ("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"]) + 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 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 3c4e293e..d27d2094 100644 --- a/packages/cultcache-ts/src/single-file-messagepack-backing-store.ts +++ b/packages/cultcache-ts/src/single-file-messagepack-backing-store.ts @@ -284,15 +284,8 @@ function catalogEntriesFor( } const prior = chosen.get(pick.entry.schemaId); - if (prior !== undefined) { - if (prior.registered && !pick.registered) { - continue; - } - - // Two entries of one tier that share an own id must be one schema: otherwise a record would be read as the other. - if (prior.registered === pick.registered && prior.entry.schemaName !== pick.entry.schemaName) { - throw new SchemaConflictError(pick.entry.schemaId, [prior.entry.schemaName, pick.entry.schemaName].sort(compareOrdinal), recordKey); - } + if (prior !== undefined && prior.registered && !pick.registered) { + continue; } chosen.set(pick.entry.schemaId, pick); diff --git a/packages/cultcache-ts/test/cult-cache.test.ts b/packages/cultcache-ts/test/cult-cache.test.ts index 76836ead..a0cb5cb0 100644 --- a/packages/cultcache-ts/test/cult-cache.test.ts +++ b/packages/cultcache-ts/test/cult-cache.test.ts @@ -1456,6 +1456,30 @@ test("an entry that owns an id is chosen over one that lists it, and an entry no } }); +test("entries of one tier that tie are taken in one fixed order, and a registered entry that lists an id is chosen over an arrived one", async () => { + const dir = await mkdtemp(join(tmpdir(), "cultcache-tie-")); + const one = writerRecord("a", "tests.n", "tests.x", writerEntry("tests.x", "tests.n", "h1", ["tests.x"])); + const two = writerRecord("b", "tests.n", "tests.x", writerEntry("tests.x", "tests.n", "h2", ["tests.x", "tests.y"])); + for (const [name, order] of [["12", [one, two]], ["21", [two, one]]] as const) { + const file = join(dir, `${name}.msgpack`); + await new SingleFileMessagePackBackingStore(file).pushAll([...order]); + assert.deepEqual((await catalogOf(file)).map((entry) => entry[3]), ["h1"], name); + } + + // z sits under old, which the arrived entry (id x) lists; the supplied entry (id r) lists it too: the registered one publishes it. + const file = join(dir, "lister.msgpack"); + await writeFile( + file, + encode([ + "cultcache.store.v1", + [rawCatalogEntry(writerEntry("tests.x", "tests.n", "stale", ["tests.x", "old"]))], + [["z", "old", "2026-09-30T00:00:00.0000000Z", encode({ z: 1 })]], + ]), + ); + await new SingleFileMessagePackBackingStore(file).push(writerRecord("a", "tests.n", "tests.r", writerEntry("tests.r", "tests.n", "fresh", ["tests.r", "old"]))); + assert.deepEqual((await catalogOf(file)).map((entry) => entry[0]), ["tests.r"]); +}); + test("a record no chosen entry publishes refuses the write, typed, and leaves the file", async () => { const dir = await mkdtemp(join(tmpdir(), "cultcache-orphan-")); const file = join(dir, "orphan.msgpack"); diff --git a/src/GameCult.Caching/CultCache.cs b/src/GameCult.Caching/CultCache.cs index 18f5e19d..7cbac9f4 100644 --- a/src/GameCult.Caching/CultCache.cs +++ b/src/GameCult.Caching/CultCache.cs @@ -85,20 +85,8 @@ namespace GameCult.Caching if (pick == null) continue; - if (chosen.TryGetValue(pick.SchemaId, out var prior)) - { - if (prior.Registered && !isRegistered) - continue; - - // Two entries of one tier that share an own id must be one schema: otherwise a record would be read as the other. - if (prior.Registered == isRegistered && !string.Equals(prior.Entry.SchemaName, pick.SchemaName, StringComparison.Ordinal)) - { - var names = new[] { prior.Entry.SchemaName, pick.SchemaName }; - throw new CultSchemaConflictException( - $"Schema id '{pick.SchemaId}' is claimed by schemas '{names[0]}' and '{names[1]}'; record '{recordKey}' cannot be written under it.", - pick.SchemaId, names, recordKey); - } - } + if (chosen.TryGetValue(pick.SchemaId, out var prior) && prior.Registered && !isRegistered) + continue; chosen[pick.SchemaId] = (pick, isRegistered); } diff --git a/tests/GameCult.Caching.Tests/StoreReadabilityTests.cs b/tests/GameCult.Caching.Tests/StoreReadabilityTests.cs index a9a5de7b..642c6e63 100644 --- a/tests/GameCult.Caching.Tests/StoreReadabilityTests.cs +++ b/tests/GameCult.Caching.Tests/StoreReadabilityTests.cs @@ -204,6 +204,19 @@ namespace GameCult.Caching.Tests } } + // Entries of one tier that tie are taken in one fixed order: the catalog does not depend on the order they arrive in. + [Test] + public void EntriesThatTieAreTakenInOneFixedOrder() + { + var one = Entry("x", "n", "h1", "x"); + var two = Entry("x", "n", "h2", "x", "y"); + foreach (var order in new[] { new[] { one, two }, new[] { two, one } }) + { + Assert.That(CultSchemaCatalogEntry.Derive(new[] { Rec("a", "x") }, order, Array.Empty()).Single().ContentHash, Is.EqualTo("h1")); + Assert.That(CultSchemaCatalogEntry.Derive(new[] { Rec("a", "x") }, Array.Empty(), order).Single().ContentHash, Is.EqualTo("h1")); + } + } + [Test] public void TwoArrivedEntriesThatShareAnOwnIdAndDisagreeOnTheSchemaNameRefuseTheWrite() {