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:
parent
7a5b64ad88
commit
ca0b62023d
6 changed files with 66 additions and 29 deletions
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue