From a0328066bc2edd36642295e53609f64b2ac7892b Mon Sep 17 00:00:00 2001 From: Libby Date: Fri, 13 Nov 2026 05:02:01 +0000 Subject: [PATCH 01/16] Rust, Python, TypeScript single-file stores re-check for a link once the store lock is held (link-late-check-rs-py-ts) Co-Authored-By: Claude Sonnet 5.5 --- packages/cultcache-py/CHANGELOG.md | 1 + .../cultcache-py/src/cultcache_py/stores.py | 2 + .../cultcache-py/tests/test_store_lock.py | 41 ++++++++++++++++++ packages/cultcache-rs/CHANGELOG.md | 1 + packages/cultcache-rs/src/lib.rs | 43 ++++++++++++++++++- packages/cultcache-ts/CHANGELOG.md | 1 + packages/cultcache-ts/src/store-file.ts | 2 + packages/cultcache-ts/test/store-file.test.ts | 35 +++++++++++++++ 8 files changed, 124 insertions(+), 2 deletions(-) diff --git a/packages/cultcache-py/CHANGELOG.md b/packages/cultcache-py/CHANGELOG.md index e5a8b8e8..84d454bf 100644 --- a/packages/cultcache-py/CHANGELOG.md +++ b/packages/cultcache-py/CHANGELOG.md @@ -8,6 +8,7 @@ ### Changed +- `store_write_lock` looks for a link at the store path again once the lock is held, so a link put at the path while a writer waited on the lock is refused with `StoreLinkedError` and the lock released, instead of being replaced. `JsonLinesBackingStore` takes no cross-process lock (only its thread lock, under which it already refuses a link), so it has no lock wait to re-check across. Pinned by a test that puts the link while a foreign process holds the lock. - `CultCache.is_writable` does not look at store paths: a link at a store path is not predicted, and the write refuses it with `StoreLinkedError`. Pinned by a test through a cache attached to a linked store. - `JsonLinesBackingStore` writes LF line endings on Windows. It wrote CRLF there, through `write_text` newline translation, before the commit_store_file change (#154, adbbb780). - `msgpack` is a hard dependency, as `pyproject.toml` declared it. The optional-import fallbacks and the `cultcache-py[msgpack]` extra they named (which never existed) are gone: a missing msgpack is an `ImportError` at import. diff --git a/packages/cultcache-py/src/cultcache_py/stores.py b/packages/cultcache-py/src/cultcache_py/stores.py index 36729d44..bc3e5429 100644 --- a/packages/cultcache-py/src/cultcache_py/stores.py +++ b/packages/cultcache-py/src/cultcache_py/stores.py @@ -495,6 +495,8 @@ def store_write_lock(path: Path, timeout: float = LOCK_TIMEOUT_SECONDS) -> Itera raise TimeoutError(f"CultCache timed out after {timeout} s waiting for the store lock {lock_path}.") time.sleep(_LOCK_POLL_SECONDS) try: + # A link may have been put at the path while the lock was awaited; the look above only saw the path before. + _refuse_linked(path) yield finally: release() diff --git a/packages/cultcache-py/tests/test_store_lock.py b/packages/cultcache-py/tests/test_store_lock.py index 5ee29aa2..0122f094 100644 --- a/packages/cultcache-py/tests/test_store_lock.py +++ b/packages/cultcache-py/tests/test_store_lock.py @@ -5,12 +5,14 @@ import shutil import subprocess import sys import tempfile +import threading import time import unittest from pathlib import Path from unittest import mock from cultcache_py import SingleFileMessagePackBackingStore, stores +from cultcache_py.stores import StoreLinkedError, store_write_lock from cultcache_py.backing_store import CultCacheEnvelope REPO = Path(__file__).resolve().parents[3] @@ -73,6 +75,45 @@ class StoreLockTests(unittest.TestCase): holder = start_holder(["node", "-e", TS_HOLDER, str(TS_STORE_FILE), str(self.path), str(HOLD_SECONDS)]) self.assert_write_waits_for(holder) + def test_a_link_put_while_a_writer_waits_on_the_lock_is_refused_once_it_holds_it(self) -> None: + target = self.dir / "volume" / "store.cc" + target.parent.mkdir() + SingleFileMessagePackBackingStore(target).push(envelope("a")) + before = target.read_bytes() + writes = { + "push": lambda store: store.push(envelope("b")), + "push_all": lambda store: store.push_all([envelope("b")]), + "delete": lambda store: store.delete("t", "a"), + } + for name, write in writes.items(): + with self.subTest(name): + if os.path.lexists(self.path): + os.unlink(self.path) + holder = start_holder([sys.executable, "-c", PY_HOLDER, str(self.path), str(HOLD_SECONDS)]) + self.addCleanup(holder.wait) + self.addCleanup(holder.stdout.close) # type: ignore[union-attr] + outcome: list[BaseException | None] = [] + + def run() -> None: + try: + write(SingleFileMessagePackBackingStore(self.path, lock_timeout=10)) + outcome.append(None) + except BaseException as exc: # noqa: BLE001 - the thread hands the outcome to the test + outcome.append(exc) + + writer = threading.Thread(target=run) + writer.start() + time.sleep(0.3) + os.symlink(target, self.path) # after the writer's pre-lock look, while it waits + writer.join() + holder.wait() + self.assertEqual(1, len(outcome)) + self.assertIsInstance(outcome[0], StoreLinkedError) + self.assertTrue(os.path.islink(self.path), "the link was replaced") + self.assertEqual(before, target.read_bytes()) + with store_write_lock(target, timeout=0.2): # the lock is free + pass + def test_the_lock_is_released_after_each_write(self) -> None: store = SingleFileMessagePackBackingStore(self.path, lock_timeout=0.2) for key in "abc": diff --git a/packages/cultcache-rs/CHANGELOG.md b/packages/cultcache-rs/CHANGELOG.md index 3978e248..327f42aa 100644 --- a/packages/cultcache-rs/CHANGELOG.md +++ b/packages/cultcache-rs/CHANGELOG.md @@ -8,6 +8,7 @@ only in git history. ### Changed +- The single-file store re-checks for a link once the store lock is held (every write and the try-exchange), so a link put at the path while a writer waited on the lock is refused with `StoreLinkedError` and the lock released, as C# does, instead of being replaced. Pinned by a test that puts the link while a second holder owns the lock. - `CultCache::is_writable` does not look at store paths: a link at a store path is not predicted, and the write refuses it with `StoreLinkedError`. Pinned by a test through a cache attached to a linked store. ### Breaking diff --git a/packages/cultcache-rs/src/lib.rs b/packages/cultcache-rs/src/lib.rs index edfc0c91..9bd88fb5 100644 --- a/packages/cultcache-rs/src/lib.rs +++ b/packages/cultcache-rs/src/lib.rs @@ -1752,7 +1752,8 @@ impl SingleFileMessagePackBackingStore { let lock = self.open_lock_file()?; fs2::FileExt::lock_exclusive(&lock) .with_context(|| format!("failed to lock {}", self.lock_path().display()))?; - release_lock(lock, action()) + // A link may have been put at the path while the lock was awaited; the refusal above only saw the path before. + release_lock(lock, refuse_linked(&self.path).and_then(|()| action())) } /// Replaces the named entries only if each still matches its expectation. @@ -1870,7 +1871,7 @@ impl SingleFileMessagePackBackingStore { .with_context(|| format!("failed to lock {}", self.lock_path().display())); } } - release_lock(lock, action()).map(Some) + release_lock(lock, refuse_linked(&self.path).and_then(|()| action())).map(Some) } fn open_lock_file(&self) -> Result { @@ -8201,6 +8202,44 @@ mod tests { // A write at a store path that is a link is refused: a replace there would swap the link for a regular file and // detach the store from its target. Reads through a live link keep working. + // A link put at the store path while a writer waits on the lock is refused once the lock is taken, as C# does, and the + // lock is free afterwards. + #[cfg(unix)] + #[test] + fn a_link_put_while_a_writer_waits_on_the_lock_is_refused_once_it_holds_it() -> Result<()> { + let temp = tempfile::tempdir()?; + let volume = temp.path().join("volume"); + fs::create_dir(&volume)?; + let target = volume.join("store.cc"); + SingleFileMessagePackBackingStore::new(&target).push(&snapshot_envelope("a", b"1"))?; + let before = fs::read(&target)?; + let path = temp.path().join("late.cc"); + let writers: [(&str, fn(&mut SingleFileMessagePackBackingStore) -> Result<()>); 4] = [ + ("push", |s| s.push(&snapshot_envelope("b", b"2"))), + ("push_all", |s| s.push_all(&[snapshot_envelope("b", b"2")], PushAllOptions::default())), + ("delete", |s| s.delete(&snapshot_envelope("a", b"1"))), + ("compare_exchange_snapshot", |s| s.compare_exchange_snapshot(&[], &[snapshot_envelope("b", b"2")]).map(|_| ())), + ]; + for (name, write) in writers { + let _ = fs::remove_file(&path); + let store = SingleFileMessagePackBackingStore::new(&path); + let holder = store.open_lock_file()?; + fs2::FileExt::lock_exclusive(&holder)?; + let mut waiting = SingleFileMessagePackBackingStore::new(&path); + let handle = std::thread::spawn(move || write(&mut waiting)); + std::thread::sleep(std::time::Duration::from_millis(300)); + std::os::unix::fs::symlink(&target, &path)?; + fs2::FileExt::unlock(&holder)?; + let error = handle.join().expect("writer thread").expect_err("the late link must be refused"); + assert!(error.downcast_ref::().is_some(), "{name}: {error:#}"); + assert!(fs::symlink_metadata(&path)?.file_type().is_symlink(), "{name}: the link was replaced"); + assert_eq!(fs::read(&target)?, before, "{name}: the target changed"); + let probe = store.open_lock_file()?; + fs2::FileExt::try_lock_exclusive(&probe).map_err(|e| anyhow!("{name}: lock still held: {e}"))?; + } + Ok(()) + } + #[cfg(unix)] #[test] fn a_write_at_a_live_link_is_refused_and_nothing_changes_while_a_read_through_it_works() -> Result<()> { diff --git a/packages/cultcache-ts/CHANGELOG.md b/packages/cultcache-ts/CHANGELOG.md index 5583b45c..c41071a7 100644 --- a/packages/cultcache-ts/CHANGELOG.md +++ b/packages/cultcache-ts/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to `@gamecult/cultcache-ts` are recorded here, following ### Changed +- `withStoreWriteLock` looks for a link at the store path again once the lock is held, so a link put at the path while a writer waited on the lock is refused with `StoreLinkedError` and the lock released, instead of being replaced. Pinned by a test that puts the link while another holder owns the lock. - `CultCache.isWritable` does not look at store paths: a link at a store path is not predicted, and the write refuses it with `StoreLinkedError`. Pinned by a test through a cache attached to a linked store. ### Breaking diff --git a/packages/cultcache-ts/src/store-file.ts b/packages/cultcache-ts/src/store-file.ts index e6988d23..0488960e 100644 --- a/packages/cultcache-ts/src/store-file.ts +++ b/packages/cultcache-ts/src/store-file.ts @@ -218,6 +218,8 @@ export async function withStoreWriteLock( } try { + // A link may have been put at the path while the lock was awaited; the look above only saw the path before. + await refuseLinkedStore(storePath); return await operation(); } finally { release(); diff --git a/packages/cultcache-ts/test/store-file.test.ts b/packages/cultcache-ts/test/store-file.test.ts index 485df058..13e1956a 100644 --- a/packages/cultcache-ts/test/store-file.test.ts +++ b/packages/cultcache-ts/test/store-file.test.ts @@ -417,6 +417,41 @@ test("a second writer in this process waits for the store lock and runs after re } }); +test("a link put while a writer waits on the store lock is refused once it holds the lock", { skip: process.platform === "win32" }, async () => { + const directory = await scratch(); + try { + const target = join(directory, "target.cc"); + await new SingleFileMessagePackBackingStore(target).push(noteEnvelope("a")); + const before = await readFile(target); + const writes: Array<[string, (store: SingleFileMessagePackBackingStore) => Promise]> = [ + ["push", (store) => store.push(noteEnvelope("b"))], + ["pushAll", (store) => store.pushAll([noteEnvelope("b")])], + ["delete", (store) => store.delete(noteEnvelope("a"))], + ]; + for (const [name, write] of writes) { + const link = join(directory, `${name}.cc`); + let writer: Promise | undefined; + await withStoreWriteLock(link, async () => { + writer = write(new SingleFileMessagePackBackingStore(link)); + writer.catch(() => undefined); + await sleepFor(150); + await fsp.symlink(target, link); // after the writer's pre-lock look, while it waits + }); + await assert.rejects(writer!, (error: unknown) => error instanceof PackageStoreLinkedError, name); + assert.ok((await fsp.lstat(link)).isSymbolicLink(), `${name}: the link was replaced`); + assert.deepEqual(await readFile(target), before, `${name}: the target changed`); + await rm(link); // the link is refused before the lock, so remove it to probe the lock + assert.equal(await withStoreWriteLock(link, async () => "free", { timeoutMs: 200 }), "free", `${name}: the lock is held`); + } + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +function noteEnvelope(key: string): CultCacheEnvelope { + return { key, type: "note", payload: documentPayloadFromBytes(Uint8Array.of(0x91, 1)), storedAt: "2026-10-20T00:00:00Z" }; +} + test("the store lock gives up after its wait bound and names the lock path", async () => { const directory = await scratch(); try { From 5241b8aa98b78a0c76733373f18555f4a3734cae Mon Sep 17 00:00:00 2001 From: Libby Date: Fri, 13 Nov 2026 05:25:53 +0000 Subject: [PATCH 02/16] Late-link follow-up: Python probes the writer's own lock, Rust shares one locked tail, dangling-link cases, doc comment restored Co-Authored-By: Claude Sonnet 5.5 --- .../cultcache-py/tests/test_store_lock.py | 11 ++++++---- packages/cultcache-rs/src/lib.rs | 21 ++++++++++++------- packages/cultcache-ts/test/store-file.test.ts | 8 ++++--- 3 files changed, 26 insertions(+), 14 deletions(-) diff --git a/packages/cultcache-py/tests/test_store_lock.py b/packages/cultcache-py/tests/test_store_lock.py index 0122f094..50b95436 100644 --- a/packages/cultcache-py/tests/test_store_lock.py +++ b/packages/cultcache-py/tests/test_store_lock.py @@ -85,8 +85,9 @@ class StoreLockTests(unittest.TestCase): "push_all": lambda store: store.push_all([envelope("b")]), "delete": lambda store: store.delete("t", "a"), } - for name, write in writes.items(): - with self.subTest(name): + missing = self.dir / "volume" / "missing.cc" + for (name, write), link_to in [(item, to) for item in writes.items() for to in (target, missing)]: + with self.subTest(name, dangling=link_to == missing): if os.path.lexists(self.path): os.unlink(self.path) holder = start_holder([sys.executable, "-c", PY_HOLDER, str(self.path), str(HOLD_SECONDS)]) @@ -104,14 +105,16 @@ class StoreLockTests(unittest.TestCase): writer = threading.Thread(target=run) writer.start() time.sleep(0.3) - os.symlink(target, self.path) # after the writer's pre-lock look, while it waits + os.symlink(link_to, self.path) # after the writer's pre-lock look, while it waits writer.join() holder.wait() self.assertEqual(1, len(outcome)) self.assertIsInstance(outcome[0], StoreLinkedError) self.assertTrue(os.path.islink(self.path), "the link was replaced") self.assertEqual(before, target.read_bytes()) - with store_write_lock(target, timeout=0.2): # the lock is free + self.assertFalse(missing.exists(), "the dangling target was created") + os.unlink(self.path) # a link is refused before the lock, so remove it to probe the writer's own lock file + with store_write_lock(self.path, timeout=0.2): # the lock is free pass def test_the_lock_is_released_after_each_write(self) -> None: diff --git a/packages/cultcache-rs/src/lib.rs b/packages/cultcache-rs/src/lib.rs index 9bd88fb5..9ba800e9 100644 --- a/packages/cultcache-rs/src/lib.rs +++ b/packages/cultcache-rs/src/lib.rs @@ -1752,8 +1752,7 @@ impl SingleFileMessagePackBackingStore { let lock = self.open_lock_file()?; fs2::FileExt::lock_exclusive(&lock) .with_context(|| format!("failed to lock {}", self.lock_path().display()))?; - // A link may have been put at the path while the lock was awaited; the refusal above only saw the path before. - release_lock(lock, refuse_linked(&self.path).and_then(|()| action())) + self.run_locked(lock, action) } /// Replaces the named entries only if each still matches its expectation. @@ -1871,7 +1870,13 @@ impl SingleFileMessagePackBackingStore { .with_context(|| format!("failed to lock {}", self.lock_path().display())); } } - release_lock(lock, refuse_linked(&self.path).and_then(|()| action())).map(Some) + self.run_locked(lock, action).map(Some) + } + + /// The one tail of both exclusive entries: with the lock held, look for a link again (one may have been put at the + /// path while the lock was awaited; the look before the lock saw the path earlier), run the action, release. + fn run_locked(&self, lock: File, action: impl FnOnce() -> Result) -> Result { + release_lock(lock, refuse_linked(&self.path).and_then(|()| action())) } fn open_lock_file(&self) -> Result { @@ -8200,8 +8205,6 @@ mod tests { Ok(()) } - // A write at a store path that is a link is refused: a replace there would swap the link for a regular file and - // detach the store from its target. Reads through a live link keep working. // A link put at the store path while a writer waits on the lock is refused once the lock is taken, as C# does, and the // lock is free afterwards. #[cfg(unix)] @@ -8213,6 +8216,7 @@ mod tests { let target = volume.join("store.cc"); SingleFileMessagePackBackingStore::new(&target).push(&snapshot_envelope("a", b"1"))?; let before = fs::read(&target)?; + let dangling = volume.join("missing.cc"); let path = temp.path().join("late.cc"); let writers: [(&str, fn(&mut SingleFileMessagePackBackingStore) -> Result<()>); 4] = [ ("push", |s| s.push(&snapshot_envelope("b", b"2"))), @@ -8220,7 +8224,7 @@ mod tests { ("delete", |s| s.delete(&snapshot_envelope("a", b"1"))), ("compare_exchange_snapshot", |s| s.compare_exchange_snapshot(&[], &[snapshot_envelope("b", b"2")]).map(|_| ())), ]; - for (name, write) in writers { + for (name, write, link_to) in writers.into_iter().flat_map(|(n, w)| [(n, w, &target), (n, w, &dangling)]) { let _ = fs::remove_file(&path); let store = SingleFileMessagePackBackingStore::new(&path); let holder = store.open_lock_file()?; @@ -8228,18 +8232,21 @@ mod tests { let mut waiting = SingleFileMessagePackBackingStore::new(&path); let handle = std::thread::spawn(move || write(&mut waiting)); std::thread::sleep(std::time::Duration::from_millis(300)); - std::os::unix::fs::symlink(&target, &path)?; + std::os::unix::fs::symlink(link_to, &path)?; fs2::FileExt::unlock(&holder)?; let error = handle.join().expect("writer thread").expect_err("the late link must be refused"); assert!(error.downcast_ref::().is_some(), "{name}: {error:#}"); assert!(fs::symlink_metadata(&path)?.file_type().is_symlink(), "{name}: the link was replaced"); assert_eq!(fs::read(&target)?, before, "{name}: the target changed"); + assert!(!dangling.exists(), "{name}: the dangling target was created"); let probe = store.open_lock_file()?; fs2::FileExt::try_lock_exclusive(&probe).map_err(|e| anyhow!("{name}: lock still held: {e}"))?; } Ok(()) } + // A write at a store path that is a link is refused: a replace there would swap the link for a regular file and + // detach the store from its target. Reads through a live link keep working. #[cfg(unix)] #[test] fn a_write_at_a_live_link_is_refused_and_nothing_changes_while_a_read_through_it_works() -> Result<()> { diff --git a/packages/cultcache-ts/test/store-file.test.ts b/packages/cultcache-ts/test/store-file.test.ts index 13e1956a..7ddf7fe3 100644 --- a/packages/cultcache-ts/test/store-file.test.ts +++ b/packages/cultcache-ts/test/store-file.test.ts @@ -428,18 +428,20 @@ test("a link put while a writer waits on the store lock is refused once it holds ["pushAll", (store) => store.pushAll([noteEnvelope("b")])], ["delete", (store) => store.delete(noteEnvelope("a"))], ]; - for (const [name, write] of writes) { - const link = join(directory, `${name}.cc`); + const missing = join(directory, "missing.cc"); + for (const [name, write, linkTo] of writes.flatMap(([n, w]) => [[n, w, target], [`${n} (dangling)`, w, missing]] as const)) { + const link = join(directory, `${name.replace(/\W+/g, "-")}.cc`); let writer: Promise | undefined; await withStoreWriteLock(link, async () => { writer = write(new SingleFileMessagePackBackingStore(link)); writer.catch(() => undefined); await sleepFor(150); - await fsp.symlink(target, link); // after the writer's pre-lock look, while it waits + await fsp.symlink(linkTo, link); // after the writer's pre-lock look, while it waits }); await assert.rejects(writer!, (error: unknown) => error instanceof PackageStoreLinkedError, name); assert.ok((await fsp.lstat(link)).isSymbolicLink(), `${name}: the link was replaced`); assert.deepEqual(await readFile(target), before, `${name}: the target changed`); + await assert.rejects(fsp.lstat(missing), { code: "ENOENT" }, `${name}: the dangling target was created`); await rm(link); // the link is refused before the lock, so remove it to probe the lock assert.equal(await withStoreWriteLock(link, async () => "free", { timeoutMs: 200 }), "free", `${name}: the lock is held`); } From d6708631dadc6b85f3a375ab307ba12fcce04c90 Mon Sep 17 00:00:00 2001 From: Libby Date: Fri, 13 Nov 2026 05:26:13 +0000 Subject: [PATCH 03/16] cultcache-rs and cultnet-rs commit Cargo.lock; CI builds them --locked (#157) --- .github/workflows/cultmesh-portability.yml | 2 +- .github/workflows/cultnet-interop.yml | 2 +- packages/cultcache-rs/.gitignore | 1 - packages/cultcache-rs/Cargo.lock | 730 ++++++++++++ packages/cultnet-rs/.gitignore | 1 - packages/cultnet-rs/Cargo.lock | 1227 ++++++++++++++++++++ 6 files changed, 1959 insertions(+), 4 deletions(-) create mode 100644 packages/cultcache-rs/Cargo.lock create mode 100644 packages/cultnet-rs/Cargo.lock diff --git a/.github/workflows/cultmesh-portability.yml b/.github/workflows/cultmesh-portability.yml index 0b8aca1d..bafe70fc 100644 --- a/.github/workflows/cultmesh-portability.yml +++ b/.github/workflows/cultmesh-portability.yml @@ -158,7 +158,7 @@ jobs: # (only the interop example built, inside the C# store tests). The target directory is under RUNNER_TEMP, away from the checkout. - name: Verify the CultCache document owner with the Rust cache shell: bash - run: CARGO_TARGET_DIR="$RUNNER_TEMP/cultcache-rs-target" cargo test --manifest-path packages/cultcache-rs/Cargo.toml + run: CARGO_TARGET_DIR="$RUNNER_TEMP/cultcache-rs-target" cargo test --locked --manifest-path packages/cultcache-rs/Cargo.toml # tests/GameCult.Caching.Tests, the C# reference cache's own suite (about 1,260 tests, under a minute), ran in no # forge workflow; only the Mesh tests above exercised the reference. diff --git a/.github/workflows/cultnet-interop.yml b/.github/workflows/cultnet-interop.yml index e053e123..750edc9a 100644 --- a/.github/workflows/cultnet-interop.yml +++ b/.github/workflows/cultnet-interop.yml @@ -135,7 +135,7 @@ jobs: python -m unittest discover -s packages/cultmesh-py/tests - name: Verify Rust RUDP reliability - run: cargo test --manifest-path packages/cultnet-rs/Cargo.toml rudp + run: cargo test --locked --manifest-path packages/cultnet-rs/Cargo.toml rudp - name: Verify Rust CultMesh runtime-local surface run: cargo test --manifest-path packages/cultmesh-rs/Cargo.toml diff --git a/packages/cultcache-rs/.gitignore b/packages/cultcache-rs/.gitignore index 96ef6c0b..ea8c4bf7 100644 --- a/packages/cultcache-rs/.gitignore +++ b/packages/cultcache-rs/.gitignore @@ -1,2 +1 @@ /target -Cargo.lock diff --git a/packages/cultcache-rs/Cargo.lock b/packages/cultcache-rs/Cargo.lock new file mode 100644 index 00000000..40d9e1e8 --- /dev/null +++ b/packages/cultcache-rs/Cargo.lock @@ -0,0 +1,730 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "ar_archive_writer" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "523e5e187fff36fcb8d1d19ccf0ca4ca43ec3d158316057f66dc6f72716bc5ec" +dependencies = [ + "object", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "cc" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ff2a8126fff0f8c839080d619675a585e61720bb12d2baceae45769fd66f155" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cultcache-rs" +version = "0.3.1" +dependencies = [ + "anyhow", + "chrono", + "cultcache-rs-derive", + "fs2", + "pretty_assertions", + "redb", + "rmp-serde", + "serde", + "serde_bytes", + "serde_json", + "serde_stacker", + "stacker", + "tempfile", + "uuid", + "windows-sys 0.59.0", +] + +[[package]] +name = "cultcache-rs-derive" +version = "0.2.0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + +[[package]] +name = "errno" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35d6e78f4cebb90e51d543db8fe41528851d6deeef6ef21ebd4bfdc1e784776" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" + +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "itoa" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bd1574e617a9a6c1f8f8181bd7e841e1f20e4277607ef28945765188d6284d4" + +[[package]] +name = "js-sys" +version = "0.3.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8dc09d2154098bbf4ef54eab6b117a3c24cbbffe94046b16ee6d6d4510584d3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "object" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e5a6c098c7a3b6547378093f5cc30bc54fd361ce711e05293a5cc589562739b" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "psm" +version = "0.1.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dcd034599e63b970727f70d79e02d62390a4a84f7c6b827c27c46d5ac3fa622" +dependencies = [ + "ar_archive_writer", + "cc", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "redb" +version = "4.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b317ef681e18f6091efbfab532eaf924a135c492d9d0299d0fbfae07e92a39b" +dependencies = [ + "libc", +] + +[[package]] +name = "rmp" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "rmp-serde" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" +dependencies = [ + "rmp", + "serde", +] + +[[package]] +name = "rustix" +version = "1.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.10", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_stacker" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4936375d50c4be7eff22293a9344f8e46f323ed2b3c243e52f89138d9bb0f4a" +dependencies = [ + "serde", + "serde_core", + "stacker", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "stacker" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707f49d46706bacf8a2b00d51dace3f9de527c13eec3778f570c411f89e69967" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys 0.61.2", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ae9ccbea3a7b0b09443036490c8f34a8b1b760fd345e5c0dae0aac718faea79" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "uuid" +version = "1.29.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bed99139f7dac9b5cfe5f17be4d66dc7079a34dee1d74bcc77217119240e267c" +dependencies = [ + "getrandom", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0181c14ddd6c7b8b0b0132a5f1ea2f32be0d893cd3ea97b6e3b28886b8a3d1c" +dependencies = [ + "cfg-if", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e931a02a39abdbf66966fc35902b5ed58344750a70885bed4ebb3d8eaf28ed9" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ca4b54280e455bd831e33a6b8e734baf432fe63fbc35c42ac14ce67a043a53a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.10", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b1f9c3798725016c6ee5f2f57869ec0831e8bbc0ab0310a86e787e97f41389e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/packages/cultnet-rs/.gitignore b/packages/cultnet-rs/.gitignore index 96ef6c0b..ea8c4bf7 100644 --- a/packages/cultnet-rs/.gitignore +++ b/packages/cultnet-rs/.gitignore @@ -1,2 +1 @@ /target -Cargo.lock diff --git a/packages/cultnet-rs/Cargo.lock b/packages/cultnet-rs/Cargo.lock new file mode 100644 index 00000000..ed0aab43 --- /dev/null +++ b/packages/cultnet-rs/Cargo.lock @@ -0,0 +1,1227 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "ar_archive_writer" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "523e5e187fff36fcb8d1d19ccf0ca4ca43ec3d158316057f66dc6f72716bc5ec" +dependencies = [ + "object", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "cc" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ff2a8126fff0f8c839080d619675a585e61720bb12d2baceae45769fd66f155" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "cultcache-rs" +version = "0.3.1" +dependencies = [ + "anyhow", + "chrono", + "cultcache-rs-derive", + "fs2", + "redb", + "rmp-serde", + "serde", + "serde_bytes", + "serde_json", + "serde_stacker", + "stacker", + "uuid", + "windows-sys 0.59.0", +] + +[[package]] +name = "cultcache-rs-derive" +version = "0.2.0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "cultnet-rs" +version = "0.3.0" +dependencies = [ + "aes-gcm", + "anyhow", + "base64", + "chrono", + "cultcache-rs", + "ed25519-dalek", + "fs2", + "hmac", + "libc", + "pretty_assertions", + "rand", + "rmp-serde", + "rmpv", + "serde", + "serde_bytes", + "serde_json", + "sha2", + "socket2", + "tempfile", + "uuid", + "windows-sys 0.61.2", + "zeroize", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "errno" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35d6e78f4cebb90e51d543db8fe41528851d6deeef6ef21ebd4bfdc1e784776" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "find-msvc-tools" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" + +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "itoa" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bd1574e617a9a6c1f8f8181bd7e841e1f20e4277607ef28945765188d6284d4" + +[[package]] +name = "js-sys" +version = "0.3.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8dc09d2154098bbf4ef54eab6b117a3c24cbbffe94046b16ee6d6d4510584d3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "object" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e5a6c098c7a3b6547378093f5cc30bc54fd361ce711e05293a5cc589562739b" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "psm" +version = "0.1.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dcd034599e63b970727f70d79e02d62390a4a84f7c6b827c27c46d5ac3fa622" +dependencies = [ + "ar_archive_writer", + "cc", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "redb" +version = "4.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b317ef681e18f6091efbfab532eaf924a135c492d9d0299d0fbfae07e92a39b" +dependencies = [ + "libc", +] + +[[package]] +name = "rmp" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "rmp-serde" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" +dependencies = [ + "rmp", + "serde", +] + +[[package]] +name = "rmpv" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a4e1d4b9b938a26d2996af33229f0ca0956c652c1375067f0b45291c1df8417" +dependencies = [ + "rmp", + "serde", + "serde_bytes", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.10", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_stacker" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4936375d50c4be7eff22293a9344f8e46f323ed2b3c243e52f89138d9bb0f4a" +dependencies = [ + "serde", + "serde_core", + "stacker", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stacker" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707f49d46706bacf8a2b00d51dace3f9de527c13eec3778f570c411f89e69967" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys 0.61.2", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ae9ccbea3a7b0b09443036490c8f34a8b1b760fd345e5c0dae0aac718faea79" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "uuid" +version = "1.29.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bed99139f7dac9b5cfe5f17be4d66dc7079a34dee1d74bcc77217119240e267c" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0181c14ddd6c7b8b0b0132a5f1ea2f32be0d893cd3ea97b6e3b28886b8a3d1c" +dependencies = [ + "cfg-if", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e931a02a39abdbf66966fc35902b5ed58344750a70885bed4ebb3d8eaf28ed9" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ca4b54280e455bd831e33a6b8e734baf432fe63fbc35c42ac14ce67a043a53a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.10", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b1f9c3798725016c6ee5f2f57869ec0831e8bbc0ab0310a86e787e97f41389e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + +[[package]] +name = "zerocopy" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" From d7e94648ac27f216bd09214096c6ba64c00508b9 Mon Sep 17 00:00:00 2001 From: Libby Date: Fri, 13 Nov 2026 05:43:49 +0000 Subject: [PATCH 04/16] Python: the BackingStore protocol can say absent, so the default soft push_all decides like the real stores (py-protocol-pull-absent) (#158) --- packages/cultcache-py/CHANGELOG.md | 2 +- .../src/cultcache_py/backing_store.py | 15 ++++-- .../cultcache-py/src/cultcache_py/stores.py | 6 +++ .../cultcache-py/tests/test_soft_push_all.py | 48 +++++++++++++++---- 4 files changed, 58 insertions(+), 13 deletions(-) diff --git a/packages/cultcache-py/CHANGELOG.md b/packages/cultcache-py/CHANGELOG.md index e5a8b8e8..36a7d47e 100644 --- a/packages/cultcache-py/CHANGELOG.md +++ b/packages/cultcache-py/CHANGELOG.md @@ -16,7 +16,7 @@ ### Breaking -- `push_all(envelopes, soft=False)` on the single-file and JSONL stores and on the `BackingStore` protocol takes the soft flag, as TypeScript, Rust and C# do: a soft push writes only where nothing is at the path; over a store the reader reads, empty included, it returns without writing, and over a file the reader cannot read it refuses (`StoreUnreadableError` for the single-file store). The `BackingStore` protocol's default `push_all` cannot tell an absent store from an empty one (a pull returns a list), so a soft push through it always skips a store whose pull returns; a store kind that can tell nothing-at-the-path overrides `push_all`. A store kind that overrides `push_all(envelopes)` without the keyword fails when a caller passes `soft`. +- `push_all(envelopes, soft=False)` on the single-file and JSONL stores and on the `BackingStore` protocol takes the soft flag, as TypeScript, Rust and C# do: a soft push writes only where nothing is at the path; over a store the reader reads, empty included, it returns without writing, and over a file the reader cannot read it refuses (`StoreUnreadableError` for the single-file store). The `BackingStore` protocol has `pull_or_absent()`: the envelopes the reader reads, `None` when nothing is at the path, a raise when the path cannot be read; its default is `pull_all()`, which cannot say absent, so a store that inherits it is always read. The default `push_all` decides soft by it (writes where it says absent, skips over a store it read, refuses where it raises), and the single-file and JSONL stores expose their pull through it; an inheriting store that can tell nothing-at-the-path overrides `pull_or_absent`, not `push_all`. Before, a soft push through the default always skipped. A store kind that overrides `push_all(envelopes)` without the keyword fails when a caller passes `soft`. - A write at a store path that is a link, live or dangling, is refused with the new `StoreLinkedError` (an `OSError`, exported from `cultcache_py`; `.path` names the path) before the lock and the replace, for the single-file store and for `JsonLinesBackingStore` (`push`, `push_all`, `delete`), and nothing is written. Before, a write at a live link replaced the link with a regular file, and at a dangling link both stores raised `FileNotFoundError`. A caller catching `FileNotFoundError` there now catches `StoreLinkedError` (`OSError`); configure the link's target as the store path. On Windows a junction or any reparse point counts as a link. Reads through a live link work. - A record whose payload carries a slot past its reader's declared slots, at any depth (the document, a nested record, a list element, a union arm, a unit arm), is read, held read-only, and every write or removal of its key (`put`, `put_global`, `update`, `put_envelope`, `put_envelopes`, `delete`, `delete_global`) is refused with `SchemaConflictError` before any store is touched (`cultlib-gaps:ruling:extra-trailing-slots`). Reading dropped those slots silently and the next write shed them. `SchemaConflictError.unread_slots` is an `UnreadSlots(type_name, slot_count, declared_count, occurrences, depth0_only)` and the message names the type and both counts, never a slot value. A depth-0 mark is consented, and the record writable, only when the record is stored under an id the document lists in `compatible_schema_ids` and that id's catalog entry has slots past the document's, covering the mark's slot count (`cultlib-gaps:ruling:slot-mark-consent`); a nested mark, or a mark under the document's own id, keeps the record read-only whatever is declared. Missing trailing slots keep their declared defaults. An untouched marked record keeps its stored bytes through every other write. Added `CultCache.is_writable(document, key)` (advisory; the write still decides: it also answers False for a foreign-carried key, for a key held as a type the document does not own, and for a global document at a non-global key; `delete` asks the same judges, so removing a key held as another type is refused with `SchemaConflictError` unless the document owns that record's schema id (its own or one it lists compatible), in which case the removal removes that record, its file row and its lookups, as a write of the key would replace it; it returned silently and removed nothing), `DocumentDefinition.decode_payload_marked`, and `UnreadSlots`. A document with a custom `decode` (no slot codec) never marks. A store holding a variant record is still unreadable here. Implies a minor bump while the version is 0.x (0.3.0 to 0.4.0): code that wrote back a record from a newer writer now gets `SchemaConflictError`. - A payload is one MessagePack value: `DocumentDefinition.decode_payload` refuses bytes after it, by count, with `ValueError` ` payload has N bytes after its first MessagePack value (refused on read).` (never the bytes, never the depth refusal; a payload that ends early stays the decoder's), as the C# reference does (`tests/vectors/union-parity/bytes-after-outcomes.msgpack`). It raised `msgpack.ExtraData` (a `ValueError`, so only a caller naming `msgpack.ExtraData` is affected); this is a minor-version bump while pre-1.0. The refused set is unchanged. Cache loads, `get` and every definition built on the MessagePack decoder go through it; a user `payload_decoder` that raises `ExtraData` is refused the same way. Depth is judged first, so a value nested past 256 with a tail is the depth refusal, as in C#. diff --git a/packages/cultcache-py/src/cultcache_py/backing_store.py b/packages/cultcache-py/src/cultcache_py/backing_store.py index 7b07e7be..bded4b11 100644 --- a/packages/cultcache-py/src/cultcache_py/backing_store.py +++ b/packages/cultcache-py/src/cultcache_py/backing_store.py @@ -67,12 +67,19 @@ class BackingStore(Protocol): def delete(self, type: str, key: str) -> None: ... + def pull_or_absent(self) -> list[CultCacheEnvelope] | None: + """The envelopes the reader reads (empty included), None when nothing is at the path; raises when the path cannot be read. + + The default is pull_all, which cannot say absent: a store that inherits it is always a store the reader read. A store kind + that can tell nothing-at-the-path overrides this, and the default push_all then writes where nothing is on a soft push.""" + return self.pull_all() + def push_all(self, envelopes: list[CultCacheEnvelope], soft: bool = False) -> None: - # A pull that returns is a store the reader read, empty included: the protocol cannot say "absent", so a soft push - # through this default always skips. A store that can tell nothing-at-the-path overrides push_all. - pulled = self.pull_all() - if soft: + # Soft: write only where pull_or_absent says nothing is; skip over a store it read, empty included; its raise refuses. + pulled = self.pull_or_absent() + if soft and pulled is not None: return + pulled = pulled or [] existing = {(envelope.type, envelope.key): envelope for envelope in pulled} for envelope in envelopes: existing[(envelope.type, envelope.key)] = envelope diff --git a/packages/cultcache-py/src/cultcache_py/stores.py b/packages/cultcache-py/src/cultcache_py/stores.py index 36729d44..b6b848bc 100644 --- a/packages/cultcache-py/src/cultcache_py/stores.py +++ b/packages/cultcache-py/src/cultcache_py/stores.py @@ -78,6 +78,9 @@ class JsonLinesBackingStore: def pull_all(self) -> list[CultCacheEnvelope]: return self._pull() or [] + def pull_or_absent(self) -> list[CultCacheEnvelope] | None: + return self._pull() + def _pull(self) -> list[CultCacheEnvelope] | None: """The envelopes the reader reads, or None when nothing is at the path.""" with self._lock: @@ -152,6 +155,9 @@ class SingleFileMessagePackBackingStore: def pull_all(self) -> list[CultCacheEnvelope]: return self._pull() or [] + def pull_or_absent(self) -> list[CultCacheEnvelope] | None: + return self._pull() + def _pull(self) -> list[CultCacheEnvelope] | None: """The envelopes the reader reads, or None when nothing is at the path.""" with self._lock: diff --git a/packages/cultcache-py/tests/test_soft_push_all.py b/packages/cultcache-py/tests/test_soft_push_all.py index 9a2c6e03..d30483a3 100644 --- a/packages/cultcache-py/tests/test_soft_push_all.py +++ b/packages/cultcache-py/tests/test_soft_push_all.py @@ -69,8 +69,33 @@ class SoftPushAllTests(unittest.TestCase): JsonLinesBackingStore(self.path).push_all([envelope("a")], soft=True) self.assertEqual(junk, self.path.read_bytes()) - def test_the_protocol_default_push_all_skips_what_its_pull_read_empty_included(self) -> None: + def test_the_protocol_default_push_all_decides_soft_by_what_pull_or_absent_says(self) -> None: class Memory(BackingStore): + def __init__(self) -> None: + self.items: list[CultCacheEnvelope] | None = None # None: nothing at the path + + def pull_all(self) -> list[CultCacheEnvelope]: + return list(self.items or []) + + def pull_or_absent(self) -> list[CultCacheEnvelope] | None: + return None if self.items is None else list(self.items) + + def _replace_all(self, envelopes: list[CultCacheEnvelope]) -> None: + self.items = list(envelopes) + + store = Memory() + store.push_all([envelope("a")], soft=True) # nothing at the path: writes + self.assertEqual(["a"], [item.key for item in store.items or []]) + store.push_all([envelope("b")], soft=True) # a store with records: skips + self.assertEqual(["a"], [item.key for item in store.items or []]) + store.items = [] + store.push_all([envelope("b")], soft=True) # a store the reader read, empty: skips + self.assertEqual([], store.items) + store.push_all([envelope("b")]) # plain: lands + self.assertEqual(["b"], [item.key for item in store.items]) + + def test_a_store_that_only_pulls_all_is_read_so_a_soft_push_skips_it(self) -> None: + class PullOnly(BackingStore): def __init__(self) -> None: self.items: list[CultCacheEnvelope] = [] @@ -80,14 +105,18 @@ class SoftPushAllTests(unittest.TestCase): def _replace_all(self, envelopes: list[CultCacheEnvelope]) -> None: self.items = list(envelopes) - store = Memory() - store.push_all([envelope("a")], soft=True) # the pull read an empty store: skips + store = PullOnly() + store.push_all([envelope("a")], soft=True) self.assertEqual([], store.items) - store.push_all([envelope("a")]) # plain: lands - store.push_all([envelope("b")], soft=True) # a store with records: skips - self.assertEqual(["a"], [item.key for item in store.items]) - store.push_all([envelope("b")]) - self.assertEqual(["a", "b"], sorted(item.key for item in store.items)) + + def test_the_single_file_and_jsonl_stores_say_absent_and_read_through_pull_or_absent(self) -> None: + for make in (SingleFileMessagePackBackingStore, JsonLinesBackingStore): + with self.subTest(make.__name__): + path = self.dir / make.__name__ + store = make(path) + self.assertIsNone(store.pull_or_absent()) + store.push_all([envelope("a")]) + self.assertEqual(["a"], [item.key for item in store.pull_or_absent() or []]) def test_the_protocol_default_push_all_refuses_where_its_pull_refuses(self) -> None: class Unreadable(BackingStore): @@ -96,6 +125,9 @@ class SoftPushAllTests(unittest.TestCase): def pull_all(self) -> list[CultCacheEnvelope]: raise StoreUnreadableError(Path("x"), ValueError("junk")) + def pull_or_absent(self) -> list[CultCacheEnvelope] | None: + raise StoreUnreadableError(Path("x"), ValueError("junk")) + def _replace_all(self, envelopes: list[CultCacheEnvelope]) -> None: Unreadable.replaced = True From 694d2ced1061137c65e6bc7dc8aacc01b9a30e04 Mon Sep 17 00:00:00 2001 From: Libby Date: Fri, 13 Nov 2026 06:12:54 +0000 Subject: [PATCH 05/16] Rust, Python and TypeScript stores re-check for a link once the store lock is held (link-late-check-rs-py-ts) (#159) --- packages/cultcache-py/CHANGELOG.md | 1 + .../cultcache-py/src/cultcache_py/stores.py | 2 + .../cultcache-py/tests/test_store_lock.py | 44 ++++++++++++++++ packages/cultcache-rs/CHANGELOG.md | 1 + packages/cultcache-rs/src/lib.rs | 50 ++++++++++++++++++- packages/cultcache-ts/CHANGELOG.md | 1 + packages/cultcache-ts/src/store-file.ts | 2 + packages/cultcache-ts/test/store-file.test.ts | 37 ++++++++++++++ 8 files changed, 136 insertions(+), 2 deletions(-) diff --git a/packages/cultcache-py/CHANGELOG.md b/packages/cultcache-py/CHANGELOG.md index 36a7d47e..77324c91 100644 --- a/packages/cultcache-py/CHANGELOG.md +++ b/packages/cultcache-py/CHANGELOG.md @@ -8,6 +8,7 @@ ### Changed +- `store_write_lock` looks for a link at the store path again once the lock is held, so a link put at the path while a writer waited on the lock is refused with `StoreLinkedError` and the lock released, instead of being replaced. `JsonLinesBackingStore` takes no cross-process lock (only its thread lock, under which it already refuses a link), so it has no lock wait to re-check across. Pinned by a test that puts the link while a foreign process holds the lock. - `CultCache.is_writable` does not look at store paths: a link at a store path is not predicted, and the write refuses it with `StoreLinkedError`. Pinned by a test through a cache attached to a linked store. - `JsonLinesBackingStore` writes LF line endings on Windows. It wrote CRLF there, through `write_text` newline translation, before the commit_store_file change (#154, adbbb780). - `msgpack` is a hard dependency, as `pyproject.toml` declared it. The optional-import fallbacks and the `cultcache-py[msgpack]` extra they named (which never existed) are gone: a missing msgpack is an `ImportError` at import. diff --git a/packages/cultcache-py/src/cultcache_py/stores.py b/packages/cultcache-py/src/cultcache_py/stores.py index b6b848bc..894252c8 100644 --- a/packages/cultcache-py/src/cultcache_py/stores.py +++ b/packages/cultcache-py/src/cultcache_py/stores.py @@ -501,6 +501,8 @@ def store_write_lock(path: Path, timeout: float = LOCK_TIMEOUT_SECONDS) -> Itera raise TimeoutError(f"CultCache timed out after {timeout} s waiting for the store lock {lock_path}.") time.sleep(_LOCK_POLL_SECONDS) try: + # A link may have been put at the path while the lock was awaited; the look above only saw the path before. + _refuse_linked(path) yield finally: release() diff --git a/packages/cultcache-py/tests/test_store_lock.py b/packages/cultcache-py/tests/test_store_lock.py index 5ee29aa2..50b95436 100644 --- a/packages/cultcache-py/tests/test_store_lock.py +++ b/packages/cultcache-py/tests/test_store_lock.py @@ -5,12 +5,14 @@ import shutil import subprocess import sys import tempfile +import threading import time import unittest from pathlib import Path from unittest import mock from cultcache_py import SingleFileMessagePackBackingStore, stores +from cultcache_py.stores import StoreLinkedError, store_write_lock from cultcache_py.backing_store import CultCacheEnvelope REPO = Path(__file__).resolve().parents[3] @@ -73,6 +75,48 @@ class StoreLockTests(unittest.TestCase): holder = start_holder(["node", "-e", TS_HOLDER, str(TS_STORE_FILE), str(self.path), str(HOLD_SECONDS)]) self.assert_write_waits_for(holder) + def test_a_link_put_while_a_writer_waits_on_the_lock_is_refused_once_it_holds_it(self) -> None: + target = self.dir / "volume" / "store.cc" + target.parent.mkdir() + SingleFileMessagePackBackingStore(target).push(envelope("a")) + before = target.read_bytes() + writes = { + "push": lambda store: store.push(envelope("b")), + "push_all": lambda store: store.push_all([envelope("b")]), + "delete": lambda store: store.delete("t", "a"), + } + missing = self.dir / "volume" / "missing.cc" + for (name, write), link_to in [(item, to) for item in writes.items() for to in (target, missing)]: + with self.subTest(name, dangling=link_to == missing): + if os.path.lexists(self.path): + os.unlink(self.path) + holder = start_holder([sys.executable, "-c", PY_HOLDER, str(self.path), str(HOLD_SECONDS)]) + self.addCleanup(holder.wait) + self.addCleanup(holder.stdout.close) # type: ignore[union-attr] + outcome: list[BaseException | None] = [] + + def run() -> None: + try: + write(SingleFileMessagePackBackingStore(self.path, lock_timeout=10)) + outcome.append(None) + except BaseException as exc: # noqa: BLE001 - the thread hands the outcome to the test + outcome.append(exc) + + writer = threading.Thread(target=run) + writer.start() + time.sleep(0.3) + os.symlink(link_to, self.path) # after the writer's pre-lock look, while it waits + writer.join() + holder.wait() + self.assertEqual(1, len(outcome)) + self.assertIsInstance(outcome[0], StoreLinkedError) + self.assertTrue(os.path.islink(self.path), "the link was replaced") + self.assertEqual(before, target.read_bytes()) + self.assertFalse(missing.exists(), "the dangling target was created") + os.unlink(self.path) # a link is refused before the lock, so remove it to probe the writer's own lock file + with store_write_lock(self.path, timeout=0.2): # the lock is free + pass + def test_the_lock_is_released_after_each_write(self) -> None: store = SingleFileMessagePackBackingStore(self.path, lock_timeout=0.2) for key in "abc": diff --git a/packages/cultcache-rs/CHANGELOG.md b/packages/cultcache-rs/CHANGELOG.md index 3978e248..327f42aa 100644 --- a/packages/cultcache-rs/CHANGELOG.md +++ b/packages/cultcache-rs/CHANGELOG.md @@ -8,6 +8,7 @@ only in git history. ### Changed +- The single-file store re-checks for a link once the store lock is held (every write and the try-exchange), so a link put at the path while a writer waited on the lock is refused with `StoreLinkedError` and the lock released, as C# does, instead of being replaced. Pinned by a test that puts the link while a second holder owns the lock. - `CultCache::is_writable` does not look at store paths: a link at a store path is not predicted, and the write refuses it with `StoreLinkedError`. Pinned by a test through a cache attached to a linked store. ### Breaking diff --git a/packages/cultcache-rs/src/lib.rs b/packages/cultcache-rs/src/lib.rs index edfc0c91..9ba800e9 100644 --- a/packages/cultcache-rs/src/lib.rs +++ b/packages/cultcache-rs/src/lib.rs @@ -1752,7 +1752,7 @@ impl SingleFileMessagePackBackingStore { let lock = self.open_lock_file()?; fs2::FileExt::lock_exclusive(&lock) .with_context(|| format!("failed to lock {}", self.lock_path().display()))?; - release_lock(lock, action()) + self.run_locked(lock, action) } /// Replaces the named entries only if each still matches its expectation. @@ -1870,7 +1870,13 @@ impl SingleFileMessagePackBackingStore { .with_context(|| format!("failed to lock {}", self.lock_path().display())); } } - release_lock(lock, action()).map(Some) + self.run_locked(lock, action).map(Some) + } + + /// The one tail of both exclusive entries: with the lock held, look for a link again (one may have been put at the + /// path while the lock was awaited; the look before the lock saw the path earlier), run the action, release. + fn run_locked(&self, lock: File, action: impl FnOnce() -> Result) -> Result { + release_lock(lock, refuse_linked(&self.path).and_then(|()| action())) } fn open_lock_file(&self) -> Result { @@ -8199,6 +8205,46 @@ mod tests { Ok(()) } + // A link put at the store path while a writer waits on the lock is refused once the lock is taken, as C# does, and the + // lock is free afterwards. + #[cfg(unix)] + #[test] + fn a_link_put_while_a_writer_waits_on_the_lock_is_refused_once_it_holds_it() -> Result<()> { + let temp = tempfile::tempdir()?; + let volume = temp.path().join("volume"); + fs::create_dir(&volume)?; + let target = volume.join("store.cc"); + SingleFileMessagePackBackingStore::new(&target).push(&snapshot_envelope("a", b"1"))?; + let before = fs::read(&target)?; + let dangling = volume.join("missing.cc"); + let path = temp.path().join("late.cc"); + let writers: [(&str, fn(&mut SingleFileMessagePackBackingStore) -> Result<()>); 4] = [ + ("push", |s| s.push(&snapshot_envelope("b", b"2"))), + ("push_all", |s| s.push_all(&[snapshot_envelope("b", b"2")], PushAllOptions::default())), + ("delete", |s| s.delete(&snapshot_envelope("a", b"1"))), + ("compare_exchange_snapshot", |s| s.compare_exchange_snapshot(&[], &[snapshot_envelope("b", b"2")]).map(|_| ())), + ]; + for (name, write, link_to) in writers.into_iter().flat_map(|(n, w)| [(n, w, &target), (n, w, &dangling)]) { + let _ = fs::remove_file(&path); + let store = SingleFileMessagePackBackingStore::new(&path); + let holder = store.open_lock_file()?; + fs2::FileExt::lock_exclusive(&holder)?; + let mut waiting = SingleFileMessagePackBackingStore::new(&path); + let handle = std::thread::spawn(move || write(&mut waiting)); + std::thread::sleep(std::time::Duration::from_millis(300)); + std::os::unix::fs::symlink(link_to, &path)?; + fs2::FileExt::unlock(&holder)?; + let error = handle.join().expect("writer thread").expect_err("the late link must be refused"); + assert!(error.downcast_ref::().is_some(), "{name}: {error:#}"); + assert!(fs::symlink_metadata(&path)?.file_type().is_symlink(), "{name}: the link was replaced"); + assert_eq!(fs::read(&target)?, before, "{name}: the target changed"); + assert!(!dangling.exists(), "{name}: the dangling target was created"); + let probe = store.open_lock_file()?; + fs2::FileExt::try_lock_exclusive(&probe).map_err(|e| anyhow!("{name}: lock still held: {e}"))?; + } + Ok(()) + } + // A write at a store path that is a link is refused: a replace there would swap the link for a regular file and // detach the store from its target. Reads through a live link keep working. #[cfg(unix)] diff --git a/packages/cultcache-ts/CHANGELOG.md b/packages/cultcache-ts/CHANGELOG.md index 5583b45c..c41071a7 100644 --- a/packages/cultcache-ts/CHANGELOG.md +++ b/packages/cultcache-ts/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to `@gamecult/cultcache-ts` are recorded here, following ### Changed +- `withStoreWriteLock` looks for a link at the store path again once the lock is held, so a link put at the path while a writer waited on the lock is refused with `StoreLinkedError` and the lock released, instead of being replaced. Pinned by a test that puts the link while another holder owns the lock. - `CultCache.isWritable` does not look at store paths: a link at a store path is not predicted, and the write refuses it with `StoreLinkedError`. Pinned by a test through a cache attached to a linked store. ### Breaking diff --git a/packages/cultcache-ts/src/store-file.ts b/packages/cultcache-ts/src/store-file.ts index e6988d23..0488960e 100644 --- a/packages/cultcache-ts/src/store-file.ts +++ b/packages/cultcache-ts/src/store-file.ts @@ -218,6 +218,8 @@ export async function withStoreWriteLock( } try { + // A link may have been put at the path while the lock was awaited; the look above only saw the path before. + await refuseLinkedStore(storePath); return await operation(); } finally { release(); diff --git a/packages/cultcache-ts/test/store-file.test.ts b/packages/cultcache-ts/test/store-file.test.ts index 485df058..7ddf7fe3 100644 --- a/packages/cultcache-ts/test/store-file.test.ts +++ b/packages/cultcache-ts/test/store-file.test.ts @@ -417,6 +417,43 @@ test("a second writer in this process waits for the store lock and runs after re } }); +test("a link put while a writer waits on the store lock is refused once it holds the lock", { skip: process.platform === "win32" }, async () => { + const directory = await scratch(); + try { + const target = join(directory, "target.cc"); + await new SingleFileMessagePackBackingStore(target).push(noteEnvelope("a")); + const before = await readFile(target); + const writes: Array<[string, (store: SingleFileMessagePackBackingStore) => Promise]> = [ + ["push", (store) => store.push(noteEnvelope("b"))], + ["pushAll", (store) => store.pushAll([noteEnvelope("b")])], + ["delete", (store) => store.delete(noteEnvelope("a"))], + ]; + const missing = join(directory, "missing.cc"); + for (const [name, write, linkTo] of writes.flatMap(([n, w]) => [[n, w, target], [`${n} (dangling)`, w, missing]] as const)) { + const link = join(directory, `${name.replace(/\W+/g, "-")}.cc`); + let writer: Promise | undefined; + await withStoreWriteLock(link, async () => { + writer = write(new SingleFileMessagePackBackingStore(link)); + writer.catch(() => undefined); + await sleepFor(150); + await fsp.symlink(linkTo, link); // after the writer's pre-lock look, while it waits + }); + await assert.rejects(writer!, (error: unknown) => error instanceof PackageStoreLinkedError, name); + assert.ok((await fsp.lstat(link)).isSymbolicLink(), `${name}: the link was replaced`); + assert.deepEqual(await readFile(target), before, `${name}: the target changed`); + await assert.rejects(fsp.lstat(missing), { code: "ENOENT" }, `${name}: the dangling target was created`); + await rm(link); // the link is refused before the lock, so remove it to probe the lock + assert.equal(await withStoreWriteLock(link, async () => "free", { timeoutMs: 200 }), "free", `${name}: the lock is held`); + } + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +function noteEnvelope(key: string): CultCacheEnvelope { + return { key, type: "note", payload: documentPayloadFromBytes(Uint8Array.of(0x91, 1)), storedAt: "2026-10-20T00:00:00Z" }; +} + test("the store lock gives up after its wait bound and names the lock path", async () => { const directory = await scratch(); try { From c4a270bc7fedb5a63237f6bee33685ed7d678f7c Mon Sep 17 00:00:00 2001 From: Libby Date: Fri, 13 Nov 2026 07:00:14 +0000 Subject: [PATCH 06/16] Cargo callers on committed-lock crates run with --locked (locked-cargo-callers) (#160) --- .github/workflows/cultnet-interop.yml | 2 +- README.md | 4 ++-- docs/start/README.md | 4 ++-- packages/cultcache-ts/test/cult-cache.test.ts | 2 +- packages/cultmath/README.md | 4 ++-- .../cultmesh-ts/test/interop/write-receipt-interop.test.ts | 2 +- packages/cultnet-rs/README.md | 2 +- packages/cultnet-ts/test/interop/cultnet-interop.test.ts | 2 +- packages/heimdall-stake/README.md | 4 ++-- tests/vectors/document-variants-c2a/old-reader-refusal.sh | 1 + 10 files changed, 14 insertions(+), 13 deletions(-) diff --git a/.github/workflows/cultnet-interop.yml b/.github/workflows/cultnet-interop.yml index 750edc9a..0d193aef 100644 --- a/.github/workflows/cultnet-interop.yml +++ b/.github/workflows/cultnet-interop.yml @@ -138,7 +138,7 @@ jobs: run: cargo test --locked --manifest-path packages/cultnet-rs/Cargo.toml rudp - name: Verify Rust CultMesh runtime-local surface - run: cargo test --manifest-path packages/cultmesh-rs/Cargo.toml + run: cargo test --locked --manifest-path packages/cultmesh-rs/Cargo.toml - name: Verify C# RUDP reliability run: dotnet test tests/GameCult.Networking.Tests/GameCult.Networking.Tests.csproj --filter "FullyQualifiedName~Rudp" diff --git a/README.md b/README.md index b916cabe..a555aa92 100644 --- a/README.md +++ b/README.md @@ -212,8 +212,8 @@ identity-owned C# session after provider route replacement. Rust package tests: ```powershell -cargo test --manifest-path packages/cultcache-rs/Cargo.toml -cargo test --manifest-path packages/cultnet-rs/Cargo.toml +cargo test --locked --manifest-path packages/cultcache-rs/Cargo.toml +cargo test --locked --manifest-path packages/cultnet-rs/Cargo.toml ``` Kotlin package build: diff --git a/docs/start/README.md b/docs/start/README.md index 6a7d6d74..9ca7f499 100644 --- a/docs/start/README.md +++ b/docs/start/README.md @@ -124,7 +124,7 @@ The test imports from `../src/index` where the block reads `@gamecult/cultcache- Same shape with `derive(DatabaseEntry)`: slots are `#[cultcache(key = N)]`, so the `.cc` payload is the slot-indexed array the C# runtime writes. The source is [`packages/cultcache-rs/tests/first_call.rs`](../../packages/cultcache-rs/tests/first_call.rs), run by -`cargo test --test first_call` in `packages/cultcache-rs`; a second test in that file fails if the block +`cargo test --locked --test first_call` in `packages/cultcache-rs`; a second test in that file fails if the block below stops matching the file. The Rust runtime has no name lookup, so the read-back is by key. More: `packages/cultcache-rs/README.md`. @@ -341,7 +341,7 @@ async function firstCall() { ### Rust CultNet first call Source: [`packages/cultnet-rs/tests/first_call.rs`](../../packages/cultnet-rs/tests/first_call.rs), run by -`cargo test --test first_call` in `packages/cultnet-rs`. More: `packages/cultnet-rs/README.md`. +`cargo test --locked --test first_call` in `packages/cultnet-rs`. More: `packages/cultnet-rs/README.md`. ```rust use std::net::{TcpListener, TcpStream}; diff --git a/packages/cultcache-ts/test/cult-cache.test.ts b/packages/cultcache-ts/test/cult-cache.test.ts index d3011ee2..dc1ddb33 100644 --- a/packages/cultcache-ts/test/cult-cache.test.ts +++ b/packages/cultcache-ts/test/cult-cache.test.ts @@ -2106,7 +2106,7 @@ async function readTsInteropStore(file: string): Promise { async function buildInteropPeers(): Promise { if (!(await exists(rustInteropBinary))) { - await execAsync(`"${cargoCommand}" build --quiet --example cultcache_interop`, { + await execAsync(`"${cargoCommand}" build --locked --quiet --example cultcache_interop`, { cwd: cultcacheRsRoot, }); } diff --git a/packages/cultmath/README.md b/packages/cultmath/README.md index b144c56d..6760a1f5 100644 --- a/packages/cultmath/README.md +++ b/packages/cultmath/README.md @@ -147,8 +147,8 @@ CultMath names with tests instead of copied project-local helper drift. ```powershell dotnet build packages/cultmath/src/CultMath/CultMath.csproj dotnet test packages/cultmath/tests/CultMath.Tests/CultMath.Tests.csproj -cargo test --manifest-path packages/cultmath/native/cultmath-core/Cargo.toml -cargo build --release --manifest-path packages/cultmath/native/cultmath-core/Cargo.toml +cargo test --locked --manifest-path packages/cultmath/native/cultmath-core/Cargo.toml +cargo build --locked --release --manifest-path packages/cultmath/native/cultmath-core/Cargo.toml .\packages\cultmath\scripts\build-unity-package.ps1 ``` diff --git a/packages/cultmesh-ts/test/interop/write-receipt-interop.test.ts b/packages/cultmesh-ts/test/interop/write-receipt-interop.test.ts index 092d5ffb..14d9e443 100644 --- a/packages/cultmesh-ts/test/interop/write-receipt-interop.test.ts +++ b/packages/cultmesh-ts/test/interop/write-receipt-interop.test.ts @@ -122,7 +122,7 @@ const rust: Target = { name: "rust", payload: (text) => encode({ text }), async start() { - if (!prebuilt) await execFileAsync(cargo, ["build", "--example", "receipt_interop_server", "--message-format", "short"], { cwd: rustManifestDir }); + if (!prebuilt) await execFileAsync(cargo, ["build", "--locked", "--example", "receipt_interop_server", "--message-format", "short"], { cwd: rustManifestDir }); const metadata = spawnSync(cargo, ["metadata", "--format-version", "1", "--no-deps"], { cwd: rustManifestDir, encoding: "utf8" }); if (metadata.status !== 0) throw new Error(`cargo metadata failed: ${metadata.stderr}`); const exe = join(JSON.parse(metadata.stdout).target_directory, "debug", "examples", `receipt_interop_server${process.platform === "win32" ? ".exe" : ""}`); diff --git a/packages/cultnet-rs/README.md b/packages/cultnet-rs/README.md index c356b97b..69a8e70d 100644 --- a/packages/cultnet-rs/README.md +++ b/packages/cultnet-rs/README.md @@ -30,7 +30,7 @@ knock on the contract before touching the furniture. ## Receipts ```powershell -cargo test +cargo test --locked ``` The initial tests prove: diff --git a/packages/cultnet-ts/test/interop/cultnet-interop.test.ts b/packages/cultnet-ts/test/interop/cultnet-interop.test.ts index d0e732ce..3b8b7bc9 100644 --- a/packages/cultnet-ts/test/interop/cultnet-interop.test.ts +++ b/packages/cultnet-ts/test/interop/cultnet-interop.test.ts @@ -2795,7 +2795,7 @@ async function buildInteropPeers(): Promise { } async function buildRustInteropPeer(): Promise { - rustInteropPeerBuild ??= execFileAsync(cargoCommand, ["build", "--quiet", "--example", "cultnet_interop_peer"], { + rustInteropPeerBuild ??= execFileAsync(cargoCommand, ["build", "--locked", "--quiet", "--example", "cultnet_interop_peer"], { cwd: cultnetRsRoot, }).then(() => undefined); await rustInteropPeerBuild; diff --git a/packages/heimdall-stake/README.md b/packages/heimdall-stake/README.md index 5c8ae095..9b6acb19 100644 --- a/packages/heimdall-stake/README.md +++ b/packages/heimdall-stake/README.md @@ -11,8 +11,8 @@ adversary, storage, and any projection. Every parameter is a placeholder until t ## First call ``` -cargo run --example first_call # prints the Meta example and a splitting table -cargo test +cargo run --locked --example first_call # prints the Meta example and a splitting table +cargo test --locked ``` ```rust diff --git a/tests/vectors/document-variants-c2a/old-reader-refusal.sh b/tests/vectors/document-variants-c2a/old-reader-refusal.sh index bfafa01d..5b9165f0 100755 --- a/tests/vectors/document-variants-c2a/old-reader-refusal.sh +++ b/tests/vectors/document-variants-c2a/old-reader-refusal.sh @@ -111,6 +111,7 @@ probe() { node ../../node_modules/typescript/bin/tsc -p tsconfig.test.json node --test dist-test/test/old-reader-v3.test.js ;; python) PYTHONPATH=packages/cultcache-py/src python3 packages/cultcache-py/tests/test_old_reader_v3.py -v ;; + # The staged tree is the frozen cultcache-rs-v0.3.1 revision, which has no committed Cargo.lock: --locked cannot work here. rust) cd packages/cultcache-rs && cargo test --test old_reader_v3 -- --nocapture ;; *) usage ;; esac From 568a60fa3edafff6488f615c01fdc5db94b410f6 Mon Sep 17 00:00:00 2001 From: Libby Date: Fri, 13 Nov 2026 07:40:21 +0000 Subject: [PATCH 07/16] Late-link tests: the Python leak fails by name, the Rust try entry is pinned (link-late-check-lows) (#161) --- .../cultcache-py/tests/test_store_lock.py | 11 ++++- packages/cultcache-rs/src/lib.rs | 48 +++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/packages/cultcache-py/tests/test_store_lock.py b/packages/cultcache-py/tests/test_store_lock.py index 50b95436..364106bf 100644 --- a/packages/cultcache-py/tests/test_store_lock.py +++ b/packages/cultcache-py/tests/test_store_lock.py @@ -1,6 +1,7 @@ from __future__ import annotations import os +import select import shutil import subprocess import sys @@ -18,6 +19,7 @@ from cultcache_py.backing_store import CultCacheEnvelope REPO = Path(__file__).resolve().parents[3] TS_STORE_FILE = REPO / "packages" / "cultcache-ts" / "dist" / "store-file.js" HOLD_SECONDS = 1.5 +HELD_WAIT_SECONDS = 3 # A holder that takes the lock the way the Rust runtime does (an exclusive file lock), not through this package. PY_HOLDER = """ @@ -43,7 +45,14 @@ def envelope(key: str) -> CultCacheEnvelope: def start_holder(argv: list[str]) -> subprocess.Popen[str]: holder = subprocess.Popen(argv, stdout=subprocess.PIPE, text=True) - assert holder.stdout is not None and holder.stdout.readline().strip() == "HELD" + assert holder.stdout is not None + ready, _, _ = select.select([holder.stdout], [], [], HELD_WAIT_SECONDS) # a holder blocked on a leaked lock never prints + if not ready: + holder.kill() + holder.wait() + holder.stdout.close() + raise AssertionError(f"the holder never took {argv[-2]}.lock within {HELD_WAIT_SECONDS} s: an earlier writer leaked the lock") + assert holder.stdout.readline().strip() == "HELD" holder.held_at = time.monotonic() # type: ignore[attr-defined] return holder diff --git a/packages/cultcache-rs/src/lib.rs b/packages/cultcache-rs/src/lib.rs index 9ba800e9..2900a1f7 100644 --- a/packages/cultcache-rs/src/lib.rs +++ b/packages/cultcache-rs/src/lib.rs @@ -1861,6 +1861,8 @@ impl SingleFileMessagePackBackingStore { fn try_with_exclusive_lock(&self, action: impl FnOnce() -> Result) -> Result> { refuse_linked(&self.path)?; + #[cfg(test)] + run_after_pre_lock_look(); let lock = self.open_lock_file()?; match fs2::FileExt::try_lock_exclusive(&lock) { Ok(()) => {} @@ -4773,6 +4775,20 @@ fn injected_write_fault(step: WriteStep) -> std::io::Result<()> { Ok(()) } +// The one gap a test cannot hold open from outside: a try-lock never waits, so what happens between its pre-lock look and +// the lock is only reachable by running the test's closure there. +#[cfg(test)] +thread_local! { + static AFTER_PRE_LOCK_LOOK: std::cell::RefCell>> = const { std::cell::RefCell::new(None) }; +} + +#[cfg(test)] +fn run_after_pre_lock_look() { + if let Some(hook) = AFTER_PRE_LOCK_LOOK.with(|hook| hook.borrow_mut().take()) { + hook(); + } +} + #[cfg(not(test))] #[inline(always)] fn injected_write_fault(_step: WriteStep) -> std::io::Result<()> { @@ -8245,6 +8261,38 @@ mod tests { Ok(()) } + // The try entry has no wait, so the late window is the gap between its look and its lock: a link put there is refused + // once the lock is held, nothing is written through or over it, and the lock is released. + #[cfg(unix)] + #[test] + fn a_link_put_between_the_try_entrys_look_and_its_lock_is_refused_and_the_lock_is_released() -> Result<()> { + let temp = tempfile::tempdir()?; + let target = temp.path().join("target.cc"); + SingleFileMessagePackBackingStore::new(&target).push(&snapshot_envelope("a", b"1"))?; + let before = fs::read(&target)?; + let dangling = temp.path().join("missing.cc"); + let path = temp.path().join("late.cc"); + for link_to in [target.clone(), dangling.clone()] { + let store = SingleFileMessagePackBackingStore::new(&path); + let (link, at) = (link_to.clone(), path.clone()); + AFTER_PRE_LOCK_LOOK.with(|hook| { + *hook.borrow_mut() = Some(Box::new(move || std::os::unix::fs::symlink(&link, &at).expect("put the link"))); + }); + let error = store + .try_compare_exchange_snapshot(&[], &[snapshot_envelope("b", b"2")]) + .expect_err("the late link must be refused"); + assert!(error.downcast_ref::().is_some(), "{error:#}"); + assert!(fs::symlink_metadata(&path)?.file_type().is_symlink(), "the link was replaced"); + assert_eq!(fs::read(&target)?, before, "the target changed"); + assert!(!dangling.exists(), "the dangling target was created"); + let probe = store.open_lock_file()?; + fs2::FileExt::try_lock_exclusive(&probe).map_err(|e| anyhow!("lock still held: {e}"))?; + drop(probe); + fs::remove_file(&path)?; + } + Ok(()) + } + // A write at a store path that is a link is refused: a replace there would swap the link for a regular file and // detach the store from its target. Reads through a live link keep working. #[cfg(unix)] From 6cfd22c786b1db69f315330f1293378e2c42fe11 Mon Sep 17 00:00:00 2001 From: Libby Date: Fri, 13 Nov 2026 08:56:51 +0000 Subject: [PATCH 08/16] Remaining unlocked cargo mentions say --locked (locked-cargo-doc-stragglers) (#162) --- packages/cultnet-rs/examples/cultnet_impair/main.rs | 2 +- packages/cultnet-rs/tests/media_fec_bench.rs | 2 +- packages/cultnet-ts/test/fixtures/idunn-runtime/README.md | 4 ++-- packages/cultnet-ts/test/runtime-presence-health.test.ts | 2 +- packages/heimdall-consent/README.md | 2 +- tests/GameCult.Networking.Tests/SelectionParityVectorTests.cs | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/cultnet-rs/examples/cultnet_impair/main.rs b/packages/cultnet-rs/examples/cultnet_impair/main.rs index c2ebd874..35a2168d 100644 --- a/packages/cultnet-rs/examples/cultnet_impair/main.rs +++ b/packages/cultnet-rs/examples/cultnet_impair/main.rs @@ -7,7 +7,7 @@ //! traffic crosses the same schedule. The schedule is `policy.rs`. //! //! ```text -//! cargo run --release --example cultnet_impair -- \ +//! cargo run --locked --release --example cultnet_impair -- \ //! --listen 0.0.0.0:17890 --upstream 127.0.0.1:17990 \ //! --profile tests/impairment/loss-1pct.toml --seed 424242 --metrics loss-1pct.csv //! ``` diff --git a/packages/cultnet-rs/tests/media_fec_bench.rs b/packages/cultnet-rs/tests/media_fec_bench.rs index 40c9de78..f0279495 100644 --- a/packages/cultnet-rs/tests/media_fec_bench.rs +++ b/packages/cultnet-rs/tests/media_fec_bench.rs @@ -3,7 +3,7 @@ //! Ignored by default; run in release, where the numbers mean something: //! //! ```text -//! cargo test --release --test media_fec_bench -- --ignored --nocapture +//! cargo test --locked --release --test media_fec_bench -- --ignored --nocapture //! ``` //! //! Each line is nanoseconds per call through the public surface, so record diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/README.md b/packages/cultnet-ts/test/fixtures/idunn-runtime/README.md index 7856c78d..86dd821e 100644 --- a/packages/cultnet-ts/test/fixtures/idunn-runtime/README.md +++ b/packages/cultnet-ts/test/fixtures/idunn-runtime/README.md @@ -6,7 +6,7 @@ cultnet-rs only, and Idunn's `IdunnRuntimeActivationLaunch` is that same crate's It needs Linux and a machine-id equal to the `machine-id` file here, because the provider identity is bound to it. On Yggdrasil: - DOCKER_ARGS='--cap-add SYS_ADMIN --security-opt apparmor=unconfined' bash ygg-verify.sh HEAD rust 'cd packages/cultnet-rs && cp /tmp/mid && mount --bind /tmp/mid /etc/machine-id && cargo run -q --example idunn_runtime_fixture /tmp/out' + DOCKER_ARGS='--cap-add SYS_ADMIN --security-opt apparmor=unconfined' bash ygg-verify.sh HEAD rust 'cd packages/cultnet-rs && cp /tmp/mid && mount --bind /tmp/mid /etc/machine-id && cargo run --locked -q --example idunn_runtime_fixture /tmp/out' then copy `/tmp/out` over this directory. `--cap-add SYS_ADMIN` exists only so the bind mount can present the committed `machine-id`, which keeps Yggdrasil's real machine-id out of the repo. Every `presence-*.bin` was accepted by `authenticate_runtime_presence_claim` and correlated by @@ -26,7 +26,7 @@ The generator takes its specification as input, so a consumer needs no patch to specification is command-line flags; `@` splices in a response file (one `--flag value` per line, `#` comments). CultLib's own sets are `fixture.args` here: - cargo run -q --example idunn_runtime_fixture /tmp/out @/fixture.args + cargo run --locked -q --example idunn_runtime_fixture /tmp/out @/fixture.args The consumer keeps its own `.args` file in its repository and runs the same command with the same machine-id bind mount as above. Global flags: `--source-repository`, `--detail`. Each diff --git a/packages/cultnet-ts/test/runtime-presence-health.test.ts b/packages/cultnet-ts/test/runtime-presence-health.test.ts index 2dc4a845..1d7c4fa6 100644 --- a/packages/cultnet-ts/test/runtime-presence-health.test.ts +++ b/packages/cultnet-ts/test/runtime-presence-health.test.ts @@ -2,7 +2,7 @@ // // The vectors below were produced by `cultnet-rs`, not written by hand: // -// cargo run --example runtime_presence_vector -p cultnet-rs +// cargo run --locked --example runtime_presence_vector -p cultnet-rs // // That matters. A fixture authored for both sides at once agrees with itself // and proves nothing; the contract is only real if one fork emits the bytes and diff --git a/packages/heimdall-consent/README.md b/packages/heimdall-consent/README.md index 3782e720..926b7d1a 100644 --- a/packages/heimdall-consent/README.md +++ b/packages/heimdall-consent/README.md @@ -7,7 +7,7 @@ plus the rules that need no store to state (`Answer::is_training_data`, `Answer: (comments 682 and 759). Specification only: no collector, tally, export or tombstone hashing lives here. Entry points: `src/lib.rs`; golden vectors `tests/vectors/consent_golden.csv`, run by `tests/golden.rs` -(`cargo test`). Planned, not live: the collector, the tally, the training export. +(`cargo test --locked`). Planned, not live: the collector, the tally, the training export. ## Where each ruling lands (doc lines at aaeb175) diff --git a/tests/GameCult.Networking.Tests/SelectionParityVectorTests.cs b/tests/GameCult.Networking.Tests/SelectionParityVectorTests.cs index 3a076d16..ad7dcaca 100644 --- a/tests/GameCult.Networking.Tests/SelectionParityVectorTests.cs +++ b/tests/GameCult.Networking.Tests/SelectionParityVectorTests.cs @@ -341,7 +341,7 @@ namespace GameCult.Networking.Tests { var path = Path.Combine(RepoRoot(), "contracts", "cultnet", "interop", "selection-vectors.rs-written.json"); if (!File.Exists(path)) - Assert.Inconclusive($"{path} is missing - run cargo test -p cultnet-rs --test selection with CULTNET_WRITE_VECTORS=1 first."); + Assert.Inconclusive($"{path} is missing - run cargo test --locked -p cultnet-rs --test selection with CULTNET_WRITE_VECTORS=1 first."); var (registry, rows, _) = BuildFixture(); using var document = JsonDocument.Parse(File.ReadAllText(path)); From 8edb2be2f42a8d8aa1ca74d28c11e00133b0cdd7 Mon Sep 17 00:00:00 2001 From: Libby Date: Fri, 13 Nov 2026 10:09:10 +0000 Subject: [PATCH 09/16] Stryker scope extends to GameCult.Caching.Tests; CultElementIds survivors killed or recorded (mutation-testing-scope) (#163) --- docs/mutation-testing.md | 91 ++++++++++- .../ElementIdWalkTests.cs | 151 ++++++++++++++++++ .../stryker-config.json | 11 ++ 3 files changed, 246 insertions(+), 7 deletions(-) create mode 100644 tests/GameCult.Caching.Tests/ElementIdWalkTests.cs create mode 100644 tests/GameCult.Caching.Tests/stryker-config.json diff --git a/docs/mutation-testing.md b/docs/mutation-testing.md index 8db67be5..c6a0f5bd 100644 --- a/docs/mutation-testing.md +++ b/docs/mutation-testing.md @@ -3,7 +3,7 @@ CultLib measures its C# test suites with Stryker.NET, pinned as a repo-local tool in `dotnet-tools.json`. Coverage is added project by project; a project with no `stryker-config.json` next to its test project has not been scoped -yet (see "Follow-ups" below for what's outstanding). +yet (see "Scope and follow-ups" below for what's outstanding). ## Running it @@ -126,14 +126,17 @@ project as precedent for the `vstest` caveat itself - that caveat is `mtp`-runner-specific and documented above for `static readonly` field initializers, and GameCult.Geometry has never actually hit it. -## Follow-ups +## Scope and follow-ups -CultMath.Tests and GameCult.Geometry.Tests are the only projects currently -scoped. GameCult.Geometry's `stryker-config.json` has no `test-runner` -entry: the project is NUnit 4 with `NUnit3TestAdapter` and +CultMath.Tests, GameCult.Geometry.Tests and GameCult.Caching.Tests are the +projects currently scoped. GameCult.Geometry's `stryker-config.json` has no +`test-runner` entry: the project is NUnit 4 with `NUnit3TestAdapter` and `Microsoft.NET.Test.Sdk`, so Stryker's default `vstest` runner applies directly (no `mtp` workaround needed, unlike CultMath.Tests's xunit.v3 -suite). +suite). GameCult.Caching.Tests is the same shape (NUnit, default `vstest`) +and its `stryker-config.json` is driven the same way, by the repo-local +`dotnet-stryker` tool: `dotnet tool restore` once, then `dotnet tool run +dotnet-stryker` from `tests/GameCult.Caching.Tests`. GameCult.Geometry has only ever been run `--since:`-scoped to the cut that added surface nets; a full-project baseline (running mutation against all of @@ -151,7 +154,81 @@ file, anything under `Fixtures/`) loses `--since` line-level scoping for that run; a future cut whose diff is C#-only, against a base commit that already has `stryker-config.json`, should scope correctly. -`GameCult.Caching`, `GameCult.Mesh` (+ `.Quic`, `.Quic.Native`), +### GameCult.Caching.Tests + +`tests/GameCult.Caching.Tests/stryker-config.json` mutates all of +`src/GameCult.Caching` (the project is `GameCult.Caching.csproj`; Standard +mutation level, `break` 0, the same thresholds as Geometry). Caching was +scoped before Networking because its suite is deterministic and quick (1415 +tests, about 40 s) over about 7.8k lines of source, where GameCult.Networking +is about 20k lines and its tests lean on socket timing, so a mutant timing out +there says little about the test. A full Caching run is about 2,900 candidate +mutants (3,602 created, minus 730 that fail to compile) at about 2 s each, +about 1.5 hours; run it detached and read the JSON report, +never as one attached session. + +The first measurement was a narrow pass over the three files that carry the +store-replace and element-id machinery, from `tests/GameCult.Caching.Tests`: + +```bash +dotnet tool run dotnet-stryker \ + -m '**/src/GameCult.Caching/StoreReplace.cs' \ + -m '**/src/GameCult.Caching/CultStoreLinks.cs' \ + -m '**/src/GameCult.Caching/CultElementIds.cs' +``` + +A clean single run of that command tested 243 mutants and scored 86.79% in +9 minutes 55 seconds. The first measurement of it overlapped two Stryker +runs, so its numbers (and the 114 survivors it showed for `CultElementIds.cs`, +where tests that kill a mutant by hand reported `Survived` or `Timeout`) are +not measurements and are not quoted here. Never run two Stryker runs against +one checkout at once, and check `ps` before starting one. A clean single run +on `CultElementIds.cs` alone, after the tests below, tested 228 mutants: 208 +`Killed`, 8 `Timeout`, 12 `Survived`, a score of 94.74%. The 12th survivor +was 321, which the throwing-enumerator case in `ListsOfValuesHoldNoIds` now +kills; the other 11 are equivalent mutants. With that case a clean single run +on `CultElementIds.cs` is 209 `Killed`, 8 `Timeout`, 11 `Survived`, 95.18%. + +Real gaps that run exposed, each now pinned by `ElementIdWalkTests` (and each +failing when its mutant is applied by hand): the order of the id path (the +codec refuses at the first type MessagePack cannot allow, so order is what an +error names first; `Append` to `Prepend` at 163, 167 and 180); the +union-cycle guard in `ConcreteTypes` (248; the mutant is a stack overflow, so +the red is a crashed test host: a depth guard would need production code in +`ConcreteTypes`, since the recursion is private and a stack overflow cannot be +caught by the test, so the regression test is left to crash loudly); a list of values reporting that it holds an +id (`Holds`, 322); `[Key]` read through an override (`KeyOf`, 583); and the +`IsWrittenWhole` and `IsKeyed` predicates (629 to 631 and 636: formatter, +`[MessagePackObject]` and `KeyAsPropertyName` inherited from a base, and the +`[DataContract]` without `[MessagePackObject]` and `||` arms). + +Equivalent survivors in `CultElementIds.cs` (11), each hand-checked against the full +suite; none can be told apart by any test: + +- 142, 251, 636 (second arm), 506 (`IsDefined(UnionAttribute, false)` to + `true`): `UnionAttribute` is `Inherited = false`, so its `inherit` argument + changes nothing. Likewise 630's `DataContractAttribute` `inherit` argument + (`DataContractAttribute` is `Inherited = false`). +- 264, 542: `group.First()` to `FirstOrDefault()` over `GroupBy` groups, which + are never empty. +- 491: `new StringBuilder(bytes.Length * 2)` to `/ 2` changes only the initial + capacity. +- 578: removing the `Nullable.GetUnderlyingType` unwrap in `WalksAsOwner`; + `Classify` unwraps nullables itself, so the unwrap is dead. A cut + candidate, not a test gap. +- 645, 657: `Append(type)` to `Prepend(type)` in `ElementTypeOf` and + `MapTypes`; `type` matches only when it is itself `IEnumerable<>`, + `IDictionary<,>` or `IReadOnlyDictionary<,>`, and none of those has a second + matching interface, so the position of `type` never decides the answer. + +The Windows-only P/Invoke group is exercised only on Windows: its mutants are +`NoCoverage` on Linux and are a target for a Windows-leg mutation run, not a +Linux gap or a finding. `StoreReplace.cs` line 45 is a different case: its +negated `IsOSPlatform(Windows)` mutant is `Survived` on Linux, because the +negated branch calls `ReplaceWindows`, whose `DllNotFound` fallback gives the +same result. + +`GameCult.Mesh` (+ `.Quic`, `.Quic.Native`), `GameCult.Networking` (+ `.WebSockets`), and the non-.NET runtimes (`cultcache-rs/py/ts`, `cultmesh-*`, `cultnet-*`) each still need their own `stryker-config.json` (or ecosystem-equivalent tool: `cargo-mutants` for diff --git a/tests/GameCult.Caching.Tests/ElementIdWalkTests.cs b/tests/GameCult.Caching.Tests/ElementIdWalkTests.cs new file mode 100644 index 00000000..bd6cb903 --- /dev/null +++ b/tests/GameCult.Caching.Tests/ElementIdWalkTests.cs @@ -0,0 +1,151 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.Serialization; +using GameCult.Caching; +using MessagePack; +using NUnit.Framework; + +namespace GameCult.Caching.Tests +{ + // The id walk's own contracts, one mutant-sized fact at a time: the order of the id path, a union that names itself, a list of + // values never counting as an id, and the attribute predicates that decide whether a type is walked or written whole. + public class ElementIdWalkTests + { + // The codec asks MessagePack about the id path in order and refuses at the first type it cannot allow, so the order is what + // an error names first: the document, then the collection types from the outermost in, then the element and its arms. + [Test] + public void TheIdPathListsCollectionsFromTheOutermostIn() + { + Assert.That(CultElementIds.Inspect(typeof(MsMapMap)).IdPath, Is.EqualTo(new[] + { + typeof(MsMapMap), typeof(Dictionary>>), + typeof(Dictionary>), typeof(List), typeof(ElementIdEngineTests.EIded) + }), "maps nested in maps"); + Assert.That(CultElementIds.Inspect(typeof(MsListDeep)).IdPath, Is.EqualTo(new[] + { + typeof(MsListDeep), typeof(List>>), typeof(List>), + typeof(List), typeof(ElementIdEngineTests.EIded) + }), "lists nested in lists"); + } + + // ConcreteTypes stops at a type it has already expanded, so a union that lists its own base is finished, not a stack overflow. + [Test] + public void AUnionThatNamesItselfIsWalkedOnce() + { + var survey = CultElementIds.Inspect(typeof(MsSelfHolder)); + Assert.That(survey.Problems, Is.Empty); + Assert.That(survey.IdPath, Is.EquivalentTo(new[] { typeof(MsSelfHolder), typeof(List), typeof(MsSelfBase), typeof(MsSelfArm) })); + } + + // Value lists hold no ids however they are nested, and a root that is itself a value list holds none either. + [Test] + public void ListsOfValuesHoldNoIds() + { + var values = new MsValues { Numbers = { 1 }, Tags = { "a" }, Grid = { new List { 1 } }, Map = { ["k"] = new List { 2 } } }; + Assert.That(CultElementIds.Holds(typeof(MsValues), values), Is.False); + Assert.That(CultElementIds.Holds(typeof(List), new List { 1, 2 }), Is.False); + Assert.That(CultElementIds.Holds(typeof(List>), new List> { new() { 1 } }), Is.False); + Assert.That(CultElementIds.Holds(typeof(List), new List { "a" }), Is.False); + Assert.That(CultElementIds.Holds(typeof(MsBoom), new MsBoom()), Is.False, "a sequence of values is not enumerated"); + } + + // A nullable struct is walked as the struct it wraps; a nullable value is not walked at all. + [Test] + public void ANullableKeyedStructWalksAsItsOwner() + { + Assert.That(CultElementIds.WalksAsOwner(typeof(MsKeyedStruct)), Is.True); + Assert.That(CultElementIds.WalksAsOwner(typeof(MsKeyedStruct?)), Is.True); + Assert.That(CultElementIds.WalksAsOwner(typeof(int?)), Is.False); + Assert.That(CultElementIds.WalksAsOwner(typeof(int)), Is.False); + } + + // [Key] is read through an override, so an element whose id property overrides a keyed base member is still an id with a key. + [Test] + public void AKeyOnTheBaseOfAnOverrideCountsForTheOverride() + { + Assert.That(CultElementIds.Inspect(typeof(MsOverrideHolder)).Problems, Is.Empty); + Assert.That(CultElementIds.Inspect(typeof(MsOverrideHolder)).IdPath, Does.Contain(typeof(MsOverrideEl))); + } + + // A type is walked by key when it is [MessagePackObject] or [Union], read through its bases for the former; it is one whole + // value when it declares its own formatter, is [DataContract] without being [MessagePackObject], or is keyed by name. + [Test] + public void KeyedTypesWalkAndWholeTypesDoNot() + { + Assert.That(CultElementIds.WalksAsOwner(typeof(MsMpBase)), Is.True, "[MessagePackObject]"); + Assert.That(CultElementIds.WalksAsOwner(typeof(MsUBase)), Is.True, "[Union]"); + Assert.That(CultElementIds.WalksAsOwner(typeof(MsMpDerived)), Is.True, "[MessagePackObject] inherited"); + Assert.That(CultElementIds.WalksAsOwner(typeof(MsPlain)), Is.False, "[Key] members without [MessagePackObject] or [Union]"); + Assert.That(CultElementIds.WalksAsOwner(typeof(MsUArm)), Is.True, "an arm that is itself [MessagePackObject]"); + + Assert.That(CultElementIds.WalksAsOwner(typeof(MsDc)), Is.False, "[DataContract] alone"); + Assert.That(CultElementIds.WalksAsOwner(typeof(MsDcMp)), Is.True, "[DataContract] beside [MessagePackObject]"); + Assert.That(CultElementIds.WalksAsOwner(typeof(MsDcOverMpBase)), Is.True, "[DataContract] over an inherited [MessagePackObject]"); + + Assert.That(CultElementIds.WalksAsOwner(typeof(MsPropName)), Is.False, "KeyAsPropertyName"); + Assert.That(CultElementIds.WalksAsOwner(typeof(MsPropNameDerived)), Is.False, "KeyAsPropertyName inherited"); + Assert.That(CultElementIds.WalksAsOwner(typeof(MsStringKey)), Is.False, "a string [Key]"); + + Assert.That(CultElementIds.WalksAsOwner(typeof(MsFormattedBase)), Is.False, "[MessagePackFormatter]"); + Assert.That(CultElementIds.WalksAsOwner(typeof(MsFormattedDerived)), Is.False, "[MessagePackFormatter] inherited"); + } + + [MessagePackObject] public class MsMapMap { [Key(0)] public Dictionary>> M { get; set; } = new(); } + [MessagePackObject] public class MsListDeep { [Key(0)] public List>> G { get; set; } = new(); } + + [Union(0, typeof(MsSelfBase)), Union(1, typeof(MsSelfArm))] public abstract class MsSelfBase { } + [MessagePackObject] public class MsSelfArm : MsSelfBase { [Key(0)] [CultElementId] public string Id { get; set; } = ""; } + [MessagePackObject] public class MsSelfHolder { [Key(0)] public List Items { get; set; } = new(); } + + [MessagePackObject] + public class MsValues + { + [Key(0)] public List Numbers { get; set; } = new(); + [Key(1)] public List Tags { get; set; } = new(); + [Key(2)] public List> Grid { get; set; } = new(); + [Key(3)] public Dictionary> Map { get; set; } = new(); + } + + public sealed class MsBoom : IEnumerable + { + public IEnumerator GetEnumerator() => throw new InvalidOperationException("enumerated"); + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator(); + } + + [MessagePackObject] public struct MsKeyedStruct { [Key(0)] public int X; } + + [MessagePackObject] + public class MsOverrideBase + { + [Key(0)] [CultElementId] public virtual string Id { get; set; } = ""; + } + + [MessagePackObject] + public class MsOverrideEl : MsOverrideBase + { + public override string Id { get; set; } = ""; + } + + [MessagePackObject] public class MsOverrideHolder { [Key(0)] public List Items { get; set; } = new(); } + + [MessagePackObject] public class MsMpBase { [Key(0)] public int A { get; set; } } + public class MsMpDerived : MsMpBase { } + public class MsPlain { [Key(0)] public int A { get; set; } } + [Union(0, typeof(MsUArm))] public abstract class MsUBase { } + [MessagePackObject] public class MsUArm : MsUBase { [Key(0)] public int A { get; set; } } + + [DataContract] public class MsDc { [DataMember] public int A { get; set; } } + [DataContract] [MessagePackObject] public class MsDcMp { [Key(0)] public int A { get; set; } } + [DataContract] public class MsDcOverMpBase : MsMpBase { } + + [MessagePackObject(keyAsPropertyName: true)] public class MsPropName { public int A { get; set; } } + public class MsPropNameDerived : MsPropName { } + [MessagePackObject] public class MsStringKey { [Key("a")] public int A { get; set; } } + + public sealed class MsNoFormatter { } + [MessagePackFormatter(typeof(MsNoFormatter))] [MessagePackObject] public class MsFormattedBase { [Key(0)] public int A { get; set; } } + [MessagePackObject] public class MsFormattedDerived : MsFormattedBase { [Key(1)] public int B { get; set; } } + } +} diff --git a/tests/GameCult.Caching.Tests/stryker-config.json b/tests/GameCult.Caching.Tests/stryker-config.json new file mode 100644 index 00000000..ac366203 --- /dev/null +++ b/tests/GameCult.Caching.Tests/stryker-config.json @@ -0,0 +1,11 @@ +{ + "stryker-config": { + "project": "GameCult.Caching.csproj", + "mutate": [ + "**/src/GameCult.Caching/**/*.cs" + ], + "mutation-level": "Standard", + "reporters": [ "progress", "html", "json" ], + "thresholds": { "high": 80, "low": 60, "break": 0 } + } +} From c229b86fde24d4d088c70b54668b4b447f4fef2a Mon Sep 17 00:00:00 2001 From: Libby Date: Fri, 13 Nov 2026 11:16:15 +0000 Subject: [PATCH 10/16] cultcache-rs: lock-file open waits through a Windows sharing violation (#164) --- packages/cultcache-rs/src/lib.rs | 132 +++++++++++++++++++++++++++++-- 1 file changed, 124 insertions(+), 8 deletions(-) diff --git a/packages/cultcache-rs/src/lib.rs b/packages/cultcache-rs/src/lib.rs index 2900a1f7..b37582d7 100644 --- a/packages/cultcache-rs/src/lib.rs +++ b/packages/cultcache-rs/src/lib.rs @@ -1863,7 +1863,9 @@ impl SingleFileMessagePackBackingStore { refuse_linked(&self.path)?; #[cfg(test)] run_after_pre_lock_look(); - let lock = self.open_lock_file()?; + let Some(lock) = self.try_open_lock_file()? else { + return Ok(None); + }; match fs2::FileExt::try_lock_exclusive(&lock) { Ok(()) => {} Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => return Ok(None), @@ -1881,19 +1883,31 @@ impl SingleFileMessagePackBackingStore { release_lock(lock, refuse_linked(&self.path).and_then(|()| action())) } + /// Opens the lock file, waiting through a Windows sharing violation the way the lock itself waits. fn open_lock_file(&self) -> Result { + let lock_path = self.prepare_lock_dir()?; + retry_while_busy( + || open_lock_once(&lock_path), + is_sharing_violation, + || std::thread::sleep(std::time::Duration::from_millis(5)), + ) + .with_context(|| format!("failed to open {}", lock_path.display())) + } + + /// The single-attempt open for the try entry: a sharing violation is `None`, the same answer as a held lock. + fn try_open_lock_file(&self) -> Result> { + let lock_path = self.prepare_lock_dir()?; + open_unless_busy(|| open_lock_once(&lock_path), is_sharing_violation) + .with_context(|| format!("failed to open {}", lock_path.display())) + } + + fn prepare_lock_dir(&self) -> Result { let lock_path = self.lock_path(); if let Some(parent) = lock_path.parent() { fs::create_dir_all(parent) .with_context(|| format!("failed to create {}", parent.display()))?; } - OpenOptions::new() - .create(true) - .read(true) - .truncate(false) - .write(true) - .open(&lock_path) - .with_context(|| format!("failed to open {}", lock_path.display())) + Ok(lock_path) } fn lock_path(&self) -> PathBuf { @@ -4548,6 +4562,53 @@ impl ReplaceKernel for WindowsKernel { } } +fn open_lock_once(lock_path: &Path) -> std::io::Result { + OpenOptions::new() + .create(true) + .read(true) + .truncate(false) + .write(true) + .open(lock_path) +} + +/// Windows ERROR_SHARING_VIOLATION (raw os error 32): another handle holds the lock file open without sharing. On Unix +/// errno 32 is EPIPE and is never a busy file. +#[cfg(windows)] +fn is_sharing_violation(error: &std::io::Error) -> bool { + error.raw_os_error() == Some(32) +} + +#[cfg(not(windows))] +fn is_sharing_violation(_error: &std::io::Error) -> bool { + false +} + +/// Repeats `open` while it fails busy, sleeping between tries and without a bound, as the lock waits; any other error surfaces. +fn retry_while_busy( + mut open: impl FnMut() -> std::io::Result, + busy: impl Fn(&std::io::Error) -> bool, + mut sleep: impl FnMut(), +) -> std::io::Result { + loop { + match open() { + Err(error) if busy(&error) => sleep(), + other => return other, + } + } +} + +/// One attempt: `None` when the open fails busy, any other error surfaces. +fn open_unless_busy( + open: impl FnOnce() -> std::io::Result, + busy: impl Fn(&std::io::Error) -> bool, +) -> std::io::Result> { + match open() { + Ok(file) => Ok(Some(file)), + Err(error) if busy(&error) => Ok(None), + Err(error) => Err(error), + } +} + #[cfg(test)] mod replace_with_tests { use super::{replace_with, ReplaceKernel}; @@ -8261,6 +8322,61 @@ mod tests { Ok(()) } + fn busy_error() -> std::io::Error { + std::io::Error::from_raw_os_error(32) + } + + fn is_busy(error: &std::io::Error) -> bool { + error.raw_os_error() == Some(32) + } + + #[test] + fn the_lock_open_waits_through_busy_failures_then_opens() { + let (mut tries, mut sleeps) = (0, 0); + let opened = retry_while_busy( + || { + tries += 1; + if tries <= 4 { Err(busy_error()) } else { Ok(tries) } + }, + is_busy, + || sleeps += 1, + ); + assert_eq!(opened.unwrap(), 5); + assert_eq!(sleeps, 4); + } + + #[test] + fn the_lock_open_surfaces_a_non_busy_error_at_once() { + let (mut tries, mut sleeps) = (0, 0); + let error = retry_while_busy::<()>( + || { + tries += 1; + Err(std::io::Error::from(std::io::ErrorKind::PermissionDenied)) + }, + is_busy, + || sleeps += 1, + ) + .expect_err("a non-busy error must surface"); + assert_eq!(error.kind(), std::io::ErrorKind::PermissionDenied); + assert_eq!((tries, sleeps), (1, 0)); + } + + #[test] + fn the_try_open_maps_a_sharing_violation_to_none_without_retrying() { + let mut tries = 0; + let opened = open_unless_busy::<()>( + || { + tries += 1; + Err(busy_error()) + }, + is_busy, + ); + assert!(opened.unwrap().is_none()); + assert_eq!(tries, 1); + let other = open_unless_busy::<()>(|| Err(std::io::Error::from(std::io::ErrorKind::PermissionDenied)), is_busy); + assert!(other.is_err()); + } + // The try entry has no wait, so the late window is the gap between its look and its lock: a link put there is refused // once the lock is held, nothing is written through or over it, and the lock is released. #[cfg(unix)] From 64f4d291479bd3e6170bec742004a1196f3b7b15 Mon Sep 17 00:00:00 2001 From: Libby Date: Fri, 13 Nov 2026 12:16:41 +0000 Subject: [PATCH 11/16] Mutation testing doc: name the revision of each narrow-pass score, restate the full-run rate (#165) --- docs/mutation-testing.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/mutation-testing.md b/docs/mutation-testing.md index c6a0f5bd..9136b667 100644 --- a/docs/mutation-testing.md +++ b/docs/mutation-testing.md @@ -163,8 +163,8 @@ scoped before Networking because its suite is deterministic and quick (1415 tests, about 40 s) over about 7.8k lines of source, where GameCult.Networking is about 20k lines and its tests lean on socket timing, so a mutant timing out there says little about the test. A full Caching run is about 2,900 candidate -mutants (3,602 created, minus 730 that fail to compile) at about 2 s each, -about 1.5 hours; run it detached and read the JSON report, +mutants (3,602 created, minus 730 that fail to compile) at 1.25 to 1.6 s +each, so about 1 to 1.3 hours; run it detached and read the JSON report, never as one attached session. The first measurement was a narrow pass over the three files that carry the @@ -177,8 +177,10 @@ dotnet tool run dotnet-stryker \ -m '**/src/GameCult.Caching/CultElementIds.cs' ``` -A clean single run of that command tested 243 mutants and scored 86.79% in -9 minutes 55 seconds. The first measurement of it overlapped two Stryker +A clean single run of that command tests 243 to 265 mutants (the count moves with the revision) and scored 85.28% on +568a60fa, before any test of this scope was added; 86.79% on d38b5728, with +the first batch of tests; and 87.17% on 76d9e7d1, with the throwing-enumerator +case, in about 10 minutes. The first measurement of it overlapped two Stryker runs, so its numbers (and the 114 survivors it showed for `CultElementIds.cs`, where tests that kill a mutant by hand reported `Survived` or `Timeout`) are not measurements and are not quoted here. Never run two Stryker runs against From f9c72a2fd2ef42cf488eadd90b040e0126c04919 Mon Sep 17 00:00:00 2001 From: Libby Date: Fri, 13 Nov 2026 12:43:17 +0000 Subject: [PATCH 12/16] cultcache-rs: pin try entry never waits and error 32 never retried off Windows (tests only) (#166) --- packages/cultcache-rs/src/lib.rs | 45 ++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/packages/cultcache-rs/src/lib.rs b/packages/cultcache-rs/src/lib.rs index b37582d7..e7d56c10 100644 --- a/packages/cultcache-rs/src/lib.rs +++ b/packages/cultcache-rs/src/lib.rs @@ -8377,6 +8377,51 @@ mod tests { assert!(other.is_err()); } + // The try entry must answer at once while another handle holds the lock. The attempt runs on a thread and its + // answer is awaited with a timeout, so an entry that waits fails this test by timeout instead of hanging the suite. + #[cfg(unix)] + #[test] + fn the_try_entry_returns_at_once_while_the_lock_is_held() -> Result<()> { + let temp = tempfile::tempdir()?; + let path = temp.path().join("held.cc"); + let store = SingleFileMessagePackBackingStore::new(&path); + let holder = store.open_lock_file()?; + fs2::FileExt::lock_exclusive(&holder)?; + let (sender, receiver) = std::sync::mpsc::channel(); + let attempt = SingleFileMessagePackBackingStore::new(&path); + std::thread::spawn(move || { + let _ = sender.send(attempt.try_compare_exchange_snapshot(&[], &[snapshot_envelope("b", b"2")])); + }); + let outcome = receiver + .recv_timeout(std::time::Duration::from_secs(3)) + .map_err(|_| anyhow!("the try entry waited on a held lock instead of returning"))?; + assert!(matches!(outcome?, TryCompareExchangeSnapshotOutcome::LockContended)); + assert!(!path.exists(), "the contended attempt wrote"); + let again = store.open_lock_file()?; + assert!(fs2::FileExt::try_lock_exclusive(&again).is_err(), "the held lock was disturbed"); + fs2::FileExt::unlock(&holder)?; + Ok(()) + } + + // Off Windows errno 32 is EPIPE, never a busy file: the predicate is false and the wait surfaces it on the first try. + #[cfg(not(windows))] + #[test] + fn error_32_is_not_busy_off_windows_and_surfaces_at_once() { + assert!(!is_sharing_violation(&busy_error())); + let (mut tries, mut sleeps) = (0, 0); + let error = retry_while_busy::<()>( + || { + tries += 1; + Err(busy_error()) + }, + is_sharing_violation, + || sleeps += 1, + ) + .expect_err("error 32 must surface off Windows"); + assert_eq!(error.raw_os_error(), Some(32)); + assert_eq!((tries, sleeps), (1, 0)); + } + // The try entry has no wait, so the late window is the gap between its look and its lock: a link put there is refused // once the lock is held, nothing is written through or over it, and the lock is released. #[cfg(unix)] From 3d333e8466b57ebd6e139cf1f6dcfa221a428ac1 Mon Sep 17 00:00:00 2001 From: Libby Date: Fri, 13 Nov 2026 15:40:35 +0000 Subject: [PATCH 13/16] Caching: CultCache.Update, update-with-retry as the golden-path write (golden-path-write-api) (#168) --- docs/start/README.md | 33 +- src/GameCult.Caching/CultCache.cs | 78 +++ src/GameCult.Caching/CultInspectorModel.cs | 2 +- src/GameCult.Caching/CultVariants.cs | 5 + src/GameCult.Caching/README.md | 25 +- .../GameCult.Caching.Tests/FirstCallTests.cs | 31 +- .../ReadmeHappyPathTests.cs | 81 +++ .../GameCult.Caching.Tests/UpdateCallTests.cs | 54 ++ tests/GameCult.Caching.Tests/UpdateTests.cs | 515 ++++++++++++++++++ 9 files changed, 806 insertions(+), 18 deletions(-) create mode 100644 tests/GameCult.Caching.Tests/ReadmeHappyPathTests.cs create mode 100644 tests/GameCult.Caching.Tests/UpdateCallTests.cs create mode 100644 tests/GameCult.Caching.Tests/UpdateTests.cs diff --git a/docs/start/README.md b/docs/start/README.md index 9ca7f499..e9dc0b3f 100644 --- a/docs/start/README.md +++ b/docs/start/README.md @@ -16,7 +16,7 @@ this page. | | CultCache | CultMath | Geometry | CultNet | CultMesh | | --- | --- | --- | --- | --- | --- | -| **C#** | [first call, tested](#c-first-call) | [package](../../packages/cultmath/README.md); snippet not yet | [package](../../src/GameCult.Geometry/README.md); snippet not yet | [first call, tested](#c-cultnet-first-call); [package](../../src/GameCult.Networking) | [quickstart](../../src/GameCult.Mesh/docs/durable-node-quickstart.md); run by `scripts/verify-eve-getting-started.ps1` | +| **C#** | [first call, tested](#c-first-call); [update, tested](#c-update-with-retry) | [package](../../packages/cultmath/README.md); snippet not yet | [package](../../src/GameCult.Geometry/README.md); snippet not yet | [first call, tested](#c-cultnet-first-call); [package](../../src/GameCult.Networking) | [quickstart](../../src/GameCult.Mesh/docs/durable-node-quickstart.md); run by `scripts/verify-eve-getting-started.ps1` | | **TypeScript** | [first call, tested](#typescript-first-call) | not yet | not yet | [first call, tested](#typescript-cultnet-first-call); [package](../../packages/cultnet-ts/README.md) | [package](../../packages/cultmesh-ts/README.md); snippet not yet; [recipe: two-client .io game](io-game.md), tested with two live clients, browser page not yet run | | **Rust** | [first call, tested](#rust-first-call) | [native core](../../packages/cultmath/native/cultmath-core); snippet not yet | not yet | [first call, tested](#rust-cultnet-first-call); [package](../../packages/cultnet-rs/README.md), [examples](../../packages/cultnet-rs/examples) | [package](../../packages/cultmesh-rs); snippet not yet | | **Python** | [first call, tested](#python-first-call) | not yet | not yet | [first call, tested](#python-cultnet-first-call); [package](../../packages/cultnet-py/README.md) | [package](../../packages/cultmesh-py/README.md); snippet not yet | @@ -70,6 +70,37 @@ public static class FirstCall `FirstCall.Run` is the call; a second test in that file fails if the block above stops matching it. More: `src/GameCult.Caching/README.md`. +## C# update with retry + +Change a record you may not be the only one changing. `Update` reads the record, hands your function a private +copy, and commits the result only if the record is still the one it read. If another writer got there first, it +reads again and runs your function again, up to five times. The source is +[`tests/GameCult.Caching.Tests/UpdateCallTests.cs`](../../tests/GameCult.Caching.Tests/UpdateCallTests.cs); the +snippet reuses `Item` from the first call. + +```csharp +using GameCult.Caching; +using GameCult.Caching.MessagePack; + +public static class UpdateCall +{ + public static CultUpdateResult Restock(string path, CultRecordKey key) + { + var cache = CultCacheMessagePack.Create(path); + return cache.Update(key, item => + { + item ??= new Item { Name = "Potion", Category = "Consumable" }; + item.Value += 10; + return item; + }); + } +} +``` + +The write is durable when `Update` returns, with no `PushAll`. A lost race re-runs the function, so keep it free of +side effects; when the attempts run out the result says `LostRace` instead of throwing. `UpsertAsync` plus +`PushAll` is the blind opt-out: last writer wins. + ## TypeScript first call Same shape in TypeScript: the document is a name, a schema id and a `parse`-able schema (zod here), diff --git a/src/GameCult.Caching/CultCache.cs b/src/GameCult.Caching/CultCache.cs index 469fc7af..dc0c8b99 100644 --- a/src/GameCult.Caching/CultCache.cs +++ b/src/GameCult.Caching/CultCache.cs @@ -2052,6 +2052,29 @@ namespace GameCult.Caching Contended } + public enum CultUpdateOutcome + { + Updated, + Declined, + LostRace + } + + /// + /// What did. A lost race is a value, not an exception. is the instance the + /// cache holds at the key when Update returns: the written one for Updated, null when the key is absent. + /// + public readonly struct CultUpdateResult where T : class + { + public CultUpdateResult(CultUpdateOutcome outcome, T? document) + { + Outcome = outcome; + Document = document; + } + + public CultUpdateOutcome Outcome { get; } + public T? Document { get; } + } + public sealed class CultCommitRequest { internal CultCommitRequest( @@ -2563,6 +2586,7 @@ namespace GameCult.Caching return UpsertAsync(document, handle); } + // A blind write, last-writer-wins per key; Update is the conditional golden path. // Refused with CultSchemaConflictException when the key holds a record stored under an id no registered type declares. public Task> UpsertAsync(T document, CultRecordHandle? handle = null) { @@ -2639,6 +2663,7 @@ namespace GameCult.Caching /// /// Commits the staged batch. Returns false when a condition failed: nothing was written, changed in memory, or published. + /// Without Expect a commit is blind; retries a conditional one. /// /// /// A commit that succeeds and then fails in a journal or observer still stands and is published, and the writer @@ -2656,6 +2681,59 @@ namespace GameCult.Caching return Land(stage, wait: false); } + /// + /// Reads the record at , applies to a private copy of it, and commits the result only + /// if the record is still the one that was read; when another writer got there first it re-reads and runs + /// again, up to times. The write is durable when Update returns. + /// + /// + /// may run up to times. It gets a private copy (null when the key holds + /// nothing), which it may change and return. It must not write to the cache or have other side effects. Return null to write + /// nothing. A store with staged blind writes refuses Update until it is flushed, as it refuses any conditional commit. The cache + /// needs a codec to copy with. Running out of attempts returns ; every other failure throws. + /// + public CultUpdateResult Update(CultRecordKey key, Func apply, int maxAttempts = 5) where T : class + { + if (apply == null) throw new ArgumentNullException(nameof(apply)); + if (maxAttempts < 1) throw new ArgumentOutOfRangeException(nameof(maxAttempts), maxAttempts, "At least one attempt is required."); + var codec = _codec ?? throw new InvalidOperationException("This cache has no codec; create it with CultCacheMessagePack.Create."); + for (var attempt = 1; ; attempt++) + { + var (version, copy) = Held(() => + { + if (!_entries.TryGetValue(key.Value, out var held)) + return ((string?)null, (T?)null); + if (held.Variant != null || held.Document is not T) + throw new InvalidOperationException($"Record {key.Value} is a variant or not a {typeof(T).Name}; Update cannot copy it."); + return (held.StoredVersion, (T?)codec.Clone(held.Document, held.Descriptor.DocumentType)); + }); + var next = apply(copy); + if (next == null) + return new CultUpdateResult(CultUpdateOutcome.Declined, Get(key)); + CultCommitOutcome outcome; + try + { + outcome = Land(batch => + { + batch.Expected.Add((key, version)); + batch.Upsert(next, new CultRecordHandle(key)); + }, wait: true); + } + catch (CultWriteConflictException conflict) when (conflict.InnerException == null) + { + outcome = CultCommitOutcome.Mismatch; + } + + if (outcome == CultCommitOutcome.Committed) + return new CultUpdateResult(CultUpdateOutcome.Updated, next); + if (outcome != CultCommitOutcome.Mismatch) + throw new InvalidOperationException($"Update of {key.Value} was refused: {outcome}."); + Held(() => { Home(next.GetType())?.PullAll(); return true; }); + if (attempt >= maxAttempts) + return new CultUpdateResult(CultUpdateOutcome.LostRace, Get(key)); + } + } + public CultRecordHandle? TryGetHandle(T document) { if (document == null) throw new ArgumentNullException(nameof(document)); diff --git a/src/GameCult.Caching/CultInspectorModel.cs b/src/GameCult.Caching/CultInspectorModel.cs index 7aa6ab74..60dec974 100644 --- a/src/GameCult.Caching/CultInspectorModel.cs +++ b/src/GameCult.Caching/CultInspectorModel.cs @@ -352,7 +352,7 @@ namespace GameCult.Caching } } - public object Clone(object document, Type type) => _deserialize(type, _serialize(document, type, type)); + public object Clone(object document, Type type) => _codec.Clone(document, type); public CultInspectorEdit BeginEdit(CultStoredDocument record) { diff --git a/src/GameCult.Caching/CultVariants.cs b/src/GameCult.Caching/CultVariants.cs index 48909c19..e5c28bbf 100644 --- a/src/GameCult.Caching/CultVariants.cs +++ b/src/GameCult.Caching/CultVariants.cs @@ -29,6 +29,11 @@ namespace GameCult.Caching // (document payload, slot and encoded value pairs) public Func>, byte[]> Overlay { get; } + // The one copy of a document: its encoding, decoded again. Update's private argument and CultInspectorModel.Clone share it. + internal object Clone(object document, Type type) => + Deserialize(type, type, Serialize(document, type, type)) + ?? throw new InvalidOperationException($"The codec returned null for {type.FullName}."); + /// /// Decodes a document payload read from a store file, as a cache load decodes it: the ids a load mints are minted from the key /// and their path, so every reader of one store mints the same ids, and a payload a cache write would refuse is refused here too. diff --git a/src/GameCult.Caching/README.md b/src/GameCult.Caching/README.md index 6c76f5cd..dffce80a 100644 --- a/src/GameCult.Caching/README.md +++ b/src/GameCult.Caching/README.md @@ -13,6 +13,7 @@ Cacheable models are plain classes annotated with cache intent. using GameCult.Caching; using MessagePack; +[MessagePackObject] [CultDocument("gamecult.item_data", "gamecult.item_data.v1")] public sealed class ItemData { @@ -131,18 +132,33 @@ using GameCult.Caching; using GameCult.Caching.MessagePack; var cache = await CultCacheMessagePack.OpenAsync("Data.msgpack"); +var key = new CultRecordKey("potion"); +// The golden path: read, change a private copy, commit only if the record is unchanged, re-run on a lost race. +// Durable when it returns; no flush. +var result = cache.Update(key, item => +{ + item ??= new ItemData { Name = "Potion", Sku = "POT-1", Category = "Consumable" }; + item.Value += 10; + return item; +}); + +// The blind opt-out: last writer wins per key. Name the key, or a new key claims the same unique Sku and is refused. var handle = await cache.UpsertAsync(new ItemData { Name = "Potion", Sku = "POT-1", Category = "Consumable", Value = 50 -}); +}, new CultRecordHandle(key)); await cache.FlushAsync(); ``` +`Update` may run your function more than once, so it must have no side effects. Returning null writes nothing, and a +lost race that outlasts `maxAttempts` comes back as `CultUpdateOutcome.LostRace`. `UpsertAsync` plus `FlushAsync` never +checks what it overwrites. + If you want the explicit assembly rite, the lower-level `CultCache` + `SingleFileMessagePackBackingStore` path is still there. The helper is just the blessed front door. @@ -294,13 +310,10 @@ Useful surfaces: - `cache.IsDirty` - `store.IsDirty` - `cache.Document(key)` -- `cache.Soa()` -- `cache.UpsertAsync(document)` +- `cache.Update(key, apply)` +- `cache.UpsertAsync(document)` (blind write) - `cache.FlushAllBackingStores()` - `cache.FlushAsync()` -- `cache.FlushBackingStore(store)` -- `cache.PrepareForReloadOrShutdown()` -- `cache.PrepareForReloadOrShutdownAsync()` - `cache.FlushAttachedStoresOnDispose` - `store.FlushOnDispose` diff --git a/tests/GameCult.Caching.Tests/FirstCallTests.cs b/tests/GameCult.Caching.Tests/FirstCallTests.cs index 8fad1229..d8febc49 100644 --- a/tests/GameCult.Caching.Tests/FirstCallTests.cs +++ b/tests/GameCult.Caching.Tests/FirstCallTests.cs @@ -62,17 +62,28 @@ namespace GameCult.Caching.Tests } [Test] - public void DocsShowTheTestedSnippet() - { - var source = File.ReadAllText(SourcePath()); - const string begin = "// snippet:begin\n"; - var start = source.IndexOf(begin, StringComparison.Ordinal) + begin.Length; - var snippet = source[start..source.IndexOf("// snippet:end\n", StringComparison.Ordinal)]; - var docs = File.ReadAllText(Path.Combine(Path.GetDirectoryName(SourcePath())!, "..", "..", "docs", "start", "README.md")); - Assert.That(docs.Replace("\r\n", "\n"), Does.Contain("```csharp\n" + snippet + "```"), - "docs/start/README.md no longer shows tests/GameCult.Caching.Tests/FirstCallTests.cs verbatim"); - } + public void DocsShowTheTestedSnippet() => StartDocs.AssertShowsSnippet(SourcePath()); static string SourcePath([System.Runtime.CompilerServices.CallerFilePath] string path = "") => path; } + + // docs/start/README.md shows each tested snippet verbatim: the region between snippet:begin and snippet:end of its source file. + internal static class StartDocs + { + public static void AssertShowsSnippet(string sourcePath) => + AssertShowsSnippet(sourcePath, Path.Combine("..", "..", "docs", "start", "README.md")); + + // The same check against another document, its path relative to the directory of sourcePath. The snippet there is the + // statements of a block, so it need not open the fence (the block's usings come first). + public static void AssertShowsSnippet(string sourcePath, string docsRelativePath, bool wholeBlock = true) + { + var source = File.ReadAllText(sourcePath).Replace("\r\n", "\n"); + const string begin = "// snippet:begin\n"; + var start = source.IndexOf(begin, StringComparison.Ordinal) + begin.Length; + var snippet = source[start..source.IndexOf("// snippet:end\n", StringComparison.Ordinal)]; + var docs = File.ReadAllText(Path.Combine(Path.GetDirectoryName(sourcePath)!, docsRelativePath)); + Assert.That(docs.Replace("\r\n", "\n"), Does.Contain(wholeBlock ? "```csharp\n" + snippet + "```" : snippet), + $"{docsRelativePath} no longer shows {Path.GetFileName(sourcePath)} verbatim"); + } + } } diff --git a/tests/GameCult.Caching.Tests/ReadmeHappyPathTests.cs b/tests/GameCult.Caching.Tests/ReadmeHappyPathTests.cs new file mode 100644 index 00000000..de2dabb4 --- /dev/null +++ b/tests/GameCult.Caching.Tests/ReadmeHappyPathTests.cs @@ -0,0 +1,81 @@ +#nullable enable +using System; +using System.IO; +using System.Threading.Tasks; +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using MessagePack; +using NUnit.Framework; + +namespace GameCult.Caching.Tests +{ + // The Happy Path of src/GameCult.Caching/README.md, run as written: its statements are the region between snippet:begin and + // snippet:end below, and ReadmeShowsTheTestedSnippet fails when the README stops showing them verbatim. + public class ReadmeHappyPathTests + { + [CultDocument("gamecult.item_data", "gamecult.item_data.v1")] + [MessagePackObject] + public sealed class ItemData + { + [Key(0)] [CultName] public string Name = string.Empty; + [Key(1)] [CultIndex] public string Sku = string.Empty; + [Key(2)] public string Category = string.Empty; + [Key(3)] public int Value; + } + + private static async Task HappyPath() + { +// snippet:begin +var cache = await CultCacheMessagePack.OpenAsync("Data.msgpack"); +var key = new CultRecordKey("potion"); + +// The golden path: read, change a private copy, commit only if the record is unchanged, re-run on a lost race. +// Durable when it returns; no flush. +var result = cache.Update(key, item => +{ + item ??= new ItemData { Name = "Potion", Sku = "POT-1", Category = "Consumable" }; + item.Value += 10; + return item; +}); + +// The blind opt-out: last writer wins per key. Name the key, or a new key claims the same unique Sku and is refused. +var handle = await cache.UpsertAsync(new ItemData +{ + Name = "Potion", + Sku = "POT-1", + Category = "Consumable", + Value = 50 +}, new CultRecordHandle(key)); + +await cache.FlushAsync(); +// snippet:end + return cache; + } + + [Test] + public async Task HappyPathRunsAsWritten() + { + var directory = Path.Combine(Path.GetTempPath(), $"readme-happy-{Guid.NewGuid():N}"); + Directory.CreateDirectory(directory); + var previous = Environment.CurrentDirectory; + try + { + Environment.CurrentDirectory = directory; // the README opens a relative path + using var cache = await HappyPath(); + Assert.That(cache.Get(new CultRecordKey("potion"))!.Value, Is.EqualTo(50)); + Assert.That(cache.GetByIndex("Sku", "POT-1"), Is.Not.Null); + } + finally + { + Environment.CurrentDirectory = previous; + Directory.Delete(directory, recursive: true); + } + } + + [Test] + public void ReadmeShowsTheTestedSnippet() => + StartDocs.AssertShowsSnippet(SourcePath(), Path.Combine("..", "..", "src", "GameCult.Caching", "README.md"), wholeBlock: false); + + static string SourcePath([System.Runtime.CompilerServices.CallerFilePath] string path = "") => path; + } +} diff --git a/tests/GameCult.Caching.Tests/UpdateCallTests.cs b/tests/GameCult.Caching.Tests/UpdateCallTests.cs new file mode 100644 index 00000000..d9a8b31e --- /dev/null +++ b/tests/GameCult.Caching.Tests/UpdateCallTests.cs @@ -0,0 +1,54 @@ +#nullable enable +using System; +using System.IO; +using NUnit.Framework; + +// snippet:begin +using GameCult.Caching; +using GameCult.Caching.MessagePack; + +public static class UpdateCall +{ + public static CultUpdateResult Restock(string path, CultRecordKey key) + { + var cache = CultCacheMessagePack.Create(path); + return cache.Update(key, item => + { + item ??= new Item { Name = "Potion", Category = "Consumable" }; + item.Value += 10; + return item; + }); + } +} +// snippet:end + +namespace GameCult.Caching.Tests +{ + // The C# update snippet that docs/start/README.md shows verbatim; it reuses FirstCallTests' Item. + public class UpdateCallTests + { + [Test] + public void RestockTwiceReadsTwenty() + { + var path = Path.Combine(Path.GetTempPath(), $"update-call-{Guid.NewGuid():N}.cc"); + try + { + var key = new CultRecordKey("potion"); + Assert.That(UpdateCall.Restock(path, key).Outcome, Is.EqualTo(CultUpdateOutcome.Updated)); + Assert.That(UpdateCall.Restock(path, key).Outcome, Is.EqualTo(CultUpdateOutcome.Updated)); + + using var reader = CultCacheMessagePack.Create(path); + Assert.That(reader.Get(key)!.Value, Is.EqualTo(20)); + } + finally + { + File.Delete(path); + } + } + + [Test] + public void DocsShowTheTestedSnippet() => StartDocs.AssertShowsSnippet(SourcePath()); + + static string SourcePath([System.Runtime.CompilerServices.CallerFilePath] string path = "") => path; + } +} diff --git a/tests/GameCult.Caching.Tests/UpdateTests.cs b/tests/GameCult.Caching.Tests/UpdateTests.cs new file mode 100644 index 00000000..0dd96bad --- /dev/null +++ b/tests/GameCult.Caching.Tests/UpdateTests.cs @@ -0,0 +1,515 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using MessagePack; +using NUnit.Framework; +using R3; + +namespace GameCult.Caching.Tests +{ + // CultCache.Update: read the content version, apply a pure function to a private copy, commit conditionally, re-run on a lost + // race (variants:ruling:write-set-content-version-declared-ownership; variants:question:update-apply-copy). + public class UpdateTests + { + private static readonly CultRecordKey Key = new("counter"); + private static readonly CultDocumentRegistry Registry = + CultDocumentRegistry.ForTypes(new[] { typeof(UpdateCounter), typeof(UpdateTally), typeof(UpdateGear) }); + + private string _directory = string.Empty; + + [SetUp] + public void CreateDirectory() + { + _directory = Path.Combine(Path.GetTempPath(), $"cultlib-update-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_directory); + } + + [TearDown] + public void DeleteDirectory() + { + if (Directory.Exists(_directory)) + Directory.Delete(_directory, recursive: true); + } + + private string PathOf(string name) => Path.Combine(_directory, name); + + private static CultCache Open(string path, bool directory, bool codec = true) + { + var cache = codec ? new CultCache(Registry, CultCacheMessagePack.CreateCodec(Registry)) : new CultCache(Registry); + cache.AddBackingStore(directory ? new DirectoryMessagePackBackingStore(path) : new SingleFileMessagePackBackingStore(path)); + return cache; + } + + private static void Seed(string path, bool directory, int value) + { + using var cache = Open(path, directory); + Assert.That(cache.Commit(batch => batch.Upsert(new UpdateCounter { Name = "counter", Value = value }, new CultRecordHandle(Key))), Is.True); + } + + private static void Rival(CultCache cache, int value) => + Assert.That(cache.Commit(batch => batch.Upsert(new UpdateCounter { Name = "counter", Value = value }, new CultRecordHandle(Key))), Is.True); + + private static int StoredValue(string path, bool directory) + { + using var reader = Open(path, directory); + return reader.Get(Key)!.Value; + } + + private static UpdateCounter Plus(UpdateCounter? counter, int by) + { + counter ??= new UpdateCounter { Name = "counter" }; + counter.Value += by; + return counter; + } + + [TestCase(false)] + [TestCase(true)] + public void TwoWritersUpdatingLoseNoIncrement(bool directory) + { + const int n = 100; + var path = PathOf("race.cc"); + Seed(path, directory, 0); + using var a = Open(path, directory); + using var b = Open(path, directory); + var applied = 0; + + var racers = new[] { a, b }.Select(cache => Task.Run(() => + { + var results = new List(); + for (var i = 0; i < n; i++) + results.Add(cache.Update(Key, counter => + { + Interlocked.Increment(ref applied); + return Plus(counter, 1); + }, maxAttempts: 200).Outcome); + return results; + })).ToArray(); + Assert.That(Task.WaitAll(racers, TimeSpan.FromMinutes(2)), Is.True, "the racers did not finish"); + + Assert.That(racers.SelectMany(racer => racer.Result), Is.All.EqualTo(CultUpdateOutcome.Updated)); + Assert.That(StoredValue(path, directory), Is.EqualTo(2 * n)); + Assert.That(applied, Is.GreaterThan(2 * n), "the writers never actually raced"); + } + + [Test] + public void LostAttemptRerunsApplyOnTheFreshValue() + { + var path = PathOf("rerun.cc"); + Seed(path, directory: false, 1); + using var cache = Open(path, false); + using var rival = Open(path, false); + var seen = new List(); + + var result = cache.Update(Key, counter => + { + seen.Add(counter!.Value); + if (seen.Count == 1) + Rival(rival, 100); + return Plus(counter, 1); + }); + + Assert.That(seen, Is.EqualTo(new[] { 1, 100 })); + Assert.That(result.Outcome, Is.EqualTo(CultUpdateOutcome.Updated)); + Assert.That(result.Document!.Value, Is.EqualTo(101)); + Assert.That(StoredValue(path, false), Is.EqualTo(101)); + } + + [Test] + public void ExhaustedAttemptsReturnLostRace() + { + var path = PathOf("lost.cc"); + Seed(path, false, 1); + using var cache = Open(path, false); + using var rival = Open(path, false); + var calls = 0; + + var result = cache.Update(Key, counter => + { + Rival(rival, 100 * ++calls); + return Plus(counter, 1); + }, maxAttempts: 3); + + Assert.That(calls, Is.EqualTo(3)); + Assert.That(result.Outcome, Is.EqualTo(CultUpdateOutcome.LostRace)); + Assert.That(result.Document!.Value, Is.EqualTo(300)); + Assert.That(StoredValue(path, false), Is.EqualTo(300), "the store holds the rival's last write"); + } + + [Test] + public void ApplyGetsAPrivateCopy() + { + var path = PathOf("copy.cc"); + Seed(path, false, 1); + using var cache = Open(path, false); + using var rival = Open(path, false); + var published = new List(); + using var subscription = cache.Watch().Subscribe(change => published.Add(change.Document!.Value)); + var arguments = new List(); + var calls = 0; + + var result = cache.Update(Key, counter => + { + calls++; + arguments.Add(counter!.Value); + Assert.That(counter, Is.Not.SameAs(cache.Get(Key)), "apply was handed the held instance"); + counter.Value += 1000; + Assert.That(cache.Get(Key)!.Value, Is.Not.EqualTo(counter.Value), "the change reached the cache before any commit"); + if (calls == 1) + Rival(rival, 5); + return counter; + }); + + Assert.That(arguments, Is.EqualTo(new[] { 1, 5 }), "the second call saw the first call's change"); + Assert.That(result.Outcome, Is.EqualTo(CultUpdateOutcome.Updated)); + Assert.That(published, Does.Not.Contain(1001), "an uncommitted value was published"); + Assert.That(published.Count(value => value == 1005), Is.EqualTo(1)); + Assert.That(StoredValue(path, false), Is.EqualTo(1005)); + } + + [Test] + public void NullFromApplyDeclines() + { + var path = PathOf("decline.cc"); + Seed(path, false, 7); + var before = File.ReadAllBytes(path); + using var cache = Open(path, false); + var changes = 0; + using var subscription = cache.Watch().Subscribe(_ => changes++); + + var result = cache.Update(Key, _ => null); + + Assert.That(result.Outcome, Is.EqualTo(CultUpdateOutcome.Declined)); + Assert.That(result.Document!.Value, Is.EqualTo(7)); + Assert.That(File.ReadAllBytes(path), Is.EqualTo(before)); + Assert.That(changes, Is.EqualTo(0)); + } + + [TestCase(false)] + [TestCase(true)] + public void AbsentKeyCreatesOnceUnderRace(bool directory) + { + var path = PathOf("create.cc"); + using var a = Open(path, directory); + using var b = Open(path, directory); + var started = new Barrier(2); + + var racers = new[] { a, b }.Select(cache => Task.Run(() => + { + started.SignalAndWait(); + return cache.Update(Key, counter => Plus(counter, 1)).Outcome; + })).ToArray(); + Assert.That(Task.WaitAll(racers, TimeSpan.FromMinutes(1)), Is.True); + + Assert.That(racers.Select(racer => racer.Result), Is.All.EqualTo(CultUpdateOutcome.Updated)); + Assert.That(StoredValue(path, directory), Is.EqualTo(2)); + } + + [Test] + public void RefusalsAreNotRetried() + { + var path = PathOf("refuse.cc"); + Seed(path, false, 1); + var before = File.ReadAllBytes(path); + var calls = 0; + Func apply = counter => { calls++; return Plus(counter, 1); }; + + using (var codecLess = Open(path, false, codec: false)) + Assert.That(() => codecLess.Update(Key, apply), Throws.InvalidOperationException); + using (var cache = Open(path, false)) + { + Assert.That(() => cache.Update(Key, apply, maxAttempts: 0), Throws.TypeOf()); + Assert.That(() => cache.Update(Key, null!), Throws.TypeOf()); + Assert.That(() => cache.Update(Key, _ => new UpdateTally { Name = "counter" }), Throws.InvalidOperationException); + } + + using (var cache = Open(path, false)) + { + cache.Commit(batch => + { + batch.Upsert(new UpdateGear { Name = "gear", Power = 1 }, new CultRecordHandle(new CultRecordKey("gear"))); + }); + cache.Commit(batch => batch.UpsertVariant(new CultRecordKey("gear-big"), new CultRecordKey("gear"), + new[] { cache.Override(nameof(UpdateGear.Name), "gear big"), cache.Override(nameof(UpdateGear.Power), 9) })); + } + + before = File.ReadAllBytes(path); + var gearCalls = 0; + using (var cache = Open(path, false)) + Assert.That(() => cache.Update(new CultRecordKey("gear-big"), gear => { gearCalls++; return gear; }), Throws.InvalidOperationException); + Assert.That(gearCalls, Is.EqualTo(0), "apply ran for a variant"); + Assert.That(calls, Is.EqualTo(0), "apply ran for a refused call"); + Assert.That(File.ReadAllBytes(path), Is.EqualTo(before)); + + using (var cache = Open(path, false)) + { + cache.UpsertAsync(new UpdateTally { Name = "staged" }).GetAwaiter().GetResult(); + Assert.That(() => cache.Update(Key, apply), Throws.InvalidOperationException.With.Message.Contains("flush before a conditional commit")); + Assert.That(calls, Is.LessThanOrEqualTo(1)); + } + + Assert.That(File.ReadAllBytes(path), Is.EqualTo(before), "a refusal wrote"); + } + + [Test] + public void VariantStoreConflictIsALostAttempt() + { + var path = PathOf("variant.cc"); + Seed(path, false, 1); + using (var seed = Open(path, false)) + { + seed.Commit(batch => batch.Upsert(new UpdateGear { Name = "gear", Power = 1 }, new CultRecordHandle(new CultRecordKey("gear")))); + seed.Commit(batch => batch.UpsertVariant(new CultRecordKey("gear-big"), new CultRecordKey("gear"), + new[] { seed.Override(nameof(UpdateGear.Name), "gear big"), seed.Override(nameof(UpdateGear.Power), 9) })); + } + + using var cache = Open(path, false); + using (var rival = Open(path, false)) + Assert.That(rival.Commit(batch => batch.Upsert(new UpdateTally { Name = "other" }, new CultRecordHandle(new CultRecordKey("other")))), Is.True); + var calls = 0; + + var result = cache.Update(Key, counter => { calls++; return Plus(counter, 1); }); + + Assert.That(result.Outcome, Is.EqualTo(CultUpdateOutcome.Updated)); + Assert.That(calls, Is.EqualTo(2), "the conflict was not a lost attempt that re-ran apply"); + Assert.That(StoredValue(path, false), Is.EqualTo(2)); + } + + private static CultPersistedStoreSnapshot ReadSnapshot(string path) => + CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path)); + + // A reload that fails inside a conflict is a read failure, not a lost race: Update lets it propagate and never re-runs apply. + // Here the gear a variant is based on has gone foreign in the file, so the reload the conflict owes cannot drop it. + [Test] + public void ConflictWhoseReloadFailedPropagates() + { + var path = PathOf("reload-failed.cc"); + Seed(path, false, 1); + using var cache = Open(path, false); + cache.Commit(batch => batch.Upsert(new UpdateGear { Name = "gear", Power = 1 }, new CultRecordHandle(new CultRecordKey("gear")))); + cache.Commit(batch => batch.UpsertVariant(new CultRecordKey("gear-big"), new CultRecordKey("gear"), + new[] { cache.Override(nameof(UpdateGear.Name), "gear big"), cache.Override(nameof(UpdateGear.Power), 9) })); + const string foreignId = "sha256:00000000000000000000000000000000000000000000000000000000000050de"; + var snapshot = ReadSnapshot(path); + snapshot.Records.Single(record => record.Key == "gear").SchemaId = foreignId; + snapshot.SchemaCatalog = snapshot.SchemaCatalog.Append(new CultSchemaCatalogEntry + { + SchemaId = foreignId, SchemaName = "tests.update_foreign", SchemaVersion = "tests.update_foreign.v1", + ContentHash = "foreign", CanonicalSchemaJson = "{}" + }).ToArray(); + File.WriteAllBytes(path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot)); + var calls = 0; + + var conflict = Assert.Throws(() => cache.Update(Key, counter => { calls++; return Plus(counter, 1); }))!; + + Assert.That(conflict.InnerException, Is.Not.Null, "the reload that failed is the cause"); + Assert.That(calls, Is.EqualTo(1), "a failed reload was retried"); + } + + [TestCase(false)] + [TestCase(true)] + public void RivalWritingAndRestoringTheValueStillRerunsApply(bool directory) + { + var path = PathOf("aba.cc"); + Seed(path, directory, 1); + using var cache = Open(path, directory); + using var rival = Open(path, directory); + var seen = new List(); + + var result = cache.Update(Key, counter => + { + seen.Add(counter!.Value); + if (seen.Count == 1) + { + Rival(rival, 5); + Rival(rival, 1); + } + return Plus(counter, 1); + }); + + Assert.That(seen, Is.EqualTo(new[] { 1, 1 }), "the content version missed A-B-A"); + Assert.That(result.Outcome, Is.EqualTo(CultUpdateOutcome.Updated)); + Assert.That(StoredValue(path, directory), Is.EqualTo(2)); + } + + [TestCase(false)] + [TestCase(true)] + public void RivalRemovalIsSeenAsNull(bool directory) + { + var path = PathOf("removal.cc"); + Seed(path, directory, 1); + using var cache = Open(path, directory); + using var rival = Open(path, directory); + var seen = new List(); + + var result = cache.Update(Key, counter => + { + seen.Add(counter?.Value); + if (seen.Count == 1) + rival.Commit(batch => batch.Remove(Key)); + return Plus(counter, 1); + }); + + Assert.That(seen, Is.EqualTo(new int?[] { 1, null })); + Assert.That(result.Outcome, Is.EqualTo(CultUpdateOutcome.Updated)); + Assert.That(StoredValue(path, directory), Is.EqualTo(1)); + } + + [Test] + public void ExhaustedAttemptsReturnLostRaceInADirectoryStore() + { + var path = PathOf("lost-dir.cc"); + Seed(path, true, 1); + using var cache = Open(path, true); + using var rival = Open(path, true); + var calls = 0; + + var result = cache.Update(Key, counter => + { + Rival(rival, 100 * ++calls); + return Plus(counter, 1); + }, maxAttempts: 2); + + Assert.That(calls, Is.EqualTo(2)); + Assert.That(result.Outcome, Is.EqualTo(CultUpdateOutcome.LostRace)); + Assert.That(result.Document!.Value, Is.EqualTo(200)); + Assert.That(StoredValue(path, true), Is.EqualTo(200)); + } + + [Test] + public void OneAttemptMeansOneApplyAndNonPositiveThrows() + { + var path = PathOf("one.cc"); + Seed(path, false, 1); + using var cache = Open(path, false); + using var rival = Open(path, false); + var calls = 0; + + var result = cache.Update(Key, counter => { calls++; Rival(rival, 50); return Plus(counter, 1); }, maxAttempts: 1); + + Assert.That(calls, Is.EqualTo(1)); + Assert.That(result.Outcome, Is.EqualTo(CultUpdateOutcome.LostRace)); + Assert.That(() => cache.Update(Key, counter => counter, maxAttempts: -1), Throws.TypeOf()); + Assert.That(calls, Is.EqualTo(1)); + } + + [TestCase(false)] + [TestCase(true)] + public void UpdatedDocumentIsTheHeldInstanceAndOwnWritesCauseNoRetry(bool directory) + { + var path = PathOf("own.cc"); + Seed(path, directory, 1); + using var cache = Open(path, directory); + cache.UpsertAsync(new UpdateCounter { Name = "counter", Value = 10 }, new CultRecordHandle(Key)).GetAwaiter().GetResult(); + cache.FlushAsync().GetAwaiter().GetResult(); + var calls = 0; + + var first = cache.Update(Key, counter => { calls++; return Plus(counter, 1); }); + var second = cache.Update(Key, counter => { calls++; return Plus(counter, 1); }); + + Assert.That(calls, Is.EqualTo(2), "the cache's own writes made a later Update retry"); + Assert.That(first.Document, Is.Not.Null); + Assert.That(second.Document, Is.SameAs(cache.Get(Key)), "Updated returned a copy, not the held instance"); + Assert.That(StoredValue(path, directory), Is.EqualTo(12)); + } + + [Test] + public void ApplyRunsOutsideTheCacheGate() + { + var path = PathOf("gate.cc"); + Seed(path, false, 1); + using var cache = Open(path, false); + var reached = false; + + cache.Update(Key, counter => + { + reached = Task.Run(() => cache.Get(Key)).Wait(TimeSpan.FromSeconds(10)); + return Plus(counter, 1); + }); + + Assert.That(reached, Is.True, "another thread could not read the cache while apply ran"); + } + + [TestCase(false)] + [TestCase(true)] + public void ContendedLedgerStoredValueEqualsUpdatedCount(bool directory) + { + var path = PathOf("ledger.cc"); + Seed(path, directory, 0); + var caches = Enumerable.Range(0, 3).Select(_ => Open(path, directory)).ToArray(); + try + { + var tasks = caches.Select(cache => Task.Run(() => + { + var updated = 0; + for (var i = 0; i < 25; i++) + if (cache.Update(Key, counter => Plus(counter, 1)).Outcome == CultUpdateOutcome.Updated) + updated++; + return updated; + })).ToArray(); + Assert.That(Task.WaitAll(tasks, TimeSpan.FromMinutes(2)), Is.True); + + Assert.That(StoredValue(path, directory), Is.EqualTo(tasks.Sum(task => task.Result)), "an Updated outcome was lost or a LostRace wrote"); + } + finally + { + foreach (var cache in caches) + cache.Dispose(); + } + } + + [Test] + public void InMemoryCacheUpdatesUnderThreadRace() + { + using var cache = new CultCache(Registry, CultCacheMessagePack.CreateCodec(Registry)); + cache.Commit(batch => batch.Upsert(new UpdateCounter { Name = "counter" }, new CultRecordHandle(Key))); + + var threads = Enumerable.Range(0, 2).Select(_ => Task.Run(() => + { + for (var i = 0; i < 100; i++) + Assert.That(cache.Update(Key, counter => Plus(counter, 1), maxAttempts: 200).Outcome, Is.EqualTo(CultUpdateOutcome.Updated)); + })).ToArray(); + Assert.That(Task.WaitAll(threads, TimeSpan.FromMinutes(1)), Is.True); + + Assert.That(cache.Get(Key)!.Value, Is.EqualTo(200)); + } + + [CultDocument("tests.update_counter", "tests.update_counter.v1")] + [MessagePackObject(AllowPrivate = true)] + internal sealed class UpdateCounter + { + [Key(0)] + [CultName] + public string Name = string.Empty; + + [Key(1)] + public int Value; + } + + [CultDocument("tests.update_tally", "tests.update_tally.v1")] + [MessagePackObject(AllowPrivate = true)] + internal sealed class UpdateTally + { + [Key(0)] + [CultName] + public string Name = string.Empty; + } + + [CultDocument("tests.update_gear", "tests.update_gear.v1")] + [MessagePackObject(AllowPrivate = true)] + internal sealed class UpdateGear + { + [Key(0)] + [CultName] + public string Name = string.Empty; + + [Key(1)] + public int Power; + } + } +} From 70f21da06a189f5c6b9d6bb19a3ab90c845dc524 Mon Sep 17 00:00:00 2001 From: Libby Date: Fri, 13 Nov 2026 18:49:46 +0000 Subject: [PATCH 14/16] cultcache-rs: expose a store's schema catalog (versions) to consumers (#167) --- packages/cultcache-rs/src/lib.rs | 43 +++++ packages/cultcache-rs/tests/schema_catalog.rs | 165 ++++++++++++++++++ 2 files changed, 208 insertions(+) create mode 100644 packages/cultcache-rs/tests/schema_catalog.rs diff --git a/packages/cultcache-rs/src/lib.rs b/packages/cultcache-rs/src/lib.rs index e7d56c10..8fccdf97 100644 --- a/packages/cultcache-rs/src/lib.rs +++ b/packages/cultcache-rs/src/lib.rs @@ -480,6 +480,39 @@ struct PersistedSchemaCatalogEntry( Vec, ); +/// One entry of a store file's schema catalog, read-only: the scalar fields every runtime publishes for a schema id. The members and +/// the canonical schema JSON the file also carries are not exposed. `schema_version` is the version string the writer published +/// (C# `CultSchemaCatalogEntry.SchemaVersion`). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SchemaCatalogEntry { + pub schema_id: String, + pub schema_name: String, + pub schema_version: String, + pub content_hash: String, + pub compatible_schema_ids: Vec, +} + +impl From<&PersistedSchemaCatalogEntry> for SchemaCatalogEntry { + fn from(entry: &PersistedSchemaCatalogEntry) -> Self { + Self { + schema_id: entry.0.clone(), + schema_name: entry.1.clone(), + schema_version: entry.2.clone(), + content_hash: entry.3.clone(), + compatible_schema_ids: entry.5.clone(), + } + } +} + +/// The catalog entry a record's schema id names, as the decoder resolves it: the first entry that has the id as its own, else the +/// first entry that lists it as compatible; `None` when the catalog publishes neither. +pub fn resolve_schema_entry<'a>(catalog: &'a [SchemaCatalogEntry], schema_id: &str) -> Option<&'a SchemaCatalogEntry> { + catalog + .iter() + .find(|entry| entry.schema_id == schema_id) + .or_else(|| catalog.iter().find(|entry| entry.compatible_schema_ids.iter().any(|id| id == schema_id))) +} + #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] struct PersistedSchemaCatalogMember( u32, @@ -1697,6 +1730,16 @@ impl SingleFileMessagePackBackingStore { self.read_all_unlocked() } + /// The schema catalog the store file publishes, read under the same shared lock [`CacheBackingStore::pull_all`] takes. An absent + /// file and a legacy envelope array have no catalog and give an empty one. Resolve a record's `schema_id` against it with + /// [`resolve_schema_entry`]. + pub fn pull_schema_catalog(&self) -> Result> { + if is_absent(&self.path)? && is_absent(&self.lock_path())? { + return Ok(Vec::new()); + } + self.with_shared_lock(|| Ok(self.read_store_unlocked()?.catalog.iter().map(SchemaCatalogEntry::from).collect())) + } + fn write_all_unlocked(&self, entries: &[CultCacheEnvelope]) -> Result<()> { let failed = |kind| CultCacheStoreWriteFailed { path: self.path.clone(), diff --git a/packages/cultcache-rs/tests/schema_catalog.rs b/packages/cultcache-rs/tests/schema_catalog.rs new file mode 100644 index 00000000..36ec3bed --- /dev/null +++ b/packages/cultcache-rs/tests/schema_catalog.rs @@ -0,0 +1,165 @@ +//! A consumer of a single-file store reads the schema version a record was published under from the store's catalog, through the +//! public surface only: `pull_all`, `pull_schema_catalog` and `resolve_schema_entry` +//! (`cultlib-gaps:follow_up:rs-snapshot-schema-version`). + +use std::path::PathBuf; + +use cultcache_rs::{ + CacheBackingStore, CultCache, DatabaseEntry, SchemaCatalogEntry, SingleFileMessagePackBackingStore, resolve_schema_entry, +}; + +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache(type = "tests.sc_one", schema = "tests.sc_one")] +struct One { + #[cultcache(key = 0)] + text: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache(type = "tests.sc_two", schema = "tests.sc_two")] +struct Two { + #[cultcache(key = 0)] + text: String, +} + +fn vectors(folder: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../tests/vectors").join(folder) +} + +/// The version a store publishes for each record, in key order: `(key, schema name, schema version)`. +fn versions(store: &SingleFileMessagePackBackingStore) -> Vec<(String, String, String)> { + let catalog = store.pull_schema_catalog().unwrap(); + let mut rows: Vec<_> = store + .pull_all() + .unwrap() + .iter() + .map(|envelope| { + let id = envelope.schema_id.as_deref().unwrap(); + let entry = resolve_schema_entry(&catalog, id).unwrap_or_else(|| panic!("no entry for {id}")); + (envelope.key.clone(), entry.schema_name.clone(), entry.schema_version.clone()) + }) + .collect(); + rows.sort(); + rows +} + +fn copy(from: PathBuf) -> (tempfile::TempDir, SingleFileMessagePackBackingStore) { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("store.cc"); + std::fs::copy(from, &path).unwrap(); + (dir, SingleFileMessagePackBackingStore::new(&path)) +} + +#[test] +fn a_consumer_reads_the_version_of_each_type_a_rust_store_holds() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("store.cc"); + let mut cache = CultCache::new(); + cache.register_entry_type::().unwrap(); + cache.register_entry_type::().unwrap(); + cache.add_backing_store(SingleFileMessagePackBackingStore::new(&path), ["tests.sc_one", "tests.sc_two"]).unwrap(); + cache.pull_all_backing_stores().unwrap(); + cache.put("a", &One { text: "1".into() }).unwrap(); + cache.put("b", &Two { text: "2".into() }).unwrap(); + drop(cache); + + let consumer = SingleFileMessagePackBackingStore::new(&path); + assert_eq!( + versions(&consumer), + vec![ + ("a".to_string(), "tests.sc_one".to_string(), "tests.sc_one.v1".to_string()), + ("b".to_string(), "tests.sc_two".to_string(), "tests.sc_two.v1".to_string()), + ] + ); +} + +#[test] +fn an_id_only_an_entry_lists_as_compatible_resolves_to_that_entry() { + let (_dir, store) = copy(vectors("document-variants-c2a/readability/compatible-id-only-v3.bin")); + let catalog = store.pull_schema_catalog().unwrap(); + assert_eq!(catalog.len(), 1); + assert_eq!(catalog[0].schema_id, "vectors.item.next"); + assert_eq!(catalog[0].compatible_schema_ids, vec!["vectors.item".to_string()]); + let entry = resolve_schema_entry(&catalog, "vectors.item").unwrap(); + assert_eq!(entry.schema_id, "vectors.item.next"); + assert_eq!(entry.schema_version, "vectors.item.v1"); + assert_eq!(resolve_schema_entry(&catalog, "vectors.nowhere"), None); +} + +#[test] +fn an_id_an_entry_owns_resolves_to_its_owner_not_to_an_entry_listing_it() { + for vector in ["own-id-over-compatible-v3.bin", "compatible-before-owner-v3.bin"] { + let (_dir, store) = copy(vectors("document-variants-c2a/readability").join(vector)); + let catalog = store.pull_schema_catalog().unwrap(); + assert_eq!(catalog.len(), 2, "{vector}"); + let entry = resolve_schema_entry(&catalog, "vectors.item").unwrap(); + assert_eq!((entry.schema_id.as_str(), entry.schema_version.as_str()), ("vectors.item", "vectors.item.v1"), "{vector}"); + } +} + +#[test] +fn a_legacy_array_and_an_absent_file_publish_no_catalog() { + let (_dir, store) = copy(vectors("document-variants-c2a/readability/legacy-envelopes.bin")); + assert!(!store.pull_all().unwrap().is_empty()); + assert_eq!(store.pull_schema_catalog().unwrap(), Vec::::new()); + + let dir = tempfile::tempdir().unwrap(); + let absent = SingleFileMessagePackBackingStore::new(dir.path().join("none.cc")); + assert_eq!(absent.pull_schema_catalog().unwrap(), Vec::::new()); + assert!(!dir.path().join("none.cc").exists() && !dir.path().join("none.cc.lock").exists(), "reading an absent store creates nothing"); +} + +// `tests/vectors/key-identity/foreign-store.cc` is written by the C# reference: two types, each published with its version. +#[test] +fn a_store_the_csharp_reference_wrote_gives_its_versions() { + let (_dir, store) = copy(vectors("key-identity/foreign-store.cc")); + assert_eq!( + versions(&store), + vec![ + ("j".to_string(), "tests.ki_alpha".to_string(), "tests.ki_alpha.v1".to_string()), + ("k".to_string(), "tests.ki_gamma".to_string(), "tests.ki_gamma.v1".to_string()), + ] + ); + let catalog = store.pull_schema_catalog().unwrap(); + assert_eq!(catalog[0].content_hash, "sha256:02f0c8b7ea07c11705c1365fc29136ada08c8076209cacfb8d5526229461ce7a"); +} + +type Entry = (&'static str, &'static str, &'static str, &'static str, &'static str, Vec<&'static str>, Vec<()>); + +// A hand-built v3 store whose versions are not the `.v1` the writers above publish, so a version read from anywhere but the +// version slot cannot pass. `shared` is listed by two entries (the first lister wins); `id.c` is owned by one entry and listed by +// an earlier one (the owner wins). +#[test] +fn distinct_versions_come_through_with_the_decoders_resolution() { + let catalog: Vec = vec![ + ("id.a", "N.a", "9.9.9", "hash-a", "{}", vec!["id.a", "shared"], vec![]), + ("id.b", "N.b", "7.0", "hash-b", "{}", vec!["id.b", "shared", "id.c"], vec![]), + ("id.c", "N.c", "3", "hash-c", "{}", vec!["id.c"], vec![]), + ]; + let record = |key: &str, id: &str| (key.to_string(), id.to_string(), "2026-11-13T00:00:00Z".to_string(), b"x".to_vec()); + let records = vec![record("r1", "shared"), record("r2", "id.c"), record("r3", "id.b"), record("r4", "id.a")]; + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("store.cc"); + std::fs::write(&path, rmp_serde::to_vec(&("cultcache.store.v3", catalog, records)).unwrap()).unwrap(); + + let store = SingleFileMessagePackBackingStore::new(&path); + let listed = store.pull_schema_catalog().unwrap(); + assert_eq!(listed.iter().map(|e| e.schema_version.as_str()).collect::>(), vec!["9.9.9", "7.0", "3"]); + assert_eq!(listed[1].content_hash, "hash-b"); + assert_eq!(listed[1].compatible_schema_ids, vec!["id.b", "shared", "id.c"]); + let got: Vec<(String, String, String)> = { + let mut rows: Vec<_> = store + .pull_all() + .unwrap() + .iter() + .map(|e| { + let entry = resolve_schema_entry(&listed, e.schema_id.as_deref().unwrap()).unwrap(); + (e.key.clone(), entry.schema_name.clone(), entry.schema_version.clone()) + }) + .collect(); + rows.sort(); + rows + }; + let row = |k: &str, n: &str, v: &str| (k.to_string(), n.to_string(), v.to_string()); + assert_eq!(got, vec![row("r1", "N.a", "9.9.9"), row("r2", "N.c", "3"), row("r3", "N.b", "7.0"), row("r4", "N.a", "9.9.9")]); +} From 395d64e1153a821d11d882b175f40fa60fb1e071 Mon Sep 17 00:00:00 2001 From: Libby Date: Fri, 13 Nov 2026 19:28:57 +0000 Subject: [PATCH 15/16] cultcache-ts: update-with-retry (update-with-retry-ts) (#169) --- docs/start/README.md | 26 +- packages/cultcache-ts/CHANGELOG.md | 9 +- packages/cultcache-ts/README.md | 6 +- packages/cultcache-ts/src/browser.ts | 2 + packages/cultcache-ts/src/cult-cache.ts | 315 ++++++--- .../single-file-messagepack-backing-store.ts | 95 ++- packages/cultcache-ts/src/types.ts | 27 +- packages/cultcache-ts/test/first-call.test.ts | 47 +- .../test/store-conditional.test.ts | 175 +++++ packages/cultcache-ts/test/store-file.test.ts | 2 +- .../test/union-surface-fixture.ts | 11 + packages/cultcache-ts/test/update.test.ts | 600 ++++++++++++++++++ .../ContentVersionVectorTests.cs | 115 ++++ tests/vectors/content-version/README.md | 17 + tests/vectors/content-version/store.cc | Bin 0 -> 766 bytes .../vectors/content-version/versions.msgpack | 1 + 16 files changed, 1342 insertions(+), 106 deletions(-) create mode 100644 packages/cultcache-ts/test/store-conditional.test.ts create mode 100644 packages/cultcache-ts/test/update.test.ts create mode 100644 tests/GameCult.Caching.Tests/ContentVersionVectorTests.cs create mode 100644 tests/vectors/content-version/README.md create mode 100644 tests/vectors/content-version/store.cc create mode 100644 tests/vectors/content-version/versions.msgpack diff --git a/docs/start/README.md b/docs/start/README.md index e9dc0b3f..58874c39 100644 --- a/docs/start/README.md +++ b/docs/start/README.md @@ -17,7 +17,7 @@ this page. | | CultCache | CultMath | Geometry | CultNet | CultMesh | | --- | --- | --- | --- | --- | --- | | **C#** | [first call, tested](#c-first-call); [update, tested](#c-update-with-retry) | [package](../../packages/cultmath/README.md); snippet not yet | [package](../../src/GameCult.Geometry/README.md); snippet not yet | [first call, tested](#c-cultnet-first-call); [package](../../src/GameCult.Networking) | [quickstart](../../src/GameCult.Mesh/docs/durable-node-quickstart.md); run by `scripts/verify-eve-getting-started.ps1` | -| **TypeScript** | [first call, tested](#typescript-first-call) | not yet | not yet | [first call, tested](#typescript-cultnet-first-call); [package](../../packages/cultnet-ts/README.md) | [package](../../packages/cultmesh-ts/README.md); snippet not yet; [recipe: two-client .io game](io-game.md), tested with two live clients, browser page not yet run | +| **TypeScript** | [first call, tested](#typescript-first-call); [update, tested](#typescript-update-with-retry) | not yet | not yet | [first call, tested](#typescript-cultnet-first-call); [package](../../packages/cultnet-ts/README.md) | [package](../../packages/cultmesh-ts/README.md); snippet not yet; [recipe: two-client .io game](io-game.md), tested with two live clients, browser page not yet run | | **Rust** | [first call, tested](#rust-first-call) | [native core](../../packages/cultmath/native/cultmath-core); snippet not yet | not yet | [first call, tested](#rust-cultnet-first-call); [package](../../packages/cultnet-rs/README.md), [examples](../../packages/cultnet-rs/examples) | [package](../../packages/cultmesh-rs); snippet not yet | | **Python** | [first call, tested](#python-first-call) | not yet | not yet | [first call, tested](#python-cultnet-first-call); [package](../../packages/cultnet-py/README.md) | [package](../../packages/cultmesh-py/README.md); snippet not yet | @@ -150,6 +150,30 @@ async function firstCall(path: string) { The test imports from `../src/index` where the block reads `@gamecult/cultcache-ts`; a second test in that file fails if the block stops matching the rest. More: `packages/cultcache-ts/README.md`. +## TypeScript update with retry + +`update` changes a record from what the store holds now and commits only if it is still that record. The source is the +second region of +[`packages/cultcache-ts/test/first-call.test.ts`](../../packages/cultcache-ts/test/first-call.test.ts), which uses the +definitions of the first call above; a test in that file fails if the block stops matching the file. + +```ts +async function restock(path: string) { + const cache = CultCache.builder() + .withRegistry(registry) + .withGenericStore(new SingleFileMessagePackBackingStore(path)) + .build(); + await cache.pullAllBackingStores(); + return cache.update(itemDocument, "item:potion", (item) => { + const current = item ?? { name: "Potion", category: "Consumable", value: 0 }; + return { ...current, value: current.value + 10 }; + }); +} +``` + +The write is durable when the promise resolves. A lost race re-runs the function, so keep it free of side effects; when the +attempts run out the result says `lostRace` instead of throwing. `put` is the blind opt-out: last writer wins. + ## Rust first call Same shape with `derive(DatabaseEntry)`: slots are `#[cultcache(key = N)]`, so the `.cc` payload is the diff --git a/packages/cultcache-ts/CHANGELOG.md b/packages/cultcache-ts/CHANGELOG.md index c41071a7..b5ab90bd 100644 --- a/packages/cultcache-ts/CHANGELOG.md +++ b/packages/cultcache-ts/CHANGELOG.md @@ -5,13 +5,20 @@ All notable changes to `@gamecult/cultcache-ts` are recorded here, following ## [Unreleased] +### Added + +- `CacheBackingStore` gains the optional `read(key)` (the record at the key with its content version, from one read) and `commitIf(entry, expectedVersion)` (write only if the version still holds, under the store's write lock; returns the storedAt written). The single-file store implements both. The content version is the lowercase hex SHA-256 of the record's persisted encoding `[key, schemaId, storedAt, payload]`, equal to the C# reference's on `tests/vectors/content-version`. A store without them still works for everything except `update`. +- `CultCacheUpdateResult` and `CultCacheUpdateOptions` (`maxAttempts`, default 5), exported from the package entry and the browser entry. + ### Changed +- A write that replaces a record at its key with different content (another schema id or other payload bytes) is stored strictly later than it: a supplied `storedAt` not later than the stored one is written one millisecond after it, so a content version that includes `storedAt` never repeats across writes. A rewrite of identical content (same schema id and payload bytes) keeps the stored stamp and so its version, which is what keeps a periodic flush of an unchanged cache from changing every record's version; C# re-mints every replacing write, so this is a departure from it, and the writer's type and catalog entry are written either way. `push`, `pushAll` and `commitIf` alike; the single-file store's `push` returns the stamp it wrote (`CacheBackingStore.push` may resolve `{ storedAt }`), and `put`, `putGlobal` and `putEnvelope` install that stamp in the cache, so the cache and the file agree after a put over a record stamped at or after now (a push result that is not a parseable stamp is ignored). A stamp that would pass 9999-12-31T23:59:59.999Z is refused with `RangeError` and nothing is written. - `withStoreWriteLock` looks for a link at the store path again once the lock is held, so a link put at the path while a writer waited on the lock is refused with `StoreLinkedError` and the lock released, instead of being replaced. Pinned by a test that puts the link while another holder owns the lock. - `CultCache.isWritable` does not look at store paths: a link at a store path is not predicted, and the write refuses it with `StoreLinkedError`. Pinned by a test through a cache attached to a linked store. ### Breaking +- `update` and `updateGlobal` are the conditional golden path and no longer the blind pair. `update(definition, key, apply, options?)` reads the record and its content version from the store, passes `apply` a fresh copy (never the held instance), commits only if the version still holds and runs `apply` again on a lost race, up to `maxAttempts`. `apply` may be async, may return `undefined` to write nothing, and must be free of side effects. It resolves to `{ outcome: "updated" | "declined" | "lostRace", document }` instead of the written value, so a caller reads `result.document`; a lost race that outlasts the attempts is `lostRace`, not a throw. `apply` returning `undefined` or `null` declines. `maxAttempts` is an integer of at least 1; `undefined` is the default 5 and `null` is refused with `RangeError`. A conditional write over a global loaded from a legacy key is refused wherever it enters (`updateGlobal`, or `update` at `CultCache.GLOBAL_KEY`) and leaves the legacy record untouched; `apply` may have run once before the write is refused. A store attached to a store-less cache while `apply` awaits makes that attempt a lost race. It needs a store with `read` and `commitIf` (the single-file store has them); a cache with no store compares the held entry. `put` and `putGlobal` are the blind writes and are unchanged. - A write at a single-file store path that is a symbolic link, live or dangling, is refused with the new `StoreLinkedError` (exported from the package entry; `error.path` names the path) before the lock and the replace, and writes nothing: `push`, `pushAll`, `delete` and a soft flush alike. Before, a write at a live link replaced the link with a regular file, a dangling link failed with `ENOENT` (or, for `pushAll`, was replaced), and a soft flush at a link skipped. A caller catching `ENOENT` around a write at a dangling link now catches `StoreLinkedError`; configure the link's target as the store path. Reads through a live link work. An `lstat` failure other than `ENOENT` (`ENOTDIR`, `EACCES`, `ELOOP`) is rethrown as it is. - `SingleFileMessagePackBackingStore.pushAll` writes the given entries onto the store as it is under the lock, as `push` does: a record under the same key is replaced and no other is removed. It used to replace the whole file with the entries, so `CultMesh.flush()` from a cache that held less than the store (a node created with `pullOnStart: false`, or one whose image was stale) wiped the records it never read. A caller that relied on `pushAll` to reset a store removes the file first. A soft flush is unchanged. `CultCache.storedSnapshot()` is the image as the stores hold it (a global loaded from a legacy key under that key) and is what `CultMesh.flush` persists, so a flush no longer leaves such a global under two keys; `snapshot()` is unchanged. - A single-file store marked `cultcache.store.v3` (it holds an element id, written by the C# runtime) is read, and a rewrite keeps the mark and the ids; this runtime does not decide when a document carries ids. A runtime from before this change refuses a v3 store by name, so rolling back to an older release after a store has been written as v3 is one-way: there is no carry-back, so restore from a copy taken before upgrading. @@ -22,7 +29,7 @@ All notable changes to `@gamecult/cultcache-ts` are recorded here, following the many-row selection aliases (never indexed for lookup, same catalog `indexAlias`). The per-key migration rule is in the README; classify every key before the CultLib your tools load is updated. The refusal verdict is the contract, its text is informational. - The single-file reader (v1 and the legacy envelope array) refuses a store that holds two records under one key as `StoreUnreadableError`; a store that opened before may now be refused. Repair it by removing the extra record with the runtime that wrote it. -- A `put`, `push` or `pushAll` that would land a second type under a held key (against the file, or within one batch) is refused with `SchemaConflictError`, and nothing is written. The written type may replace the record when its catalog entry carries the stored schema id as its own or lists it compatible (a type renamed under a stable id restamps the record, as C# does); otherwise retyping a key is a delete, then a put. The C# allowance for an undeclared older id of the same schema name that reads whole is not ported: a same-named type keeps passing, including one that drops persisted slots, which C# refuses. +- A `put`, `push` or `pushAll` that would land a second type under a held key (against the file, or within one batch) is refused with `SchemaConflictError`, and nothing is written. The written type may replace the record when its catalog entry carries the stored schema id as its own or lists it compatible (a type renamed under a stable id restamps the record, as C# does, when its payload changes; an identical payload keeps the stamp); otherwise retyping a key is a delete, then a put. The C# allowance for an undeclared older id of the same schema name that reads whole is not ported: a same-named type keeps passing, including one that drops persisted slots, which C# refuses. - Same-type writes replace by key; in one `pushAll` batch the last entry for a key wins. - A store whose canonical-schema catalog slot is not a string is refused as `StoreUnreadableError`. - The cache holds one record per key (variants:ruling:cache-refuses-retype). A `put` or `putEnvelope` that lands a second type under a key the cache holds, or under a key a store carries foreign, is refused with `SchemaConflictError` naming the key, even with no store (a memory-only cache used to hold both types at the key). A type that owns the held record's schema id (as for the store) replaces it, unless the held record is in a store other than the written type's home: that rename is refused with `SchemaConflictError` (remove the record first). Two stores holding one key, registered or foreign, refuse the load and admit nothing. A store's `delete` removes the record whose stored schema id the removal's type owns, whatever the arrived record's `type` string. diff --git a/packages/cultcache-ts/README.md b/packages/cultcache-ts/README.md index 625d28dd..2aa78c9c 100644 --- a/packages/cultcache-ts/README.md +++ b/packages/cultcache-ts/README.md @@ -168,10 +168,8 @@ const settings = cache.getRequiredGlobal(settingsDocument); - `getByIndex(...)` - `getGlobal(...)` - `getRequiredGlobal(...)` -- `put(...)` -- `putGlobal(...)` -- `update(...)` -- `updateGlobal(...)` +- `update(...)` and `updateGlobal(...)`, the conditional golden path: apply a function to a fresh copy of the record as the store holds it, commit only if it is unchanged, re-run on a lost race (`updated`, `declined` or `lostRace`) +- `put(...)` and `putGlobal(...)`, the blind opt-out: last writer wins - `delete(...)` - `deleteGlobal(...)` - `snapshot()` (the logical image) and `storedSnapshot()` (the image as the stores hold it, for a whole flush) diff --git a/packages/cultcache-ts/src/browser.ts b/packages/cultcache-ts/src/browser.ts index 98995acb..05a5ecd6 100644 --- a/packages/cultcache-ts/src/browser.ts +++ b/packages/cultcache-ts/src/browser.ts @@ -35,6 +35,8 @@ export type { InferCultCacheSchemaValue, CultCacheStoreRegistration, CultCacheUnreadSlots, + CultCacheUpdateOptions, + CultCacheUpdateResult, PushAllOptions, } from "./types"; export type { diff --git a/packages/cultcache-ts/src/cult-cache.ts b/packages/cultcache-ts/src/cult-cache.ts index 543356aa..c8d1cc62 100644 --- a/packages/cultcache-ts/src/cult-cache.ts +++ b/packages/cultcache-ts/src/cult-cache.ts @@ -22,6 +22,8 @@ import type { CultCacheSchemaCatalogEntry, CultCacheStoreRegistration, CultCacheUnreadSlots, + CultCacheUpdateOptions, + CultCacheUpdateResult, } from "./types"; type Accessor = (value: unknown) => string | undefined; @@ -48,6 +50,10 @@ type EntryLookups = { readonly indexes: ReadonlyMap; }; +// What a conditional write requires of the record at its key: the content version its store reported (null: no record), or for a +// cache with no store the entry it held. Every write installs a new entry object, so the identity catches an A-B-A. +type WriteCondition = { readonly version: string | null } | { readonly held: HydratedEntry | undefined }; + type HydratedEntry = CultCacheEnvelope & { catalogEntry: NonNullable; value: unknown; @@ -302,85 +308,88 @@ export class CultCache { // HydratedState, running every check and every user accessor, and replaces the cache's state // in one assignment only once the build finishes. A refused load admits nothing. pullAllBackingStores(): Promise { - return this.#serial(async () => { - const next = emptyHydratedState(); - const persistedGlobalKeys = new Map(); + return this.#serial(() => this.#pullNow()); + } - for (const registration of this.#stores) { - const entries = await registration.store.pullAll(); + // The pull itself, for a caller already inside a serial operation. + async #pullNow(): Promise { + const next = emptyHydratedState(); + const persistedGlobalKeys = new Map(); - for (const entry of entries) { - const registered = this.#resolveDefinitionForEnvelope(entry); - if (!registered) { - // A store may hold what this cache has no type for; it is carried, not served, and not a reason to refuse the load. - next.foreign.push({ - key: entry.key, - schemaId: schemaIdForEnvelope(entry), - schemaName: entry.catalogEntry?.schemaName ?? entry.type, - storedAt: entry.storedAt, - }); - continue; - } + for (const registration of this.#stores) { + const entries = await registration.store.pullAll(); - const type = registered.definition.type; - const home = this.#resolveRegistration(type); - if (home?.store !== registration.store) { + for (const entry of entries) { + const registered = this.#resolveDefinitionForEnvelope(entry); + if (!registered) { + // A store may hold what this cache has no type for; it is carried, not served, and not a reason to refuse the load. + next.foreign.push({ + key: entry.key, + schemaId: schemaIdForEnvelope(entry), + schemaName: entry.catalogEntry?.schemaName ?? entry.type, + storedAt: entry.storedAt, + }); + continue; + } + + const type = registered.definition.type; + const home = this.#resolveRegistration(type); + if (home?.store !== registration.store) { + throw new Error( + `CultCache "${type}" record "${entry.key}" was loaded from ${this.#describe(registration)}, ` + + `but its home is ${this.#describe(home)}.`, + ); + } + + const payload = this.#cloneBytes(entry.payload); + const { value, unreadSlots } = registered.formatter.decode(payload); + let admitted: CultCacheEnvelope = { ...entry, type, payload }; + if (registered.global) { + const persistedKey = persistedGlobalKeys.get(type); + if (persistedKey !== undefined) { throw new Error( - `CultCache "${type}" record "${entry.key}" was loaded from ${this.#describe(registration)}, ` + - `but its home is ${this.#describe(home)}.`, + `CultCache global document type "${type}" has multiple persisted entries: "${persistedKey}" and "${entry.key}".`, ); } - - const payload = this.#cloneBytes(entry.payload); - const { value, unreadSlots } = registered.formatter.decode(payload); - let admitted: CultCacheEnvelope = { ...entry, type, payload }; - if (registered.global) { - const persistedKey = persistedGlobalKeys.get(type); - if (persistedKey !== undefined) { - throw new Error( - `CultCache global document type "${type}" has multiple persisted entries: "${persistedKey}" and "${entry.key}".`, - ); - } - persistedGlobalKeys.set(type, entry.key); - if (entry.key !== CultCache.GLOBAL_KEY) { - next.legacyGlobals.set(type, admitted); - admitted = { ...admitted, key: CultCache.GLOBAL_KEY }; - } + persistedGlobalKeys.set(type, entry.key); + if (entry.key !== CultCache.GLOBAL_KEY) { + next.legacyGlobals.set(type, admitted); + admitted = { ...admitted, key: CultCache.GLOBAL_KEY }; } - const hydrated = this.#admitEntry(next, registered, admitted, value, unreadSlots); - const other = this.#heldUnderOtherType(next, type, hydrated.key); - if (other !== undefined) { - throw new Error(`Record ${hydrated.key} is held by two stores, as type "${other.type}" and as type "${type}".`); - } - this.#installEntry(next, registered, hydrated); } - } - - const foreignByKey = new Map(); - for (const record of next.foreign) { - const first = foreignByKey.get(record.key); - if (first !== undefined) { - throw new Error(`Record ${record.key} is held by two stores, as schema "${first}" and as schema "${record.schemaName}".`); - } - foreignByKey.set(record.key, record.schemaName); - const other = [...this.#definitions.keys()].map((type) => next.entries.get(this.#entryId(type, record.key))).find((held) => held !== undefined); + const hydrated = this.#admitEntry(next, registered, admitted, value, unreadSlots); + const other = this.#heldUnderOtherType(next, type, hydrated.key); if (other !== undefined) { - throw new Error(`Record ${record.key} is held by two stores, as type "${other.type}" and as schema "${record.schemaName}".`); + throw new Error(`Record ${hydrated.key} is held by two stores, as type "${other.type}" and as type "${type}".`); + } + this.#installEntry(next, registered, hydrated); + } + } + + const foreignByKey = new Map(); + for (const record of next.foreign) { + const first = foreignByKey.get(record.key); + if (first !== undefined) { + throw new Error(`Record ${record.key} is held by two stores, as schema "${first}" and as schema "${record.schemaName}".`); + } + foreignByKey.set(record.key, record.schemaName); + const other = [...this.#definitions.keys()].map((type) => next.entries.get(this.#entryId(type, record.key))).find((held) => held !== undefined); + if (other !== undefined) { + throw new Error(`Record ${record.key} is held by two stores, as type "${other.type}" and as schema "${record.schemaName}".`); + } + } + + // Judged whole, once: the refusal names the least violation of the state this load would leave, whatever the + // order of the stores and of the entries in them. + refuseLeastIndexSharing(function* () { + for (const entry of next.entries.values()) { + for (const [alias, value] of entry.lookups.indexes) { + yield { type: entry.type, schemaName: entry.catalogEntry.schemaName, alias, value, key: entry.key }; } } + }()); - // Judged whole, once: the refusal names the least violation of the state this load would leave, whatever the - // order of the stores and of the entries in them. - refuseLeastIndexSharing(function* () { - for (const entry of next.entries.values()) { - for (const [alias, value] of entry.lookups.indexes) { - yield { type: entry.type, schemaName: entry.catalogEntry.schemaName, alias, value, key: entry.key }; - } - } - }()); - - this.#hydrated = next; - }); + this.#hydrated = next; } get( @@ -513,12 +522,16 @@ export class CultCache { return key ? this.get(definition, key) : undefined; } + /** + * The blind write: the record at the key becomes `value`, whatever it held, and the last writer wins. `update` is the + * conditional path, which changes a record from what the store holds now and re-runs on a lost race. + */ put( definition: TDefinition, key: string, value: CultCacheDocumentValue, ): Promise> { - return this.#serial(() => this.#putNow(definition, key, value)); + return this.#serial(async () => (await this.#putNow(definition, key, value)).value); } putEnvelope( @@ -560,31 +573,68 @@ export class CultCache { definition: TDefinition, value: CultCacheDocumentValue, ): Promise> { - return this.#serial(() => { + return this.#serial(async () => { this.#requireGlobal(definition); - return this.#putNow(definition, CultCache.GLOBAL_KEY, value); + return (await this.#putNow(definition, CultCache.GLOBAL_KEY, value)).value; }); } - update( + /** + * Changes the record at the key from what the store holds now. `apply` may run up to `maxAttempts` times (default 5): each + * attempt gets a fresh copy of the record as the store holds it (undefined when absent), which it may change and return, and + * the result is written only if the record is still that version; a lost race runs `apply` again on the newer record. It must + * not write this cache or have side effects. Returning undefined writes nothing (`declined`). A lost race that outlasts + * `maxAttempts` returns `lostRace` and does not throw; a refusal, or a throw from `apply`, rejects and is not retried. The + * write is durable when the promise resolves. `document` is the cache's record after the outcome. `put` is the blind write. + */ + async update( definition: TDefinition, key: string, - updater: ( + apply: ( current: CultCacheDocumentValue | undefined, - ) => CultCacheDocumentValue, - ): Promise> { - return this.#serial(() => this.#putNow(definition, key, updater(this.get(definition, key)))); + ) => CultCacheDocumentValue | undefined | Promise | undefined>, + options: CultCacheUpdateOptions = {}, + ): Promise>> { + if (typeof apply !== "function") { + throw new TypeError("CultCache update needs a function to apply."); + } + // undefined is the default; anything else must be an integer of at least 1, so null is refused. + const maxAttempts = options.maxAttempts === undefined ? 5 : options.maxAttempts; + if (!Number.isInteger(maxAttempts) || maxAttempts < 1) { + throw new RangeError(`CultCache update maxAttempts must be an integer of at least 1, not ${String(maxAttempts)}.`); + } + + for (let attempt = 1; ; attempt++) { + const { expected, copy } = await this.#serial(() => this.#readForUpdate(definition, key)); + // Outside the serial queue and the store's lock: apply may await, and may write this cache through other keys. + const next = await apply(copy as CultCacheDocumentValue | undefined); + if (next === undefined || next === null) { + return { outcome: "declined", document: this.get(definition, key) }; + } + const written = await this.#serial(() => this.#putNow(definition, key, next, expected)); + if (written.outcome === "committed") { + return { outcome: "updated", document: this.get(definition, key) as CultCacheDocumentValue }; + } + if (attempt === maxAttempts) { + // Display only. A cache with no store has nothing to pull, and a pull would replace what it holds with nothing. + if (this.#stores.length > 0) { + await this.#serial(() => this.#pullNow()); + } + return { outcome: "lostRace", document: this.get(definition, key) }; + } + } } - updateGlobal( + /** `update` of the type's global record. */ + async updateGlobal( definition: TDefinition, - updater: ( + apply: ( current: CultCacheDocumentValue | undefined, - ) => CultCacheDocumentValue, - ): Promise> { - return this.#serial(() => - this.#putNow(definition, CultCache.GLOBAL_KEY, updater(this.getGlobal(definition))), - ); + ) => CultCacheDocumentValue | undefined | Promise | undefined>, + options: CultCacheUpdateOptions = {}, + ): Promise>> { + this.#requireGlobal(definition); + return this.update(definition, CultCache.GLOBAL_KEY, apply, options); } delete( @@ -724,11 +774,55 @@ export class CultCache { this.#stores.push(candidate); } + // One attempt's read for update: the record at the key as its home holds it now, the condition a commit must meet, and a copy of + // the record for apply to change. A store-backed cache asks its store, which reports the record and its version from one read; + // a cache with no store has only the held entry, whose identity is the condition and whose admitted payload is the copy. + async #readForUpdate( + definition: AnyCultCacheDocumentDefinition, + key: string, + ): Promise<{ expected: WriteCondition; copy: unknown }> { + const registered = this.#requireDefinition(definition); + this.#requireGlobalKey(registered, key); + const type = definition.type; + this.#refuseLegacyConditional(type); + const home = this.#homeStore(type); + if (home === undefined) { + const held = this.#hydrated.entries.get(this.#entryId(type, key)); + if (held === undefined && this.#heldUnderOtherType(this.#hydrated, type, key) !== undefined) { + throw new Error(`Record ${key} is not a "${type}"; update cannot copy it.`); + } + return { expected: { held }, copy: held ? registered.formatter.decode(this.#cloneBytes(held.payload)).value : undefined }; + } + if (home.read === undefined || home.commitIf === undefined) { + throw new Error(`CultCache backing store for type "${type}" cannot write conditionally; update needs a store with read and commitIf.`); + } + const found = await home.read(key); + if (found === undefined) { + return { expected: { version: null }, copy: undefined }; + } + if (this.#resolveDefinitionForEnvelope(found.envelope) !== registered) { + throw new Error(`Record ${key} is not a "${type}"; update cannot copy it.`); + } + return { expected: { version: found.version }, copy: registered.formatter.decode(this.#cloneBytes(found.envelope.payload)).value }; + } + + #putNow( + definition: TDefinition, + key: string, + value: CultCacheDocumentValue, + ): Promise<{ outcome: "committed"; value: CultCacheDocumentValue }>; + #putNow( + definition: TDefinition, + key: string, + value: CultCacheDocumentValue, + expected: WriteCondition, + ): Promise<{ outcome: "committed"; value: CultCacheDocumentValue } | { outcome: "mismatch" }>; async #putNow( definition: TDefinition, key: string, value: CultCacheDocumentValue, - ): Promise> { + expected?: WriteCondition, + ): Promise<{ outcome: "committed"; value: CultCacheDocumentValue } | { outcome: "mismatch" }> { const registered = this.#requireDefinition(definition); const payload = registered.formatter.encode(value); const { value: parsed, unreadSlots } = registered.formatter.decode(payload) as { value: CultCacheDocumentValue; unreadSlots?: NotedSlots }; @@ -740,19 +834,55 @@ export class CultCache { schemaId: registered.catalogEntry.schemaId, catalogEntry: registered.catalogEntry, }; - await this.#writeNow(registered, entry, parsed, unreadSlots); - return parsed; + const outcome = await this.#writeNow(registered, entry, parsed, unreadSlots, expected); + return outcome === "committed" ? { outcome, value: parsed } : { outcome }; } // Validates the whole write (home, global singleton, name and index accessors) before the store // is touched; once the store accepts, applying to the cache has no fallible step. - async #writeNow(registered: RegisteredDefinition, entry: CultCacheEnvelope, value: unknown, unreadSlots?: NotedSlots): Promise { + // With a condition, the write lands only if the record at the key is still the one the caller read: the store's version + // (commitIf), or for a cache with no store the held entry itself. Otherwise nothing is written or installed and the answer is + // "mismatch". Without one the write is blind, as put's. + async #writeNow( + registered: RegisteredDefinition, + entry: CultCacheEnvelope, + value: unknown, + unreadSlots?: NotedSlots, + expected?: WriteCondition, + ): Promise<"committed" | "mismatch"> { const home = this.#homeStore(entry.type); const retyped = this.#judge(registered, entry.key, entry); const hydrated = this.#admitEntry(this.#hydrated, registered, entry, value, unreadSlots); this.#refuseIndexSharing(this.#hydrated, entry.type, registered.catalogEntry.schemaName, entry.key, hydrated.lookups); const legacy = this.#hydrated.legacyGlobals.get(entry.type); - await home?.push(entry); + // A conditional write over a legacy global is refused here too (a pull while apply awaited can have loaded one): the condition + // was read from the global's own key, the record sits under its legacy key, and the retire below would delete it unseen. + if (expected !== undefined) { + this.#refuseLegacyConditional(entry.type); + } + if (expected !== undefined && "held" in expected) { + // The condition was fixed at read for a cache with no store. A store attached since has its own record at the key that + // this read never saw, so the held identity no longer judges the write: lose the race and read again. + if (home !== undefined || this.#hydrated.entries.get(this.#entryId(entry.type, entry.key)) !== expected.held) { + return "mismatch"; + } + } + if (expected !== undefined && "version" in expected) { + if (home?.commitIf === undefined) { + throw new Error(`CultCache backing store for type "${entry.type}" cannot write conditionally; update needs a store with read and commitIf.`); + } + const committed = await home.commitIf(entry, expected.version); + if (committed.outcome === "mismatch") { + return "mismatch"; + } + hydrated.storedAt = committed.storedAt; + } else { + // A store that re-stamps a write (storedAt later than the record it replaces) reports the stamp; the cache holds that one. + const pushed = await home?.push(entry); + if (typeof pushed === "object" && pushed !== null && typeof pushed.storedAt === "string" && !Number.isNaN(Date.parse(pushed.storedAt))) { + hydrated.storedAt = pushed.storedAt; + } + } if (home && legacy) { await this.#retireLegacyGlobal(home, legacy, entry); } @@ -762,6 +892,17 @@ export class CultCache { this.#removeEntry(this.#hydrated, this.#definitions.get(retyped.type)!, retyped); } this.#installEntry(this.#hydrated, registered, hydrated); + return "committed"; + } + + // The one rule that a conditional write (update, whichever entry reaches it) never touches a global loaded from a legacy key: + // only putGlobal retires that key. Asked before apply runs and again at the write. + #refuseLegacyConditional(type: string): void { + if (this.#hydrated.legacyGlobals.has(type)) { + throw new Error( + `CultCache global "${type}" was loaded from a legacy key; write it once with putGlobal to retire its legacy key.`, + ); + } } // An envelope of the definition's type at the key, carrying nothing: what the judge asks about a write or a removal by that type. 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 b5199ae3..03b93683 100644 --- a/packages/cultcache-ts/src/single-file-messagepack-backing-store.ts +++ b/packages/cultcache-ts/src/single-file-messagepack-backing-store.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import { lstatSync, readFileSync } from "node:fs"; import { mkdir } from "node:fs/promises"; import { dirname, resolve } from "node:path"; @@ -55,14 +56,42 @@ export class SingleFileMessagePackBackingStore implements CacheBackingStore { return disk.envelopes; } - async push(entry: CultCacheEnvelope): Promise { - await this.#enqueue(async () => { + async push(entry: CultCacheEnvelope): Promise<{ storedAt: string }> { + return this.#enqueue(async () => { const disk = this.#readDisk(); this.#format = disk.format; const existing = disk.envelopes; requireOneTypePerKey(this.filePath, [entry], existing); - const filtered = existing.filter((candidate) => candidate.key !== entry.key); - await this.#writeAll([entry], filtered); + const [written] = await this.#writeAll([entry], existing); + return { storedAt: written!.storedAt }; + }); + } + + // The record at the key as the file holds it now, with its content version, from one read. Like pullAll it takes no lock: a + // reader sees a whole file (the commit renames one over the other), and the version it reports is the one commitIf judges. + async read(key: string): Promise<{ envelope: CultCacheEnvelope; version: string } | undefined> { + const envelope = readSingleFileStore(this.filePath).envelopes.find((candidate) => candidate.key === key); + return envelope === undefined ? undefined : { envelope, version: recordVersion(envelope) }; + } + + // Writes `entry` the way push does, but only if the record at its key (of any type) still has `expectedVersion`; null expects + // none. The comparison and the write are one hold of the write lock, so no other writer lands between them. + async commitIf( + entry: CultCacheEnvelope, + expectedVersion: string | null, + ): Promise<{ outcome: "committed"; storedAt: string } | { outcome: "mismatch" }> { + return this.#enqueue(async () => { + const disk = this.#readDisk(); + this.#format = disk.format; + const existing = disk.envelopes; + const current = existing.find((candidate) => candidate.key === entry.key); + if ((current === undefined ? null : recordVersion(current)) !== expectedVersion) { + return { outcome: "mismatch" as const }; + } + + requireOneTypePerKey(this.filePath, [entry], existing); + const [written] = await this.#writeAll([entry], existing); + return { outcome: "committed" as const, storedAt: written!.storedAt }; }); } @@ -126,13 +155,67 @@ export class SingleFileMessagePackBackingStore implements CacheBackingStore { return result; } - async #writeAll(supplied: CultCacheEnvelope[], arrived: CultCacheEnvelope[]): Promise { + // Writes the supplied records onto the arrived ones and returns the supplied records as written. A supplied record whose + // schema id and payload bytes differ from the arrived one it replaces is never stamped earlier than the record it replaces, so a content version that includes storedAt + // cannot repeat across writes (variants:ruling:storedat-on-change): one not later than the arrived stamp is written one + // millisecond after it. + async #writeAll(supplied: CultCacheEnvelope[], arrived: CultCacheEnvelope[]): Promise { + const arrivedByKey = new Map(arrived.map((entry) => [entry.key, entry])); + const written = supplied.map((entry) => { + const prior = arrivedByKey.get(entry.key); + if (prior === undefined) { + return entry; + } + // A rewrite of the identical record (same schema id and payload bytes) keeps the stamp and so the version it has; the + // writer's type and catalog entry still go down with it. + if (sameContent(entry, prior)) { + return { ...entry, storedAt: prior.storedAt }; + } + return { ...entry, storedAt: laterStoredAt(entry.storedAt, prior.storedAt) }; + }); await mkdir(dirname(this.filePath), { recursive: true }); - await commitStoreFile(this.filePath, encode(encodeSnapshot(supplied, arrived, this.#format))); + await commitStoreFile(this.filePath, encode(encodeSnapshot(written, arrived, this.#format))); + return written; } } +// The content version of a stored record: the lowercase hex SHA-256 of its persisted encoding, the MessagePack array +// [key, schemaId, storedAt, payload] as the store writes it (the C# reference's StoredVersion). Private to this module so +// node:crypto stays out of the browser-safe cache. +export function recordVersion(entry: CultCacheEnvelope): string { + return createHash("sha256") + .update(encode([entry.key, schemaIdForEnvelope(entry), entry.storedAt, documentPayloadBytes(entry.payload)])) + .digest("hex"); +} + +function sameContent(a: CultCacheEnvelope, b: CultCacheEnvelope): boolean { + if (schemaIdForEnvelope(a) !== schemaIdForEnvelope(b)) { + return false; + } + const x = documentPayloadBytes(a.payload); + const y = documentPayloadBytes(b.payload); + return x.length === y.length && x.every((byte, i) => byte === y[i]); +} + +// `storedAt` if it is later than `prior`, else `prior` plus one millisecond. A stamp that does not parse is not later; a prior +// that does not parse leaves nothing to be later than. +function laterStoredAt(storedAt: string, prior: string): string { + const priorMs = Date.parse(prior); + if (Number.isNaN(priorMs)) { + return storedAt; + } + const storedMs = Date.parse(storedAt); + if (!(Number.isNaN(storedMs) || storedMs <= priorMs)) { + return storedAt; + } + // The stamp is ISO-8601 with a four-digit year; one past 9999-12-31T23:59:59.999Z would print as an extended year. + if (priorMs + 1 > Date.UTC(9999, 11, 31, 23, 59, 59, 999)) { + throw new RangeError(`CultCache cannot stamp a write later than the record it replaces (${prior}): the stamp would leave the four-digit years.`); + } + return new Date(priorMs + 1).toISOString(); +} + // A store holds a key once, and the reader refuses a file that holds it twice, so a write that would put a second type under a // key (against a record on disk, or within its own batch) is refused before anything is written. Retyping a record is a delete // of the old type followed by a put of the new one. diff --git a/packages/cultcache-ts/src/types.ts b/packages/cultcache-ts/src/types.ts index 10371053..60e0b878 100644 --- a/packages/cultcache-ts/src/types.ts +++ b/packages/cultcache-ts/src/types.ts @@ -125,9 +125,34 @@ export interface PushAllOptions { export interface CacheBackingStore { pullAll(): Promise; - push(entry: CultCacheEnvelope): Promise; + /** A store that re-stamps the write returns the storedAt it wrote; the cache then holds that stamp, not its own. */ + push(entry: CultCacheEnvelope): Promise; delete(entry: CultCacheEnvelope): Promise; pushAll?(entries: CultCacheEnvelope[], options?: PushAllOptions): Promise; + /** + * The record at the key as the store holds it now, with its content version, from one read; undefined when the store holds + * none. The version is the store's own: the cache carries it from `read` to `commitIf` and never computes it. + */ + read?(key: string): Promise<{ envelope: CultCacheEnvelope; version: string } | undefined>; + /** + * Writes the entry only if the record at its key still has `expectedVersion` (null: no record), judged and written under the + * store's write lock. A mismatch writes nothing. A commit returns the storedAt actually written. + */ + commitIf?( + entry: CultCacheEnvelope, + expectedVersion: string | null, + ): Promise<{ outcome: "committed"; storedAt: string } | { outcome: "mismatch" }>; +} + +/** The outcome of `CultCache.update`: what `apply` wrote, declined to write, or lost to a rival writer. */ +export type CultCacheUpdateResult = + | { outcome: "updated"; document: TValue } + | { outcome: "declined"; document: TValue | undefined } + | { outcome: "lostRace"; document: TValue | undefined }; + +export interface CultCacheUpdateOptions { + /** How many times `apply` may run before a lost race is returned as `lostRace`. An integer of at least 1; default 5. */ + maxAttempts?: number; } export interface CultCacheStoreRegistration { diff --git a/packages/cultcache-ts/test/first-call.test.ts b/packages/cultcache-ts/test/first-call.test.ts index 45f6edd2..9ef3d308 100644 --- a/packages/cultcache-ts/test/first-call.test.ts +++ b/packages/cultcache-ts/test/first-call.test.ts @@ -46,6 +46,21 @@ async function firstCall(path: string) { } // snippet:end +// The update snippet, which docs/start/README.md shows after the first call and which uses its definitions. +// snippet:update:begin +async function restock(path: string) { + const cache = CultCache.builder() + .withRegistry(registry) + .withGenericStore(new SingleFileMessagePackBackingStore(path)) + .build(); + await cache.pullAllBackingStores(); + return cache.update(itemDocument, "item:potion", (item) => { + const current = item ?? { name: "Potion", category: "Consumable", value: 0 }; + return { ...current, value: current.value + 10 }; + }); +} +// snippet:update:end + test("define, store and read back a typed document", async () => { const dir = await mkdtemp(join(tmpdir(), "cultcache-first-call-")); try { @@ -57,13 +72,35 @@ test("define, store and read back a typed document", async () => { } }); -test("docs show the tested snippet", async () => { +test("update with retry: restock twice reads value 20 through a second cache", async () => { + const dir = await mkdtemp(join(tmpdir(), "cultcache-first-call-")); + try { + const path = join(dir, "restock.cc"); + assert.equal((await restock(path)).outcome, "updated"); + assert.equal((await restock(path)).outcome, "updated"); + const reopened = CultCache.builder() + .withRegistry(registry) + .withGenericStore(new SingleFileMessagePackBackingStore(path)) + .build(); + await reopened.pullAllBackingStores(); + assert.equal(reopened.get(itemDocument, "item:potion")?.value, 20); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +// The docs show the region between `// snippet:begin` and `end` markers verbatim, the package import aside. +async function assertDocsShow(region: "" | "update:") { // Compiled to dist-test/test/first-call.test.js, so the package root is two levels up. const source = await readFile(join(__dirname, "..", "..", "test", "first-call.test.ts"), "utf8"); const snippet = source - .split("// snippet:begin\n")[1] - .split("// snippet:end\n")[0] + .split(`// snippet:${region}begin\n`)[1] + .split(`// snippet:${region}end\n`)[0] .replace('"../src/index"', '"@gamecult/cultcache-ts"'); const docs = (await readFile(join(__dirname, "..", "..", "..", "..", "docs", "start", "README.md"), "utf8")).replace(/\r\n/g, "\n"); - assert.ok(docs.includes("```ts\n" + snippet + "```"), "docs/start/README.md no longer shows test/first-call.test.ts"); -}); + assert.ok(docs.includes("```ts\n" + snippet + "```"), `docs/start/README.md no longer shows the ${region || "first-call "}region of test/first-call.test.ts`); +} + +test("docs show the tested snippet", () => assertDocsShow("")); + +test("docs show the tested update snippet", () => assertDocsShow("update:")); diff --git a/packages/cultcache-ts/test/store-conditional.test.ts b/packages/cultcache-ts/test/store-conditional.test.ts new file mode 100644 index 00000000..5f8f4ed5 --- /dev/null +++ b/packages/cultcache-ts/test/store-conditional.test.ts @@ -0,0 +1,175 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { existsSync, readFileSync } from "node:fs"; +import { copyFile, mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import test from "node:test"; + +import { decode, encode } from "@msgpack/msgpack"; + +import { documentPayloadBytes, documentPayloadFromBytes } from "../src/document"; +import { SingleFileMessagePackBackingStore, readSingleFileStore, recordVersion } from "../src/single-file-messagepack-backing-store"; +import { schemaIdForEnvelope } from "../src/store-format"; +import type { CultCacheEnvelope } from "../src/types"; + +// The store owns the content version and the conditional verdict (variants:ruling:write-set-content-version-declared-ownership): +// read reports a record with its version, commitIf writes only while the version holds, and a write that replaces a record +// is stamped later than it (variants:ruling:storedat-on-change). + +function findVectors(from: string): string { + for (let dir = from; ; dir = resolve(dir, "..")) { + if (existsSync(join(dir, "tests", "vectors", "content-version"))) return join(dir, "tests", "vectors", "content-version"); + if (resolve(dir, "..") === dir) throw new Error("tests/vectors/content-version not found above the test."); + } +} + +const entry = (key: string, byte: number, storedAt = "2026-01-01T00:00:00.000Z", type = "t"): CultCacheEnvelope => ({ + key, + type, + payload: documentPayloadFromBytes(Uint8Array.of(0x91, byte)), + storedAt, +}); + +async function scratch(): Promise { + return mkdtemp(join(tmpdir(), "cultcache-conditional-")); +} + +test("the content version of every record of the C#-written store equals the version the reference read", async () => { + const vectors = findVectors(__dirname); + const rows = decode(readFileSync(join(vectors, "versions.msgpack"))) as Array<[string, string]>; + assert.equal(rows.length, 2); + const directory = await scratch(); + try { + const path = join(directory, "store.cc"); + await copyFile(join(vectors, "store.cc"), path); + const store = new SingleFileMessagePackBackingStore(path); + for (const [key, version] of rows) { + const found = await store.read(key); + assert.equal(found?.version, version, `read(${key}).version`); + assert.equal(recordVersion(found!.envelope), version, `recordVersion(${key})`); + } + assert.equal(await store.read("absent"), undefined); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +test("the version is the SHA-256 of the record's persisted encoding and changes with every field of it", async () => { + const base = entry("k", 1); + const hex = (e: CultCacheEnvelope) => + createHash("sha256").update(encode([e.key, schemaIdForEnvelope(e), e.storedAt, documentPayloadBytes(e.payload)])).digest("hex"); + assert.equal(recordVersion(base), hex(base)); + const versions = new Set([base, entry("k", 2), entry("k", 1, "2026-01-01T00:00:00.001Z"), entry("j", 1), { ...base, schemaId: "sha256:other" }].map(recordVersion)); + assert.equal(versions.size, 5); +}); + +test("commitIf writes nothing when the version does not hold, and commits when it does", async () => { + const directory = await scratch(); + try { + const path = join(directory, "store.cc"); + const store = new SingleFileMessagePackBackingStore(path); + // null expects no record: it commits over an absent key and mismatches over a present one. + assert.deepEqual(await store.commitIf(entry("k", 1), "not-a-version"), { outcome: "mismatch" }); + assert.equal(existsSync(path), false, "a mismatch creates no file"); + assert.equal((await store.commitIf(entry("k", 1), null)).outcome, "committed"); + const first = (await store.read("k"))!; + const before = await readFile(path); + assert.deepEqual(await store.commitIf(entry("k", 2), null), { outcome: "mismatch" }); + assert.deepEqual(await store.commitIf(entry("k", 2), "0".repeat(64)), { outcome: "mismatch" }); + assert.deepEqual([...(await readFile(path))], [...before], "a mismatch leaves the bytes identical"); + assert.equal((await store.commitIf(entry("k", 2), first.version)).outcome, "committed"); + const second = (await store.read("k"))!; + assert.notEqual(second.version, first.version); + assert.deepEqual([...documentPayloadBytes(second.envelope.payload)], [0x91, 2]); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +test("commitIf judges the record at the key whatever its type, and a record of another key does not matter", async () => { + const directory = await scratch(); + try { + const store = new SingleFileMessagePackBackingStore(join(directory, "store.cc")); + await store.push(entry("a", 1, undefined, "u")); + await store.push(entry("b", 1)); + const other = (await store.read("a"))!; + assert.deepEqual(await store.commitIf(entry("b", 2), other.version), { outcome: "mismatch" }); + assert.equal((await store.commitIf(entry("b", 2), recordVersion((await store.read("b"))!.envelope))).outcome, "committed"); + assert.deepEqual([...documentPayloadBytes((await store.read("a"))!.envelope.payload)], [0x91, 1], "the record at another key is untouched"); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +test("a write that replaces a record is stored strictly later than it, whatever the write's own storedAt", async () => { + const directory = await scratch(); + try { + const store = new SingleFileMessagePackBackingStore(join(directory, "store.cc")); + const at = "2026-03-04T05:06:07.100Z"; + await store.push(entry("k", 1, at)); + // Not later (equal, earlier, unparseable) is written one millisecond after the record it replaces, by push, pushAll and commitIf. + await store.push(entry("k", 2, at)); + assert.equal((await store.read("k"))!.envelope.storedAt, "2026-03-04T05:06:07.101Z"); + await store.push(entry("k", 3, "2020-01-01T00:00:00.000Z")); + assert.equal((await store.read("k"))!.envelope.storedAt, "2026-03-04T05:06:07.102Z"); + await store.pushAll([entry("k", 4, "not a time")]); + assert.equal((await store.read("k"))!.envelope.storedAt, "2026-03-04T05:06:07.103Z"); + const committed = await store.commitIf(entry("k", 5, "2020-01-01T00:00:00.000Z"), (await store.read("k"))!.version); + assert.deepEqual(committed, { outcome: "committed", storedAt: "2026-03-04T05:06:07.104Z" }); + assert.equal((await store.read("k"))!.envelope.storedAt, "2026-03-04T05:06:07.104Z"); + // A later stamp is kept as it is, and a key with no record before it is stamped as it is. + await store.push(entry("k", 6, "2030-01-01T00:00:00.000Z")); + await store.push(entry("fresh", 1, "2000-01-01T00:00:00.000Z")); + assert.equal((await store.read("k"))!.envelope.storedAt, "2030-01-01T00:00:00.000Z"); + assert.equal((await store.read("fresh"))!.envelope.storedAt, "2000-01-01T00:00:00.000Z"); + assert.equal(readSingleFileStore(join(directory, "store.cc")).envelopes.length, 2); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +test("a replacing write that would stamp past year 9999 is refused and writes nothing", async () => { + const directory = await scratch(); + try { + const store = new SingleFileMessagePackBackingStore(join(directory, "store.cc")); + const last = "9999-12-31T23:59:59.999Z"; + await store.push(entry("k", 1, last)); + await assert.rejects(() => store.push(entry("k", 2, last)), RangeError); + assert.equal((await store.read("k"))!.envelope.storedAt, last); + assert.deepEqual([...documentPayloadBytes((await store.read("k"))!.envelope.payload)], [0x91, 1]); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +test("a write that restores an earlier value does not restore its version", async () => { + const directory = await scratch(); + try { + const store = new SingleFileMessagePackBackingStore(join(directory, "store.cc")); + const at = "2026-03-04T05:06:07.100Z"; + await store.push(entry("k", 1, at)); + const first = (await store.read("k"))!.version; + await store.push(entry("k", 5, at)); + await store.push(entry("k", 1, at)); + assert.notEqual((await store.read("k"))!.version, first, "an A-B-A within one millisecond keeps no version"); + assert.deepEqual(await store.commitIf(entry("k", 9, at), first), { outcome: "mismatch" }); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +test("two stores on one file never both commit against the same version", async () => { + const directory = await scratch(); + try { + const path = join(directory, "store.cc"); + const left = new SingleFileMessagePackBackingStore(path); + const right = new SingleFileMessagePackBackingStore(path); + await left.push(entry("k", 0)); + const version = (await left.read("k"))!.version; + const outcomes = await Promise.all(Array.from({ length: 6 }, (_, i) => (i % 2 === 0 ? left : right).commitIf(entry("k", i + 1), version))); + assert.equal(outcomes.filter((o) => o.outcome === "committed").length, 1); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); diff --git a/packages/cultcache-ts/test/store-file.test.ts b/packages/cultcache-ts/test/store-file.test.ts index 7ddf7fe3..6cb70392 100644 --- a/packages/cultcache-ts/test/store-file.test.ts +++ b/packages/cultcache-ts/test/store-file.test.ts @@ -424,7 +424,7 @@ test("a link put while a writer waits on the store lock is refused once it holds await new SingleFileMessagePackBackingStore(target).push(noteEnvelope("a")); const before = await readFile(target); const writes: Array<[string, (store: SingleFileMessagePackBackingStore) => Promise]> = [ - ["push", (store) => store.push(noteEnvelope("b"))], + ["push", async (store) => void (await store.push(noteEnvelope("b")))], ["pushAll", (store) => store.pushAll([noteEnvelope("b")])], ["delete", (store) => store.delete(noteEnvelope("a"))], ]; diff --git a/packages/cultcache-ts/test/union-surface-fixture.ts b/packages/cultcache-ts/test/union-surface-fixture.ts index 1b556eb1..28089522 100644 --- a/packages/cultcache-ts/test/union-surface-fixture.ts +++ b/packages/cultcache-ts/test/union-surface-fixture.ts @@ -7,6 +7,7 @@ import { z } from "zod"; import { codecs } from "../src"; import type { Codec } from "../src/codec"; import { defineDocumentType, documentPayloadFromBytes } from "../src/document"; +import { recordVersion } from "../src/single-file-messagepack-backing-store"; import type { CacheBackingStore, CultCacheEnvelope } from "../src/types"; // The union-surface fixture: the codecs, schema and value tests/vectors/union-parity was written from, shared by the @@ -235,6 +236,16 @@ export function memoryStore(entries: CultCacheEnvelope[]): CacheBackingStore & { async pullAll() { return entries.map((entry) => ({ ...entry })); }, async push(entry) { entries.push(entry); }, async delete() {}, + async read(key) { + const envelope = [...entries].reverse().find((entry) => entry.key === key); + return envelope === undefined ? undefined : { envelope, version: recordVersion(envelope) }; + }, + async commitIf(entry, expectedVersion) { + const current = [...entries].reverse().find((candidate) => candidate.key === entry.key); + if ((current === undefined ? null : recordVersion(current)) !== expectedVersion) return { outcome: "mismatch" }; + entries.push(entry); + return { outcome: "committed", storedAt: entry.storedAt }; + }, }; } diff --git a/packages/cultcache-ts/test/update.test.ts b/packages/cultcache-ts/test/update.test.ts new file mode 100644 index 00000000..fb0d56fd --- /dev/null +++ b/packages/cultcache-ts/test/update.test.ts @@ -0,0 +1,600 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { setImmediate as tick } from "node:timers/promises"; + +import { decode, encode } from "@msgpack/msgpack"; +import { z } from "zod"; + +import { CultCache } from "../src/cult-cache"; +import { defineDocumentRegistry, defineDocumentType, documentPayloadBytes, documentPayloadFromBytes } from "../src/document"; +import { SingleFileMessagePackBackingStore, readSingleFileStore } from "../src/single-file-messagepack-backing-store"; +import { SchemaConflictError, schemaIdForEnvelope } from "../src/store-format"; +import type { CacheBackingStore } from "../src/types"; +import { envelopeOf, memoryStore, read, surfaceDocument } from "./union-surface-fixture"; + +// CultCache.update: read the record and its content version from the store, apply a function to a copy, commit only if the +// version still holds, run again on a lost race (variants:cut_spec:cut-update-with-retry-ts.r1; the C# tests in +// tests/GameCult.Caching.Tests/UpdateTests.cs are the parity). + +const counterDocument = defineDocumentType({ + type: "update.counter", + schemaId: "update.counter.v1", + schemaName: "update.counter", + schemaVersion: "update.counter.v1", + schema: z.object({ value: z.number().int() }), +}); +const tallyDocument = defineDocumentType({ + type: "update.tally", + schemaId: "update.tally.v1", + schemaName: "update.tally", + schemaVersion: "update.tally.v1", + schema: z.object({ count: z.number().int() }), +}); +const registry = defineDocumentRegistry(counterDocument, tallyDocument); +const settingsFields = { schemaName: "update.settings", schemaVersion: "update.settings.v1", schemaId: "update.settings.v1", schema: z.object({ theme: z.string() }) }; +const settingsDocument = defineDocumentType({ type: "update.settings", ...settingsFields, global: true }); +const plainSettingsDocument = defineDocumentType({ type: "update.settings", ...settingsFields }); + +const KEY = "counter"; + +async function withDirectory(body: (path: string) => Promise): Promise { + const directory = await mkdtemp(join(tmpdir(), "cultcache-update-")); + try { + return await body(join(directory, "store.cc")); + } finally { + await rm(directory, { recursive: true, force: true }); + } +} + +type Registry = Parameters["withRegistry"]>[0]; + +async function open(path: string, documents: Registry = registry): Promise { + const cache = CultCache.builder().withRegistry(documents).withGenericStore(new SingleFileMessagePackBackingStore(path)).build(); + await cache.pullAllBackingStores(); + return cache; +} + +// What the file holds at the key, read without a cache. +function stored(path: string, key = KEY): number | undefined { + const envelope = readSingleFileStore(path).envelopes.find((candidate) => candidate.key === key); + return envelope === undefined ? undefined : (decode(documentPayloadBytes(envelope.payload)) as { value: number }).value; +} + +const plus = (n: number) => (current: { value: number } | undefined) => ({ value: (current?.value ?? 0) + n }); + +test("two writers updating lose no increment", async () => { + await withDirectory(async (path) => { + const [left, right] = [await open(path), await open(path)]; + let calls = 0; + const loop = async (cache: CultCache) => { + const outcomes: string[] = []; + for (let i = 0; i < 50; i++) { + const result = await cache.update(counterDocument, KEY, async (current) => { + calls++; + await tick(); + return { value: (current?.value ?? 0) + 1 }; + }, { maxAttempts: 50 }); + outcomes.push(result.outcome); + } + return outcomes; + }; + const outcomes = (await Promise.all([loop(left), loop(right)])).flat(); + assert.ok(outcomes.every((outcome) => outcome === "updated"), outcomes.join()); + assert.equal(stored(path), 100); + assert.ok(calls > 100, `the caches raced: apply ran ${calls} times`); + }); +}); + +test("a lost attempt reruns apply on the fresh value", async () => { + await withDirectory(async (path) => { + const [cache, rival] = [await open(path), await open(path)]; + await cache.put(counterDocument, KEY, { value: 1 }); + const seen: Array = []; + const result = await cache.update(counterDocument, KEY, async (current) => { + seen.push(current?.value); + if (seen.length === 1) await rival.put(counterDocument, KEY, { value: 100 }); + return { value: current!.value + 1 }; + }); + assert.deepEqual(seen, [1, 100]); + assert.equal(result.outcome, "updated"); + assert.equal(result.document?.value, 101); + assert.equal(stored(path), 101); + }); +}); + +test("exhausted attempts return lostRace, and the cache then shows the store", async () => { + await withDirectory(async (path) => { + const [cache, rival] = [await open(path), await open(path)]; + await cache.put(counterDocument, KEY, { value: 1 }); + let calls = 0; + const result = await cache.update( + counterDocument, + KEY, + async (current) => { + calls++; + await rival.put(counterDocument, KEY, { value: 100 * calls }); + return { value: current!.value + 1 }; + }, + { maxAttempts: 3 }, + ); + assert.equal(calls, 3); + assert.equal(result.outcome, "lostRace"); + assert.equal(result.document?.value, 300); + assert.equal(stored(path), 300); + }); +}); + +test("five attempts by default", async () => { + await withDirectory(async (path) => { + const [cache, rival] = [await open(path), await open(path)]; + let calls = 0; + const result = await cache.update(counterDocument, KEY, async () => { + calls++; + await rival.put(counterDocument, KEY, { value: calls }); + return { value: -1 }; + }); + assert.equal(result.outcome, "lostRace"); + assert.equal(calls, 5); + }); +}); + +test("maxAttempts 1 makes one call", async () => { + await withDirectory(async (path) => { + const [cache, rival] = [await open(path), await open(path)]; + await cache.put(counterDocument, KEY, { value: 1 }); + let calls = 0; + const apply = async (current: { value: number } | undefined) => { + calls++; + await rival.put(counterDocument, KEY, { value: 50 + calls }); + return { value: current!.value + 1 }; + }; + assert.equal((await cache.update(counterDocument, KEY, apply, { maxAttempts: 1 })).outcome, "lostRace"); + assert.equal(calls, 1); + }); +}); + +// An apply that declines resolves at once whatever the bound, so a validation that lets a bad value through fails the assertion +// below instead of looping: no apply here ever loses a race. +test("invalid maxAttempts (undefined is the default, null is refused) and a non-function reject before any call", async () => { + await withDirectory(async (path) => { + const cache = await open(path); + let calls = 0; + const apply = () => (calls++, undefined); + for (const maxAttempts of [0, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY, null as never]) { + await assert.rejects(() => cache.update(counterDocument, KEY, apply, { maxAttempts }), RangeError, `maxAttempts ${String(maxAttempts)}`); + } + await assert.rejects(() => cache.update(counterDocument, KEY, "no" as never), TypeError); + assert.equal(calls, 0); + assert.equal((await cache.update(counterDocument, KEY, apply, { maxAttempts: undefined })).outcome, "declined"); + assert.equal(calls, 1); + }); +}); + +test("null from apply declines as undefined does: nothing is written", async () => { + await withDirectory(async (path) => { + const cache = await open(path); + await cache.put(counterDocument, KEY, { value: 1 }); + const before = await readFile(path); + const result = await cache.update(counterDocument, KEY, () => null as never); + assert.deepEqual(result, { outcome: "declined", document: cache.get(counterDocument, KEY) }); + assert.deepEqual([...(await readFile(path))], [...before]); + }); +}); + +test("a cache with no store refuses a key held as another type before apply runs", async () => { + const cache = CultCache.builder().withRegistry(registry).build(); + await cache.put(tallyDocument, KEY, { count: 1 }); + let calls = 0; + await assert.rejects(() => cache.update(counterDocument, KEY, () => (calls++, { value: 1 })), /is not a "update.counter"/); + assert.equal(calls, 0); +}); + +test("a store attached while apply awaits turns the commit into a lost race, never a blind push", async () => { + await withDirectory(async (path) => { + const writer = await open(path); + await writer.put(counterDocument, KEY, { value: 50 }); + const cache = CultCache.builder().withRegistry(registry).build(); + const seen: Array = []; + const result = await cache.update(counterDocument, KEY, async (current) => { + seen.push(current?.value); + if (seen.length === 1) cache.addBackingStore(new SingleFileMessagePackBackingStore(path)); + return { value: (current?.value ?? 0) + 1 }; + }); + assert.deepEqual(seen, [undefined, 50], "the second attempt reads the store's record"); + assert.equal(result.outcome, "updated"); + assert.equal(stored(path), 51); + }); +}); + +test("put installs the storedAt the store stamped, so the cache and the file agree", async () => { + await withDirectory(async (path) => { + const cache = await open(path); + await cache.put(counterDocument, KEY, { value: 1 }); + const store = new SingleFileMessagePackBackingStore(path); + const [envelope] = readSingleFileStore(path).envelopes; + await store.push({ ...envelope!, payload: documentPayloadFromBytes(encode({ value: 99 })), storedAt: "2099-01-01T00:00:00.000Z" }); + await cache.put(counterDocument, KEY, { value: 2 }); + const onDisk = readSingleFileStore(path).envelopes[0]!.storedAt; + assert.equal(onDisk, "2099-01-01T00:00:00.001Z"); + assert.equal(cache.getEnvelope(counterDocument, KEY)!.storedAt, onDisk); + }); +}); + +test("apply gets a fresh copy: changing it changes nothing held, and a lost attempt's change is not seen by the next", async () => { + await withDirectory(async (path) => { + const [cache, rival] = [await open(path), await open(path)]; + await cache.put(counterDocument, KEY, { value: 1 }); + const argument: Array<{ value: number } | undefined> = []; + const seen: number[] = []; + await cache.update(counterDocument, KEY, async (current) => { + argument.push(current); + assert.notEqual(current, cache.get(counterDocument, KEY)); + seen.push(current!.value); + current!.value += 1000; + assert.equal(cache.get(counterDocument, KEY)!.value, 1); + if (seen.length === 1) await rival.put(counterDocument, KEY, { value: 7 }); + return { value: current!.value + 1 }; + }); + assert.deepEqual(seen, [1, 7], "the second attempt does not see the first one's change"); + assert.notEqual(argument[0], argument[1]); + }); +}); + +test("an in-place change to the held value is neither applied nor persisted", async () => { + await withDirectory(async (path) => { + const [cache, fresh] = [await open(path), await open(path)]; + await cache.put(counterDocument, KEY, { value: 1 }); + cache.get(counterDocument, KEY)!.value = 999; + const seen: number[] = []; + const result = await cache.update(counterDocument, KEY, (current) => { + seen.push(current!.value); + return { value: current!.value + 1 }; + }); + assert.deepEqual(seen, [1]); + assert.equal(stored(path), 2); + await fresh.pullAllBackingStores(); + assert.equal(fresh.get(counterDocument, KEY)!.value, 2); + result.document!.value = 999; + await cache.update(counterDocument, KEY, plus(1)); + assert.equal(stored(path), 3); + }); +}); + +test("undefined from apply declines: nothing is written", async () => { + await withDirectory(async (path) => { + const cache = await open(path); + await cache.put(counterDocument, KEY, { value: 1 }); + const before = await readFile(path); + const held = await cache.update(counterDocument, KEY, () => undefined); + assert.equal(held.outcome, "declined"); + assert.equal(held.document, cache.get(counterDocument, KEY)); + const absent = await cache.update(counterDocument, "absent", () => undefined); + assert.deepEqual(absent, { outcome: "declined", document: undefined }); + assert.deepEqual([...(await readFile(path))], [...before]); + }); +}); + +test("an absent key is created once under a race", async () => { + await withDirectory(async (path) => { + const [left, right] = [await open(path), await open(path)]; + const outcomes = await Promise.all( + [left, right].map((cache) => + cache.update(counterDocument, KEY, async (current) => { + await tick(); + return { value: (current?.value ?? 0) + 1 }; + }), + ), + ); + assert.deepEqual(outcomes.map((result) => result.outcome), ["updated", "updated"]); + assert.equal(stored(path), 2); + }); +}); + +test("a rival writing and restoring the value still reruns apply", async () => { + await withDirectory(async (path) => { + const [cache, rival] = [await open(path), await open(path)]; + await cache.put(counterDocument, KEY, { value: 1 }); + const seen: number[] = []; + await cache.update(counterDocument, KEY, async (current) => { + seen.push(current!.value); + if (seen.length === 1) { + await rival.put(counterDocument, KEY, { value: 5 }); + await rival.put(counterDocument, KEY, { value: 1 }); + } + return { value: current!.value + 1 }; + }); + assert.deepEqual(seen, [1, 1]); + assert.equal(stored(path), 2); + }); +}); + +test("a rival removal is seen as undefined", async () => { + await withDirectory(async (path) => { + const [cache, rival] = [await open(path), await open(path)]; + await cache.put(counterDocument, KEY, { value: 1 }); + const seen: Array = []; + await cache.update(counterDocument, KEY, async (current) => { + seen.push(current?.value); + if (seen.length === 1) { + await rival.pullAllBackingStores(); + await rival.delete(counterDocument, KEY); + } + return { value: (current?.value ?? 0) + 1 }; + }); + assert.deepEqual(seen, [1, undefined]); + assert.equal(stored(path), 1); + }); +}); + +test("a stale cache's first attempt reads the store", async () => { + await withDirectory(async (path) => { + const [stale, rival] = [await open(path), await open(path)]; + await rival.put(counterDocument, KEY, { value: 1 }); + await stale.pullAllBackingStores(); + await rival.put(counterDocument, KEY, { value: 7 }); + const seen: number[] = []; + await stale.update(counterDocument, KEY, (current) => { + seen.push(current!.value); + return { value: current!.value + 1 }; + }); + assert.deepEqual(seen, [7]); + assert.equal(stored(path), 8); + }); +}); + +test("refusals are not retried and apply is not called for a record it cannot copy", async () => { + await withDirectory(async (path) => { + const cache = await open(path); + await cache.put(tallyDocument, "tally", { count: 1 }); + const before = await readFile(path); + let calls = 0; + const apply = (current: { value: number } | undefined) => { + calls++; + return { value: (current?.value ?? 0) + 1 }; + }; + // Another type at the key. + await assert.rejects(() => cache.update(counterDocument, "tally", apply), /not a "update.counter"/); + // A foreign record: a type this cache has no definition for. + const narrow = await open(path, defineDocumentRegistry(counterDocument)); + await assert.rejects(() => narrow.update(counterDocument, "tally", apply), /not a "update.counter"/); + assert.equal(calls, 0); + assert.deepEqual([...(await readFile(path))], [...before]); + + // A store without read and commitIf cannot take a conditional write. + const bare: CacheBackingStore = { pullAll: async () => [], push: async () => {}, delete: async () => {} }; + const plain = CultCache.builder().withRegistry(registry).withGenericStore(bare).build(); + await plain.pullAllBackingStores(); + await assert.rejects(() => plain.update(counterDocument, KEY, apply), /read and commitIf/); + assert.equal(calls, 0); + + // A record held read-only is refused by the judge after apply ran once; apply that throws is not retried either. + const surface = surfaceDocument(); + const entries = [envelopeOf(await read("extra-slot-record.msgpack"))]; + const readOnly = CultCache.builder().withDocumentType(surface).withGenericStore(memoryStore(entries)).build(); + await readOnly.pullAllBackingStores(); + let surfaceCalls = 0; + await assert.rejects(() => readOnly.update(surface, "surface", (value) => (surfaceCalls++, value)), SchemaConflictError); + assert.equal(surfaceCalls, 1); + assert.equal(entries.length, 1, "nothing reached the store"); + await assert.rejects( + () => cache.update(counterDocument, KEY, () => { calls++; throw new Error("boom"); }), + /boom/, + ); + assert.equal(calls, 1); + }); +}); + +test("own writes cause no retry, and updated returns the held instance", async () => { + await withDirectory(async (path) => { + const cache = await open(path); + await cache.put(counterDocument, KEY, { value: 1 }); + let calls = 0; + const apply = (current: { value: number } | undefined) => { + calls++; + return { value: current!.value + 1 }; + }; + await cache.update(counterDocument, KEY, apply); + const second = await cache.update(counterDocument, KEY, apply); + assert.equal(calls, 2); + assert.equal(second.outcome, "updated"); + assert.equal(second.document, cache.get(counterDocument, KEY)); + assert.equal(stored(path), 3); + // The cache holds the record as the store wrote it, the stamp the store chose included: over a record stamped in the future + // the store writes one millisecond after it, whatever the clock says. + const future = readSingleFileStore(path).envelopes[0]!; + await new SingleFileMessagePackBackingStore(path).push({ ...future, payload: documentPayloadFromBytes(encode({ value: 99 })), storedAt: "2099-01-01T00:00:00.000Z" }); + await cache.update(counterDocument, KEY, apply); + assert.equal(readSingleFileStore(path).envelopes[0]!.storedAt, "2099-01-01T00:00:00.001Z"); + assert.equal(cache.getEnvelope(counterDocument, KEY)!.storedAt, "2099-01-01T00:00:00.001Z"); + }); +}); + +test("apply runs outside the cache's serial queue", async () => { + await withDirectory(async (path) => { + const cache = await open(path); + let calls = 0; + const result = await cache.update(counterDocument, KEY, async (current) => { + calls++; + await Promise.race([ + cache.put(tallyDocument, "other", { count: 1 }), + new Promise((_, reject) => setTimeout(() => reject(new Error("a cache call from apply did not finish")), 5000).unref()), + ]); + return { value: (current?.value ?? 0) + 1 }; + }); + assert.equal(result.outcome, "updated"); + assert.equal(calls, 1); + }); +}); + +test("a cache with no store loses no increment under interleaved updates", async () => { + const cache = CultCache.builder().withRegistry(registry).build(); + await cache.pullAllBackingStores(); + let calls = 0; + const loop = async () => { + let updated = 0; + for (let i = 0; i < 50; i++) { + const result = await cache.update(counterDocument, KEY, async (current) => { + calls++; + await tick(); + return { value: (current?.value ?? 0) + 1 }; + }); + if (result.outcome === "updated") updated++; + } + return updated; + }; + // The two loops run in lockstep, so one may lose every round (a lostRace is the answer, not a lost write): what is held is + // exactly what the updated outcomes wrote. + const updated = (await Promise.all([loop(), loop()])).reduce((sum, n) => sum + n, 0); + assert.equal(cache.get(counterDocument, KEY)!.value, updated); + assert.ok(calls > updated, `the loops raced: apply ran ${calls} times for ${updated} writes`); +}); + +test("a store-less cache copies the bytes it admitted, not the held value", async () => { + const cache = CultCache.builder().withRegistry(registry).build(); + await cache.put(counterDocument, KEY, { value: 1 }); + cache.get(counterDocument, KEY)!.value = 999; + const seen: number[] = []; + await cache.update(counterDocument, KEY, (current) => (seen.push(current!.value), { value: current!.value + 1 })); + assert.deepEqual(seen, [1]); +}); + +test("updateGlobal updates the global record and refuses one loaded from a legacy key until putGlobal retires it", async () => { + await withDirectory(async (path) => { + const settings = defineDocumentRegistry(settingsDocument); + const cache = await open(path, settings); + const first = await cache.updateGlobal(settingsDocument, (current) => ({ theme: `${current?.theme ?? ""}a` })); + assert.equal(first.outcome, "updated"); + assert.deepEqual(cache.getGlobal(settingsDocument), { theme: "a" }); + assert.equal(readSingleFileStore(path).envelopes[0]!.key, CultCache.GLOBAL_KEY); + await rm(path); + + const legacy = await open(path, defineDocumentRegistry(plainSettingsDocument)); + await legacy.put(plainSettingsDocument, "old-settings", { theme: "x" }); + const loaded = await open(path, settings); + let calls = 0; + await assert.rejects(() => loaded.updateGlobal(settingsDocument, (current) => (calls++, current)), /putGlobal to retire its legacy key/); + assert.equal(calls, 0); + // Every path to the write is refused, the global's own key included, and the legacy record survives. + const before = await readFile(path); + await assert.rejects(() => loaded.update(settingsDocument, CultCache.GLOBAL_KEY, () => ({ theme: "lost" })), /putGlobal to retire its legacy key/); + assert.deepEqual([...(await readFile(path))], [...before]); + assert.deepEqual(readSingleFileStore(path).envelopes.map((e) => e.key), ["old-settings"]); + await loaded.putGlobal(settingsDocument, { theme: "y" }); + assert.equal((await loaded.updateGlobal(settingsDocument, (current) => ({ theme: `${current!.theme}z` }))).outcome, "updated"); + assert.deepEqual(loaded.getGlobal(settingsDocument), { theme: "yz" }); + await assert.rejects(() => cache.update(settingsDocument, "not-global", () => ({ theme: "q" })), /must use key/); + }); +}); + +test("a legacy global that a pull loads while apply awaits is refused at the write, and its legacy record survives", async () => { + await withDirectory(async (path) => { + const settings = defineDocumentRegistry(settingsDocument); + const cache = await open(path, settings); + const legacy = await open(path, defineDocumentRegistry(plainSettingsDocument)); + await assert.rejects( + () => + cache.update(settingsDocument, CultCache.GLOBAL_KEY, async () => { + await legacy.put(plainSettingsDocument, "old-settings", { theme: "x" }); + await cache.pullAllBackingStores(); + return { theme: "lost" }; + }), + /putGlobal to retire its legacy key/, + ); + assert.deepEqual(readSingleFileStore(path).envelopes.map((e) => e.key), ["old-settings"]); + }); +}); + +test("the ledger holds under contention: the stored value is the number of updated outcomes", async () => { + await withDirectory(async (path) => { + const caches = await Promise.all([open(path), open(path), open(path)]); + const results = await Promise.all( + caches.map(async (cache) => { + let updated = 0; + for (let i = 0; i < 25; i++) { + const result = await cache.update(counterDocument, KEY, async (current) => { + await tick(); + return { value: (current?.value ?? 0) + 1 }; + }); + if (result.outcome === "updated") updated++; + } + return updated; + }), + ); + assert.equal(stored(path), results.reduce((sum, n) => sum + n, 0)); + }); +}); + +test("a flush of an unchanged cache rewrites the file byte for byte, and an update does not lose a race to it", async () => { + await withDirectory(async (path) => { + const cache = await open(path); + await cache.put(counterDocument, KEY, { value: 1 }); + await cache.put(counterDocument, "other", { value: 7 }); + const store = new SingleFileMessagePackBackingStore(path); + const before = await readFile(path); + // What CultMesh.flush does: push the whole stored image, twice. + await store.pushAll(await store.pullAll()); + await store.pushAll(await store.pullAll()); + assert.deepEqual(await readFile(path), before); + const result = await cache.update(counterDocument, KEY, async (current) => { + await store.pushAll(await store.pullAll()); + return { value: current!.value + 1 }; + }, { maxAttempts: 1 }); + assert.equal(result.outcome, "updated"); + assert.equal(stored(path), 2); + }); +}); + +test("a changed record is re-stamped later than the one it replaces, and a revert to earlier bytes too", async () => { + await withDirectory(async (path) => { + const store = new SingleFileMessagePackBackingStore(path); + const cache = await open(path); + await cache.put(counterDocument, KEY, { value: 1 }); + const first = readSingleFileStore(path).envelopes[0]!; + const same = await store.push({ ...first, storedAt: "2099-01-01T00:00:00.000Z" }); + assert.equal(same.storedAt, first.storedAt, "identical content keeps its stamp"); + await cache.put(counterDocument, KEY, { value: 2 }); + const second = readSingleFileStore(path).envelopes[0]!; + const reverted = await store.push({ ...first, storedAt: second.storedAt }); + assert.ok(Date.parse(reverted.storedAt) > Date.parse(second.storedAt)); + }); +}); + +test("a push result that is not a parseable stamp is ignored", async () => { + for (const result of [true, {}, "", "garbage", { storedAt: "" }, { storedAt: "garbage" }]) { + const entries: Parameters[0] = []; + const store = { ...memoryStore(entries), push: async (entry: (typeof entries)[number]) => { entries.push(entry); return result as never; } }; + const cache = CultCache.builder().withRegistry(registry).withGenericStore(store).build(); + await cache.put(counterDocument, KEY, { value: 1 }); + const held = cache.getEnvelope(counterDocument, KEY)!.storedAt; + assert.equal(Number.isNaN(Date.parse(held)), false, `held stamp ${held} after a push result of ${JSON.stringify(result)}`); + } +}); + +test("a rewrite of identical content keeps the stamp but writes the writer's type and catalog entry", async () => { + await withDirectory(async (path) => { + const old = defineDocumentType({ type: "update.rn.old", schemaId: "update.rn.v1", schemaName: "update.rn.old", schemaVersion: "update.rn.v1", schema: z.object({ value: z.number().int() }) }); + const renamed = defineDocumentType({ type: "update.rn.new", schemaId: "update.rn.v1", schemaName: "update.rn.new", schemaVersion: "update.rn.v1", schema: z.object({ value: z.number().int() }) }); + await (await open(path, defineDocumentRegistry(old))).put(old, KEY, { value: 1 }); + const before = readSingleFileStore(path).envelopes[0]!; + await (await open(path, defineDocumentRegistry(renamed))).put(renamed, KEY, { value: 1 }); + const after = readSingleFileStore(path).envelopes[0]!; + assert.equal(after.storedAt, before.storedAt, "identical content keeps its stamp"); + assert.equal(after.type, "update.rn.new", "the writer's type is what the file says"); + }); +}); + +test("a rewrite of the same bytes under another schema id is a changed record: stamped later, written under the new id", async () => { + await withDirectory(async (path) => { + const v1 = defineDocumentType({ type: "update.sid", schemaId: "update.sid.v1", schemaName: "update.sid", schemaVersion: "update.sid.v1", schema: z.object({ value: z.number().int() }) }); + const v2 = defineDocumentType({ type: "update.sid", schemaId: "update.sid.v2", schemaName: "update.sid", schemaVersion: "update.sid.v2", compatibleSchemaIds: ["update.sid.v1"], schema: z.object({ value: z.number().int() }) }); + await (await open(path, defineDocumentRegistry(v1))).put(v1, KEY, { value: 1 }); + const before = readSingleFileStore(path).envelopes[0]!; + await (await open(path, defineDocumentRegistry(v2))).put(v2, KEY, { value: 1 }); + const after = readSingleFileStore(path).envelopes[0]!; + assert.ok(Date.parse(after.storedAt) > Date.parse(before.storedAt)); + assert.equal(schemaIdForEnvelope(after), "update.sid.v2"); + }); +}); diff --git a/tests/GameCult.Caching.Tests/ContentVersionVectorTests.cs b/tests/GameCult.Caching.Tests/ContentVersionVectorTests.cs new file mode 100644 index 00000000..7c2cd5a8 --- /dev/null +++ b/tests/GameCult.Caching.Tests/ContentVersionVectorTests.cs @@ -0,0 +1,115 @@ +#nullable enable +using System; +using System.Buffers; +using System.IO; +using System.Linq; +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using MessagePack; +using NUnit.Framework; + +namespace GameCult.Caching.Tests +{ + // tests/vectors/content-version: the content version of a stored record, the lowercase hex SHA-256 of its persisted encoding + // (variants:ruling:write-set-content-version-declared-ownership), as the reference reads it from a single-file store. The C# + // reference is the only writer: under CULTCACHE_WRITE_VECTORS=1 the store is written and the versions read from it are written + // beside it; every other run opens the committed store.cc and compares what the reference reads to versions.msgpack. Another + // runtime computes the version from the same file and must equal these rows. + public class ContentVersionVectorTests + { + private const string Vector = "content-version"; + + private static readonly Type Item = + EmittedDocumentTypes.Emit("CvItem", "tests.cv_item", "tests.cv_item.v1", new[] { new EmittedDocumentTypes.Field("Text", typeof(string), 0) }); + + private static readonly CultDocumentRegistry Registry = CultDocumentRegistry.ForTypes(new[] { Item }); + + private static readonly (string Key, string Text)[] Records = { ("alpha", "first"), ("beta", "second") }; + + private static byte[] StoreBytes() + { + var directory = Path.Combine(Path.GetTempPath(), $"cultlib-cvv-{Guid.NewGuid():N}"); + Directory.CreateDirectory(directory); + try + { + var path = Path.Combine(directory, "store.cc"); + using (var cache = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Registry, StoreFlushOnDispose = true })) + { + cache.Clock = () => VectorFiles.StoredAt; + foreach (var (key, text) in Records) + cache.UpsertAsync(Item, EmittedDocumentTypes.New(Item, ("Text", text)), new CultRecordKey(key)).GetAwaiter().GetResult(); + cache.FlushAsync().GetAwaiter().GetResult(); + } + + return File.ReadAllBytes(path); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + // What the reference reads from a store file: [key, version] for each record, sorted by key. + private static (string Key, string Version)[] ReadVersions(byte[] store) + { + var directory = Path.Combine(Path.GetTempPath(), $"cultlib-cvr-{Guid.NewGuid():N}"); + Directory.CreateDirectory(directory); + try + { + var path = Path.Combine(directory, "store.cc"); + File.WriteAllBytes(path, store); + using var cache = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Registry }); + return cache.AllStoredDocuments + .Select(stored => (Key: stored.Key.Value, Version: stored.StoredVersion)) + .OrderBy(row => row.Key, StringComparer.Ordinal) + .ToArray(); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + private static byte[] RowsFile((string Key, string Version)[] rows) + { + var buffer = new ArrayBufferWriter(); + var writer = new MessagePackWriter(buffer); + writer.WriteArrayHeader(rows.Length); + foreach (var (key, version) in rows) + { + writer.WriteArrayHeader(2); + writer.Write(key); + writer.Write(version); + } + + writer.Flush(); + return buffer.WrittenSpan.ToArray(); + } + + [Test] + public void TheReferenceReadsTheCommittedVersions() + { + if (VectorFiles.Writing) + { + var store = StoreBytes(); + VectorFiles.Check(Vector, "store.cc", store); + VectorFiles.Check(Vector, "versions.msgpack", RowsFile(ReadVersions(store))); + return; + } + + var committed = VectorFiles.Committed(Vector, "store.cc"); + var rows = ReadVersions(committed); + Assert.That(rows.Select(row => row.Key), Is.EqualTo(Records.Select(record => record.Key)), "the store holds the two records"); + Assert.That(rows.Select(row => row.Version).Distinct().Count(), Is.EqualTo(rows.Length), "two different records, two versions"); + Assert.That(rows.All(row => row.Version.Length == 64 && row.Version == row.Version.ToLowerInvariant()), Is.True, "lowercase hex SHA-256"); + Assert.That(Convert.ToHexString(RowsFile(rows)), Is.EqualTo(Convert.ToHexString(VectorFiles.Committed(Vector, "versions.msgpack"))), "versions.msgpack"); + } + + [Test] + public void TheCommittedStoreIsWhatTheReferenceWrites() + { + if (VectorFiles.Writing) return; + Assert.That(Convert.ToHexString(StoreBytes()), Is.EqualTo(Convert.ToHexString(VectorFiles.Committed(Vector, "store.cc"))), "store.cc"); + } + } +} diff --git a/tests/vectors/content-version/README.md b/tests/vectors/content-version/README.md new file mode 100644 index 00000000..cddd7bff --- /dev/null +++ b/tests/vectors/content-version/README.md @@ -0,0 +1,17 @@ +# content-version vectors + +Written only by the C# reference, `tests/GameCult.Caching.Tests/ContentVersionVectorTests.cs`, with +`CULTCACHE_WRITE_VECTORS=1` (never set in CI). Every other run opens the committed `store.cc`, compares what the reference reads +to `versions.msgpack` and compares the store bytes the reference writes to the file; a changed vector is a reviewed commit. +Other runtimes compute the version of each record of `store.cc` and must equal these rows +(`variants:ruling:write-set-content-version-declared-ownership`). + +RULE: the content version of a stored record is the lowercase hex SHA-256 of its persisted encoding, the MessagePack array +`[key, schemaId, storedAt, payload bytes]` exactly as the store writes the record. + +## Files + +- `store.cc`: a single-file store of one type, `tests.cv_item` (one string slot, `[0: Text]`), holding `alpha` = "first" and + `beta` = "second", stored-at pinned to `2026-01-01T00:00:00Z`. +- `versions.msgpack`: an array of `[key, version]`, sorted by key, the version the reference reads for the record + (`GetStored(key).StoredVersion`). diff --git a/tests/vectors/content-version/store.cc b/tests/vectors/content-version/store.cc new file mode 100644 index 0000000000000000000000000000000000000000..3962430aec5fc575a5c2c70acde762d9a6e046cd GIT binary patch literal 766 zcmcgp!Ait15cQ_NVXtbLq?@Ga&676|u6PlVX)<*SZC9EoF5*X21^oc^CWv3KEdGP+ z-xzmgalI;rxy-zo_vTG*Ud>tM4VnSP&JSCP&sldj-EjltLT13YLC&NUi8hI)lq(IQ zXJwu!(@QPWNdY{!QX?2{QHs=P6(*Gl?J9Zp Date: Fri, 13 Nov 2026 19:55:55 +0000 Subject: [PATCH 16/16] Caching: Update reads version and copy from one durable store read (update-copy-from-stored-bytes) (#170) --- docs/start/README.md | 4 +- .../DirectoryMessagePackBackingStore.cs | 107 ++++--- src/GameCult.Caching/CultCache.cs | 86 ++++- .../HeldInstanceUpdateTests.cs | 131 ++++++++ .../OutsideStoreJudgeTests.cs | 13 + .../GameCult.Caching.Tests/ElementIdTests.cs | 22 ++ tests/GameCult.Caching.Tests/UpdateTests.cs | 296 +++++++++++++++++- 7 files changed, 597 insertions(+), 62 deletions(-) create mode 100644 tests/GameCult.Caching.OutsideStoreTests/HeldInstanceUpdateTests.cs diff --git a/docs/start/README.md b/docs/start/README.md index 58874c39..d877469c 100644 --- a/docs/start/README.md +++ b/docs/start/README.md @@ -72,8 +72,8 @@ public static class FirstCall ## C# update with retry -Change a record you may not be the only one changing. `Update` reads the record, hands your function a private -copy, and commits the result only if the record is still the one it read. If another writer got there first, it +Change a record you may not be the only one changing. `Update` reads the record as its store holds it, hands your function a fresh +copy of it, and commits the result only if the record is still the one it read. If another writer got there first, it reads again and runs your function again, up to five times. The source is [`tests/GameCult.Caching.Tests/UpdateCallTests.cs`](../../tests/GameCult.Caching.Tests/UpdateCallTests.cs); the snippet reuses `Item` from the first call. diff --git a/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs b/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs index 6f6a4daf..6e0ce6cd 100644 --- a/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs +++ b/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs @@ -64,48 +64,7 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore var reports = new List(); var loaded = new Dictionary(StringComparer.Ordinal); var foreign = new List(); - for (var attempt = 1; ; attempt++) - { - loaded.Clear(); - reports.Clear(); - foreign.Clear(); - // Only pages named by the manifest read under the lease are loaded; orphaned pages are never loaded. With no - // lock to lease (none created yet, or a store copied without it) a writer may commit mid-load, so an unleased - // load stands only if its manifest is still current; if the manifest moved it reloads, under the lease the - // writer created. A page that fails under an unmoved manifest is corruption, not a race. - using var lease = AcquireCommitLease(wait: true, create: false); - var manifestBytes = ReadManifestBytes(); - var (manifest, _) = ParseManifest(manifestBytes); - Trace($"manifest records={manifest.Records.Length}"); - try - { - if (lease == null) - UnleasedManifestRead?.Invoke(); - LoadRecordPages( - manifest.Records.OrderBy(record => record.Key, StringComparer.Ordinal).ToArray(), - manifest.SchemaCatalog, - loaded, - reports, - foreign); - if (lease != null || !ManifestMoved(manifestBytes)) - { - Trace($"indexed-pages loaded={loaded.Count}"); - break; - } - } - catch (Exception exception) when (lease == null && IsTornGeneration(exception)) - { - // The re-read stays out of the filter: an exception thrown inside a filter is swallowed as false. - if (!ManifestMoved(manifestBytes)) - throw; - if (attempt == UnleasedLoadAttempts) - throw Unsettled(exception); - continue; - } - - if (attempt == UnleasedLoadAttempts) - throw Unsettled(null); - } + LoadManifestPages(null, loaded, reports, foreign, Trace); bool Staged(string key) => _dirtyKeys.ContainsKey(key) || _deletedKeys.ContainsKey(key); var arrived = loaded @@ -136,6 +95,70 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore Trace("publish"); } + // The one load of manifest pages, for the whole store (key null) or one record: under the lease, with the moved-manifest retry. + // It reads only; adopting what it loaded is the caller's. + private void LoadManifestPages( + string? key, + Dictionary loaded, + List reports, + List foreign, + Action trace) + { + for (var attempt = 1; ; attempt++) + { + loaded.Clear(); + reports.Clear(); + foreign.Clear(); + // Only pages named by the manifest read under the lease are loaded; orphaned pages are never loaded. With no + // lock to lease (none created yet, or a store copied without it) a writer may commit mid-load, so an unleased + // load stands only if its manifest is still current; if the manifest moved it reloads, under the lease the + // writer created. A page that fails under an unmoved manifest is corruption, not a race. + using var lease = AcquireCommitLease(wait: true, create: false); + var manifestBytes = ReadManifestBytes(); + var (manifest, _) = ParseManifest(manifestBytes); + trace($"manifest records={manifest.Records.Length}"); + try + { + if (lease == null) + UnleasedManifestRead?.Invoke(); + LoadRecordPages( + manifest.Records.Where(record => key == null || string.Equals(record.Key, key, StringComparison.Ordinal)) + .OrderBy(record => record.Key, StringComparer.Ordinal).ToArray(), + manifest.SchemaCatalog, + loaded, + reports, + foreign); + if (lease != null || !ManifestMoved(manifestBytes)) + { + trace($"indexed-pages loaded={loaded.Count}"); + break; + } + } + catch (Exception exception) when (lease == null && IsTornGeneration(exception)) + { + // The re-read stays out of the filter: an exception thrown inside a filter is swallowed as false. + if (!ManifestMoved(manifestBytes)) + throw; + if (attempt == UnleasedLoadAttempts) + throw Unsettled(exception); + continue; + } + + if (attempt == UnleasedLoadAttempts) + throw Unsettled(null); + } + } + + // The record at a key as its page holds it now, decoded fresh and stamped with VersionOf its manifest payload, the version + // CommitBatch compares. Null when none is there or it is foreign. Touches no entry, hydration, report or foreign list of the + // store, and raises nothing. + internal override CultStoredDocument? ReadDurable(string key) => Held(() => + { + var loaded = new Dictionary(StringComparer.Ordinal); + LoadManifestPages(key, loaded, new List(), new List(), _ => { }); + return loaded.TryGetValue(key, out var stored) ? stored : null; + }); + // Variants wait for the directory store's own cut; until then it refuses one rather than persist a resolved view as a plain page. private static void RefuseVariant(CultStoredDocument entry) { diff --git a/src/GameCult.Caching/CultCache.cs b/src/GameCult.Caching/CultCache.cs index dc0c8b99..303d3737 100644 --- a/src/GameCult.Caching/CultCache.cs +++ b/src/GameCult.Caching/CultCache.cs @@ -2682,34 +2682,66 @@ namespace GameCult.Caching } /// - /// Reads the record at , applies to a private copy of it, and commits the result only - /// if the record is still the one that was read; when another writer got there first it re-reads and runs - /// again, up to times. The write is durable when Update returns. + /// Reads the record at as its home store holds it, applies to a fresh copy of it, and + /// commits the result only if the record is still the one that was read; when another writer got there first it re-reads and runs + /// again, up to times. The write is durable when Update returns. /// /// - /// may run up to times. It gets a private copy (null when the key holds - /// nothing), which it may change and return. It must not write to the cache or have other side effects. Return null to write - /// nothing. A store with staged blind writes refuses Update until it is flushed, as it refuses any conditional commit. The cache - /// needs a codec to copy with. Running out of attempts returns ; every other failure throws. + /// may run up to times. It gets the record as the store that holds it has it now, read from every home + /// of T and its routed subtypes and decoded afresh for this attempt (null when the key holds nothing), which it may change and return. A record held by two stores, or an apply that changes the record's home store, throws. A change made in place to an + /// instance the cache holds is neither seen nor written. It must not write to the cache or have other side effects. Return null to write + /// nothing. A store with staged blind writes refuses Update until it is flushed, as it refuses any conditional commit. A cache with no + /// store attached copies its held instance and needs a codec; a store kind outside the library cannot serve Update (). Running out of attempts returns ; every other failure throws. /// public CultUpdateResult Update(CultRecordKey key, Func apply, int maxAttempts = 5) where T : class { if (apply == null) throw new ArgumentNullException(nameof(apply)); if (maxAttempts < 1) throw new ArgumentOutOfRangeException(nameof(maxAttempts), maxAttempts, "At least one attempt is required."); - var codec = _codec ?? throw new InvalidOperationException("This cache has no codec; create it with CultCacheMessagePack.Create."); for (var attempt = 1; ; attempt++) { - var (version, copy) = Held(() => + var (version, copy, source) = Held(() => { - if (!_entries.TryGetValue(key.Value, out var held)) - return ((string?)null, (T?)null); - if (held.Variant != null || held.Document is not T) + if (_stores.Count == 0) + { + // No store: the held entry is the record. + _entries.TryGetValue(key.Value, out var held); + var codec = _codec ?? throw new InvalidOperationException("This cache has no codec; create it with CultCacheMessagePack.Create."); + if (held == null) + return ((string?)null, (T?)null, (CacheBackingStore?)null); + if (held.Variant != null || held.Document is not T) + throw new InvalidOperationException($"Record {key.Value} is a variant or not a {typeof(T).Name}; Update cannot copy it."); + return (held.StoredVersion, (T?)codec.Clone(held.Document, held.Descriptor.DocumentType), null); + } + + // The store with the key is among the homes of T and its routed subtypes; the cache's own view of the key may be stale, so it is not asked. + var homes = CandidateHomes(typeof(T)); + if (homes.Length == 0) + throw new InvalidOperationException("A conditional batch names its home store through a record it upserts or removes."); + CacheBackingStore? holder = null; + CultStoredDocument? durable = null; + foreach (var candidate in homes) + { + if (candidate.ReadDurable(key.Value) is not { } found) + continue; + if (holder != null) + throw new InvalidOperationException($"Record {key.Value} is held by two stores, {holder} and {candidate}; Update cannot choose."); + holder = candidate; + durable = found; + } + + if (durable == null) + return ((string?)null, (T?)null, (CacheBackingStore?)null); + if (durable.Variant != null || durable.Document is not T typed) throw new InvalidOperationException($"Record {key.Value} is a variant or not a {typeof(T).Name}; Update cannot copy it."); - return (held.StoredVersion, (T?)codec.Clone(held.Document, held.Descriptor.DocumentType)); + CultElementIds.Plan(durable.Descriptor.DocumentType, durable.Document, key.Value, deterministic: true).Apply(); + return (durable.StoredVersion, typed, holder); }); var next = apply(copy); if (next == null) return new CultUpdateResult(CultUpdateOutcome.Declined, Get(key)); + if (source != null && Held(() => Home(next.GetType())) is var landing && landing != source) + throw new InvalidOperationException( + $"Update of {key.Value} would move the record from {source} to {landing?.ToString() ?? "memory"}; remove it and write it at its new home."); CultCommitOutcome outcome; try { @@ -2728,9 +2760,11 @@ namespace GameCult.Caching return new CultUpdateResult(CultUpdateOutcome.Updated, next); if (outcome != CultCommitOutcome.Mismatch) throw new InvalidOperationException($"Update of {key.Value} was refused: {outcome}."); - Held(() => { Home(next.GetType())?.PullAll(); return true; }); if (attempt >= maxAttempts) + { + Held(() => { Home(next.GetType())?.PullAll(); return true; }); return new CultUpdateResult(CultUpdateOutcome.LostRace, Get(key)); + } } } @@ -3751,6 +3785,18 @@ namespace GameCult.Caching return changes; } + // Every store a record of this type, or of a registered subtype of it, can live in. + private CacheBackingStore[] CandidateHomes(Type type) + { + var homes = new HashSet(); + if (Home(type) is { } own) + homes.Add(own); + foreach (var descriptor in _registry.AllDescriptors) + if (type.IsAssignableFrom(descriptor.DocumentType) && Home(descriptor.DocumentType) is { } routed) + homes.Add(routed); + return _stores.Select(entry => entry.Store).Where(homes.Contains).ToArray(); + } + // The most derived routed home type assignable from the document type, else the untyped store. private CacheBackingStore? Home(Type documentType) { @@ -4027,6 +4073,15 @@ namespace GameCult.Caching internal void UseRegistry(CultDocumentRegistry? registry) => _registry = registry; public abstract void PullAll(); + + /// + /// The record at as this store holds it now, decoded as a load decodes it by this call (a new instance nobody + /// else holds) and stamped with the version this store's commit compares. Null when the store holds nothing there or holds it foreign. + /// It changes none of the store's entries, staged writes or reports and raises nothing. Internal because only this library can stamp a version. + /// + internal virtual CultStoredDocument? ReadDurable(string key) => + throw new NotSupportedException($"{this} cannot read one record, which Update needs."); + public abstract void Push(CultStoredDocument entry); public abstract void Delete(CultStoredDocument entry); public void PushAll() => PushAll(soft: false); @@ -5173,6 +5228,9 @@ namespace GameCult.Caching return ToStoredDocument(record, durable.Version(key), durable.Snapshot.SchemaCatalog, DeserializeMarked, out _); } + // Update's read: the durable record at the key, decoded afresh, stamped with the version CommitBatch compares. + internal override CultStoredDocument? ReadDurable(string key) => Held(() => DurableAt(ReadSnapshot(), key)); + // The file now holds every record written, under the id and at the storedAt the write gave it, and the version of those bytes: // the store's entries and the cache's say so. A copied record is on disk as the file had it, so nothing learns anything new about it: in particular the // ids it minted at load are still in memory only. diff --git a/tests/GameCult.Caching.OutsideStoreTests/HeldInstanceUpdateTests.cs b/tests/GameCult.Caching.OutsideStoreTests/HeldInstanceUpdateTests.cs new file mode 100644 index 00000000..6b02b72f --- /dev/null +++ b/tests/GameCult.Caching.OutsideStoreTests/HeldInstanceUpdateTests.cs @@ -0,0 +1,131 @@ +using System; +using System.Collections.Generic; +using System.IO; +using GameCult.Caching.MessagePack; +using MessagePack; +using NUnit.Framework; + +namespace GameCult.Caching.OutsideStoreTests +{ + // variants:follow_up:update-copy-from-stored-bytes: Update takes its version and its copy from one durable read of the home + // store, never from the held instance. Consumer side: public API only, so a change made in place to an instance the cache + // hands out can never be committed as if it had been read. + public class HeldInstanceUpdateTests + { + [CultDocument("tests.held_counter", "tests.held_counter.v1")] + [MessagePackObject] + public sealed class HeldCounter + { + [Key(0)] [CultName] public string Name { get; set; } = string.Empty; + [Key(1)] public int Value { get; set; } + } + + private static readonly CultDocumentRegistry Registry = CultDocumentRegistry.ForTypes(new[] { typeof(HeldCounter) }); + private static readonly CultRecordKey Key = new("counter"); + + private string _directory = string.Empty; + + [SetUp] + public void CreateDirectory() + { + _directory = Path.Combine(Path.GetTempPath(), $"cultlib-held-update-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_directory); + } + + [TearDown] + public void DeleteDirectory() + { + if (Directory.Exists(_directory)) + Directory.Delete(_directory, recursive: true); + } + + private static CultCache Open(string path, bool directory) + { + var cache = new CultCache(Registry, CultCacheMessagePack.CreateCodec(Registry)); + cache.AddBackingStore(directory ? new DirectoryMessagePackBackingStore(path) : new SingleFileMessagePackBackingStore(path)); + cache.PullAllBackingStoresAsync().GetAwaiter().GetResult(); + return cache; + } + + private string Seed(bool directory, int value) + { + var path = Path.Combine(_directory, "held.cc"); + using var cache = Open(path, directory); + Assert.That(cache.Commit(batch => batch.Upsert(new HeldCounter { Name = "counter", Value = value }, new CultRecordHandle(Key))), Is.True); + return path; + } + + private static int StoredValue(string path, bool directory) + { + using var reader = Open(path, directory); + return reader.Get(Key)!.Value; + } + + private static HeldCounter Plus(HeldCounter? counter, int by) + { + counter!.Value += by; + return counter; + } + + [TestCase(false)] + [TestCase(true)] + public void InPlaceChangeToHeldInstanceIsNeitherAppliedNorPersisted(bool directory) + { + var path = Seed(directory, 1); + using var cache = Open(path, directory); + cache.Get(Key)!.Value = 999; + var seen = new List(); + + var result = cache.Update(Key, counter => { seen.Add(counter!.Value); return Plus(counter, 1); }); + + Assert.That(result.Outcome, Is.EqualTo(CultUpdateOutcome.Updated)); + Assert.That(seen, Is.EqualTo(new[] { 1 }), "apply saw the in-place change"); + Assert.That(result.Document!.Value, Is.EqualTo(2)); + Assert.That(StoredValue(path, directory), Is.EqualTo(2), "the in-place 999 was persisted"); + } + + [TestCase(false)] + [TestCase(true)] + public void InPlaceChangeToAPreviousResultIsNotCommitted(bool directory) + { + var path = Seed(directory, 1); + using var cache = Open(path, directory); + var first = cache.Update(Key, counter => Plus(counter, 1)); + Assert.That(first.Outcome, Is.EqualTo(CultUpdateOutcome.Updated)); + first.Document!.Value = 999; + var seen = new List(); + + var second = cache.Update(Key, counter => { seen.Add(counter!.Value); return Plus(counter, 1); }); + + Assert.That(second.Outcome, Is.EqualTo(CultUpdateOutcome.Updated)); + Assert.That(seen, Is.EqualTo(new[] { 2 }), "apply ran more than once or saw the in-place change"); + Assert.That(StoredValue(path, directory), Is.EqualTo(3)); + } + + [TestCase(false)] + [TestCase(true)] + public void DeclineAfterInPlaceChangeWritesNothing(bool directory) + { + var path = Seed(directory, 1); + using var cache = Open(path, directory); + cache.Get(Key)!.Value = 999; + var before = Snapshot(path); + + Assert.That(cache.Update(Key, _ => null).Outcome, Is.EqualTo(CultUpdateOutcome.Declined)); + + Assert.That(Snapshot(path), Is.EqualTo(before)); + Assert.That(StoredValue(path, directory), Is.EqualTo(1)); + } + + // Every file of the store: the single file, or the manifest and its pages. + private static Dictionary Snapshot(string path) + { + var files = new Dictionary { [path] = File.ReadAllBytes(path) }; + var pages = path + ".records"; + if (Directory.Exists(pages)) + foreach (var file in Directory.GetFiles(pages, "*", SearchOption.AllDirectories)) + files[file] = File.ReadAllBytes(file); + return files; + } + } +} diff --git a/tests/GameCult.Caching.OutsideStoreTests/OutsideStoreJudgeTests.cs b/tests/GameCult.Caching.OutsideStoreTests/OutsideStoreJudgeTests.cs index bd2fe047..86949fa0 100644 --- a/tests/GameCult.Caching.OutsideStoreTests/OutsideStoreJudgeTests.cs +++ b/tests/GameCult.Caching.OutsideStoreTests/OutsideStoreJudgeTests.cs @@ -364,5 +364,18 @@ namespace GameCult.Caching.OutsideStoreTests Assert.That(() => cache.Commit(batch => batch.Remove(K)), Throws.InstanceOf(), "removal"); Assert.That(disk.Records["k"], Is.EqualTo(before)); } + + // Only the library can stamp a version on a decoded record, so a store kind outside it cannot read one honestly: Update refuses before apply. + [Test] + public void UpdateOverAnOutsideStoreIsNotSupported() + { + var disk = new Disk(); + PutOnDisk(disk, AB, "k", new OutA { Name = "k", Value = 1 }); + using var cache = Open(disk, AB); + var calls = 0; + + Assert.That(() => cache.Update(K, a => { calls++; return a; }), Throws.TypeOf()); + Assert.That(calls, Is.EqualTo(0), "apply ran for a store that cannot serve Update"); + } } } diff --git a/tests/GameCult.Caching.Tests/ElementIdTests.cs b/tests/GameCult.Caching.Tests/ElementIdTests.cs index a6c68c94..d40ffe25 100644 --- a/tests/GameCult.Caching.Tests/ElementIdTests.cs +++ b/tests/GameCult.Caching.Tests/ElementIdTests.cs @@ -224,6 +224,28 @@ namespace GameCult.Caching.Tests Assert.That(AllIds(reread), Is.EqualTo(first), "the ids minted at load are the ids written, and stay through repeated writes"); } + // Update reads the store's bytes, which hold no ids; it mints the ids the load minted, so the write keeps the ids the cache showed. + [Test] + public void UpdateOfAPreIdRecordPersistsTheIdsTheLoadMinted() + { + var path = PathOf("old.cc"); + WritePreIdStore(path, null, ("a", "a", 2)); + var key = new CultRecordKey("a"); + + string[] shown; + using (var cache = Open(path)) + { + shown = AllIds(cache.Get(key)!); + Assert.That(shown, Has.All.Not.Empty); + var result = cache.Update(key, deck => { deck!.Name = "a2"; return deck; }); + Assert.That(result.Outcome, Is.EqualTo(CultUpdateOutcome.Updated)); + } + + var written = MessagePackSerializer.Deserialize(DiskRecord(path, "a").Payload); + Assert.That(written.Name, Is.EqualTo("a2")); + Assert.That(AllIds(written), Is.EqualTo(shown), "the write replaced the ids the cache showed with random ones"); + } + [Test] public void MintElementIdsRewritesEveryPreIdRecordAndNothingElse() { diff --git a/tests/GameCult.Caching.Tests/UpdateTests.cs b/tests/GameCult.Caching.Tests/UpdateTests.cs index 0dd96bad..63462ba6 100644 --- a/tests/GameCult.Caching.Tests/UpdateTests.cs +++ b/tests/GameCult.Caching.Tests/UpdateTests.cs @@ -19,7 +19,7 @@ namespace GameCult.Caching.Tests { private static readonly CultRecordKey Key = new("counter"); private static readonly CultDocumentRegistry Registry = - CultDocumentRegistry.ForTypes(new[] { typeof(UpdateCounter), typeof(UpdateTally), typeof(UpdateGear) }); + CultDocumentRegistry.ForTypes(new[] { typeof(UpdateCounter), typeof(UpdateTally), typeof(UpdateGear), typeof(UpdateBase), typeof(UpdateDerived) }); private string _directory = string.Empty; @@ -78,16 +78,22 @@ namespace GameCult.Caching.Tests using var a = Open(path, directory); using var b = Open(path, directory); var applied = 0; + // Each round starts both writers together: an attempt reads fresh from the store, so only overlapping read-commit windows lose. + var round = new Barrier(2); var racers = new[] { a, b }.Select(cache => Task.Run(() => { var results = new List(); for (var i = 0; i < n; i++) + { + round.SignalAndWait(); results.Add(cache.Update(Key, counter => { Interlocked.Increment(ref applied); return Plus(counter, 1); }, maxAttempts: 200).Outcome); + } + return results; })).ToArray(); Assert.That(Task.WaitAll(racers, TimeSpan.FromMinutes(2)), Is.True, "the racers did not finish"); @@ -172,6 +178,193 @@ namespace GameCult.Caching.Tests Assert.That(StoredValue(path, false), Is.EqualTo(1005)); } + // No publication in the middle of an Update: a rival's write that cost this cache an attempt is never published on its own, + // only the value this Update commits (a pull after every lost attempt would publish both). + [TestCase(false)] + [TestCase(true)] + public void LostAttemptPublishesNothingMidUpdate(bool directory) + { + var path = PathOf("midupdate.cc"); + Seed(path, directory, 1); + using var cache = Open(path, directory); + using var rival = Open(path, directory); + var published = new List(); + using var subscription = cache.Watch().Subscribe(change => published.Add(change.Document!.Value)); + var calls = 0; + + var result = cache.Update(Key, counter => + { + if (++calls == 1) + Rival(rival, 5); + return Plus(counter, 1); + }); + + Assert.That(result.Outcome, Is.EqualTo(CultUpdateOutcome.Updated)); + Assert.That(calls, Is.EqualTo(2)); + Assert.That(published, Is.EqualTo(new[] { 6 }), "the rival's 5 was published in the middle of the Update"); + } + + // The directory store reads only the key's own page, so a damaged page of another record does not stop an Update. + [Test] + public void DirectoryUpdateReadsOnlyItsOwnPage() + { + var path = PathOf("pages.cc"); + var other = new CultRecordKey("other"); + using (var seed = Open(path, true)) + Assert.That(seed.Commit(batch => batch.Upsert(new UpdateCounter { Name = "other", Value = 9 }, new CultRecordHandle(other))), Is.True); + var otherPages = Directory.GetFiles(path + ".records", "*.msgpack"); + Assert.That(otherPages, Has.Length.EqualTo(1)); + Seed(path, true, 1); + using var cache = Open(path, true); + File.WriteAllBytes(otherPages[0], new byte[] { 1, 2, 3, 4, 5 }); + + var result = cache.Update(Key, counter => Plus(counter, 1)); + + Assert.That(result.Outcome, Is.EqualTo(CultUpdateOutcome.Updated)); + Assert.That(result.Document!.Value, Is.EqualTo(2)); + } + + // The durable read is not a load: it leaves the store's foreign list, its migration reports and what the cache publishes alone. + [TestCase(false)] + [TestCase(true)] + public void ReadDurableLeavesTheStoreViewAlone(bool directory) + { + var path = PathOf("view.cc"); + Seed(path, directory, 1); + using (var writer = Open(path, directory)) + Assert.That(writer.Commit(batch => batch.Upsert(new UpdateGear { Name = "gear", Power = 3 }, new CultRecordHandle(new CultRecordKey("gear")))), Is.True); + var narrow = CultDocumentRegistry.ForTypes(new[] { typeof(UpdateCounter) }); + using var cache = new CultCache(narrow); + cache.AddBackingStore(directory ? new DirectoryMessagePackBackingStore(path) : new SingleFileMessagePackBackingStore(path)); + using var rival = Open(path, directory); + var store = cache.BackingStores.Single(); + var foreign = store.ForeignRecords; + var reports = store.LastSchemaMigrationReports; + Assert.That(foreign.Select(record => record.Key), Is.EqualTo(new[] { "gear" }), "the fixture holds a foreign record"); + Assert.That(reports, Is.Not.Empty, "the fixture holds a migration report"); + var foreignKeys = foreign.Select(record => (record.Key, record.SchemaId, record.StoredAt)).ToArray(); + var reportKinds = reports.Select(report => (report.PersistedSchemaName, report.Kind)).ToArray(); + var changes = 0; + using var subscription = cache.Watch().Subscribe(_ => changes++); + var calls = 0; + + var result = cache.Update(Key, counter => + { + if (++calls == 1) + Rival(rival, 5); + return Plus(counter, 1); + }); + + Assert.That(result.Outcome, Is.EqualTo(CultUpdateOutcome.Updated)); + Assert.That(calls, Is.EqualTo(2), "the Update reread the key"); + Assert.That(store.ForeignRecords, Is.SameAs(foreign)); + Assert.That(store.LastSchemaMigrationReports, Is.SameAs(reports)); + Assert.That(store.ForeignRecords.Select(record => (record.Key, record.SchemaId, record.StoredAt)), Is.EqualTo(foreignKeys)); + Assert.That(store.LastSchemaMigrationReports.Select(report => (report.PersistedSchemaName, report.Kind)), Is.EqualTo(reportKinds)); + Assert.That(changes, Is.EqualTo(1), "only the committed write was published"); + } + + // Routing: store A is home to UpdateBase, store B to UpdateDerived : UpdateBase. Land judges a derived record at B. + private CultCache OpenRouted() + { + var cache = new CultCache(Registry); + cache.AddBackingStore(new SingleFileMessagePackBackingStore(PathOf("a.cc")), typeof(UpdateBase)); + cache.AddBackingStore(new SingleFileMessagePackBackingStore(PathOf("b.cc")), typeof(UpdateDerived)); + return cache; + } + + private CultCache OpenOneRouted(string file, Type home) + { + var cache = new CultCache(Registry); + cache.AddBackingStore(new SingleFileMessagePackBackingStore(PathOf(file)), home); + return cache; + } + + private byte[]? BytesOf(string file) => File.Exists(PathOf(file)) ? File.ReadAllBytes(PathOf(file)) : null; + + private static readonly CultRecordHandle BaseHandle = new(Key); + + private static void RivalDerived(CultCache cache, int value) => + Assert.That(cache.Commit(batch => batch.Upsert(new UpdateDerived { Name = "counter", Value = value }, BaseHandle)), Is.True); + + [Test] + public void RoutedSubtypeStaleCacheConverges() + { + using var cache = OpenRouted(); + using var rival = OpenRouted(); + RivalDerived(rival, 1); + var seen = new List(); + + var result = cache.Update(Key, document => + { + seen.Add((document as UpdateDerived)?.Value); + var derived = (UpdateDerived)document!; + derived.Value += 1; + return derived; + }); + + Assert.That(seen, Is.EqualTo(new int?[] { 1 }), "apply did not see the rival's record in its home store, once"); + Assert.That(result.Outcome, Is.EqualTo(CultUpdateOutcome.Updated)); + using var reader = OpenRouted(); + Assert.That(((UpdateDerived)reader.Get(Key)!).Value, Is.EqualTo(2)); + } + + [Test] + public void RoutedSubtypeCreateLandsInItsHome() + { + using (var cache = OpenRouted()) + { + var calls = 0; + var result = cache.Update(Key, document => + { + calls++; + Assert.That(document, Is.Null); + return new UpdateDerived { Name = "counter", Value = 1 }; + }); + Assert.That(calls, Is.EqualTo(1)); + Assert.That(result.Outcome, Is.EqualTo(CultUpdateOutcome.Updated)); + } + + using (var inB = OpenOneRouted("b.cc", typeof(UpdateDerived))) + Assert.That(inB.Get(Key), Is.InstanceOf()); + using (var inA = OpenOneRouted("a.cc", typeof(UpdateBase))) + Assert.That(inA.Get(Key), Is.Null, "the record landed in the wrong store"); + } + + [Test] + public void MovingTheRecordToAnotherHomeRefuses() + { + using (var rival = OpenRouted()) + RivalDerived(rival, 1); + var before = (A: BytesOf("a.cc"), B: BytesOf("b.cc")); + using var cache = OpenRouted(); + var calls = 0; + + var refusal = Assert.Throws(() => + cache.Update(Key, _ => { calls++; return new UpdateBase { Name = "counter" }; }))!; + + Assert.That(calls, Is.EqualTo(1), "a refusal was retried"); + Assert.That(refusal.Message, Does.Contain("a.cc").And.Contain("b.cc").And.Contain("move")); + Assert.That(BytesOf("a.cc"), Is.EqualTo(before.A)); + Assert.That(BytesOf("b.cc"), Is.EqualTo(before.B)); + } + + [Test] + public void ARecordHeldByTwoStoresRefusesBeforeApply() + { + using var cache = OpenRouted(); + using (var inA = OpenOneRouted("a.cc", typeof(UpdateBase))) + Assert.That(inA.Commit(batch => batch.Upsert(new UpdateBase { Name = "counter" }, BaseHandle)), Is.True); + using (var inB = OpenOneRouted("b.cc", typeof(UpdateDerived))) + Assert.That(inB.Commit(batch => batch.Upsert(new UpdateDerived { Name = "counter", Value = 1 }, BaseHandle)), Is.True); + var calls = 0; + + var refusal = Assert.Throws(() => cache.Update(Key, document => { calls++; return document; }))!; + + Assert.That(calls, Is.EqualTo(0)); + Assert.That(refusal.Message, Does.Contain("held by two stores")); + } + [Test] public void NullFromApplyDeclines() { @@ -219,8 +412,19 @@ namespace GameCult.Caching.Tests var calls = 0; Func apply = counter => { calls++; return Plus(counter, 1); }; - using (var codecLess = Open(path, false, codec: false)) - Assert.That(() => codecLess.Update(Key, apply), Throws.InvalidOperationException); + // A cache with no store copies its held instance, so it needs a codec. + using (var storeLess = new CultCache(Registry)) + { + storeLess.Commit(batch => batch.Upsert(new UpdateCounter { Name = "counter", Value = 1 }, new CultRecordHandle(Key))); + Assert.That(() => storeLess.Update(Key, apply), Throws.InvalidOperationException.With.Message.Contains("no codec")); + } + + // Stores are attached, but none is home to the type: refused before apply. + using (var noHome = new CultCache(Registry, CultCacheMessagePack.CreateCodec(Registry))) + { + noHome.AddBackingStore(new SingleFileMessagePackBackingStore(PathOf("nohome.cc")), typeof(UpdateTally)); + Assert.That(() => noHome.Update(Key, apply), Throws.InvalidOperationException.With.Message.Contains("names its home store")); + } using (var cache = Open(path, false)) { Assert.That(() => cache.Update(Key, apply, maxAttempts: 0), Throws.TypeOf()); @@ -241,7 +445,7 @@ namespace GameCult.Caching.Tests before = File.ReadAllBytes(path); var gearCalls = 0; using (var cache = Open(path, false)) - Assert.That(() => cache.Update(new CultRecordKey("gear-big"), gear => { gearCalls++; return gear; }), Throws.InvalidOperationException); + Assert.That(() => cache.Update(new CultRecordKey("gear-big"), gear => { gearCalls++; return gear; }), Throws.InvalidOperationException.With.Message.Contains("Update cannot copy it")); Assert.That(gearCalls, Is.EqualTo(0), "apply ran for a variant"); Assert.That(calls, Is.EqualTo(0), "apply ran for a refused call"); Assert.That(File.ReadAllBytes(path), Is.EqualTo(before)); @@ -479,6 +683,73 @@ namespace GameCult.Caching.Tests Assert.That(cache.Get(Key)!.Value, Is.EqualTo(200)); } + [TestCase(false)] + [TestCase(true)] + public void StaleCacheFirstAttemptSeesTheStore(bool directory) + { + var path = PathOf("stale.cc"); + Seed(path, directory, 1); + using var a = Open(path, directory); + using var b = Open(path, directory); + Assert.That(b.Get(Key)!.Value, Is.EqualTo(1)); + Rival(a, 7); + var seen = new List(); + + var result = b.Update(Key, counter => { seen.Add(counter!.Value); return Plus(counter, 1); }); + + Assert.That(result.Outcome, Is.EqualTo(CultUpdateOutcome.Updated)); + Assert.That(seen, Is.EqualTo(new[] { 7 }), "the first attempt read the cache's stale copy"); + Assert.That(StoredValue(path, directory), Is.EqualTo(8)); + } + + // The durable read is not a load: it leaves the store's view of what the cache holds alone, so a later pull still delivers the rival's write. + [TestCase(false)] + [TestCase(true)] + public void ADeclinedUpdateDoesNotHideTheRivalsWriteFromALaterPull(bool directory) + { + var path = PathOf("hidden.cc"); + Seed(path, directory, 1); + using var a = Open(path, directory); + using var b = Open(path, directory); + Assert.That(b.Get(Key)!.Value, Is.EqualTo(1)); + Rival(a, 7); + + Assert.That(b.Update(Key, _ => null).Outcome, Is.EqualTo(CultUpdateOutcome.Declined)); + Assert.That(b.Get(Key)!.Value, Is.EqualTo(1), "a declined Update changed what the cache holds"); + b.PullAllBackingStoresAsync().GetAwaiter().GetResult(); + + Assert.That(b.Get(Key)!.Value, Is.EqualTo(7)); + } + + [Test] + public void CodecLessStoreBackedCacheUpdates() + { + var path = PathOf("codecless.cc"); + Seed(path, false, 1); + var calls = 0; + using (var cache = Open(path, false, codec: false)) + Assert.That(cache.Update(Key, counter => { calls++; return Plus(counter, 1); }).Outcome, Is.EqualTo(CultUpdateOutcome.Updated)); + + Assert.That(calls, Is.EqualTo(1)); + Assert.That(StoredValue(path, false), Is.EqualTo(2)); + } + + [TestCase(false)] + [TestCase(true)] + public void AStoreHoldingNothingAtTheKeyIsAbsentEvenIfTheCacheHoldsIt(bool directory) + { + var path = PathOf("gone.cc"); + Seed(path, directory, 5); + using var a = Open(path, directory); + using var b = Open(path, directory); + Assert.That(b.Get(Key), Is.Not.Null); + Assert.That(a.Commit(batch => batch.Remove(Key)), Is.True); + var seen = new List(); + + Assert.That(b.Update(Key, counter => { seen.Add(counter); return null; }).Outcome, Is.EqualTo(CultUpdateOutcome.Declined)); + Assert.That(seen, Is.EqualTo(new UpdateCounter?[] { null })); + } + [CultDocument("tests.update_counter", "tests.update_counter.v1")] [MessagePackObject(AllowPrivate = true)] internal sealed class UpdateCounter @@ -511,5 +782,22 @@ namespace GameCult.Caching.Tests [Key(1)] public int Power; } + + [CultDocument("tests.update_base", "tests.update_base.v1")] + [MessagePackObject(AllowPrivate = true)] + internal class UpdateBase + { + [Key(0)] + [CultName] + public string Name = string.Empty; + } + + [CultDocument("tests.update_derived", "tests.update_derived.v1")] + [MessagePackObject(AllowPrivate = true)] + internal sealed class UpdateDerived : UpdateBase + { + [Key(1)] + public int Value; + } } }