Writers: drop the same-tier dedupe refusal (unreachable), and pin tie order and the registered lister

Two entries of one tier that share an own id are refused when the id is carried, so a refusal at dedupe could never fire.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-30 10:14:33 +02:00
commit ca0b62023d
6 changed files with 66 additions and 29 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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);
}

View file

@ -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<CultSchemaCatalogEntry>()).Single().ContentHash, Is.EqualTo("h1"));
Assert.That(CultSchemaCatalogEntry.Derive(new[] { Rec("a", "x") }, Array.Empty<CultSchemaCatalogEntry>(), order).Single().ContentHash, Is.EqualTo("h1"));
}
}
[Test]
public void TwoArrivedEntriesThatShareAnOwnIdAndDisagreeOnTheSchemaNameRefuseTheWrite()
{