cultcache-ts: a pushAll through a dangling link is refused like push, not replaced (#147)
This commit is contained in:
parent
1045914701
commit
25f21fcc53
6 changed files with 18 additions and 27 deletions
|
|
@ -1620,7 +1620,7 @@ The one door per runtime, and who walks through it:
|
|||
- **Shared paths.** Open, flush, commit, `ReadDurable`, `ReplaceDurable`. Rust `pull_all` RS:1243 also checks `is_absent` on the store and on the lock path before it takes the shared lock; that skips lock creation only, and the verdict stays with the reader.
|
||||
- **Cut line.** `merge-r1` h2 (C#) and h4/h6 (TS `readStoreBytes`); `mesh-single-file-reader` h1.
|
||||
- **Pinned by.** C#: `StoreReadabilityTests` `ADanglingFileLinkIsAnIoErrorOnOpenNotAnEmptyStore` :208, `ADanglingDirectoryLinkIsAnIoErrorOnOpenNotAnEmptyStore` :185, and the commit variants :196, :219. Mesh: `ADanglingLinkIsAnIoErrorOnTypedWriteRawWriteAndReadNeverAMissingFile` :107 and `OnlyNothingAtThePathIsAMissingFile` :134 (`CultMeshSingleFileStoreTests.cs`). Rust: `a_dangling_symlink_at_the_store_path_is_an_error_and_nothing_is_written` RS:5832. TS: "readStoreBytes reports nothing at the path only when the lstat finds nothing too".
|
||||
- **Known gaps.** The writer side still tolerates a dangling link: Rust `write_all_unlocked` RS:992-996 and TS `pushAll` TS:94-100 replace it on a whole-store write (`variants:follow_up:writer-dangling-link-tolerance`, owner `c2a-write-set`). What a replace does to a live link is `variants:follow_up:store-replace-cut` item 2. Windows junction behavior was proven at `42f05f66` and `b1ff2dcb`, not at the final head.
|
||||
- **Known gaps.** The writer no longer tolerates a dangling link: the reader's refusal stands for every write, whole-store `pushAll` included (Rust `a_push_all_through_a_dangling_link_is_refused_like_push_and_writes_nothing`, TS `a pushAll through a dangling link is refused like push`; `variants:follow_up:writer-dangling-link-tolerance`). What a replace does to a live link is `variants:follow_up:store-replace-cut` item 2. Windows junction behavior was proven at `42f05f66` and `b1ff2dcb`, not at the final head.
|
||||
|
||||
### C. Zero-byte file
|
||||
|
||||
|
|
@ -1672,7 +1672,7 @@ The one door per runtime, and who walks through it:
|
|||
- **Shared paths.** TS `pushAll` and Rust `push_all` only.
|
||||
- **Cut line.** `merge-r1` h4 (TS) and h6 (Rust).
|
||||
- **Pinned by.** The manifest walks run a soft flush over every vector in TS (the `softPushAll` operation, TS test :2336) and Rust (`soft_push_all`, RS:5149), each with an absent and a dangling-link case; `a_soft_push_all_writes_where_nothing_is_and_refuses_a_dangling_link` RS:6019.
|
||||
- **Known gaps.** C# and Python have no soft decision, and the C# Mesh flag is dead; no follow-up is registered (h6 `undone` names it). `writer-dangling-link-tolerance` applies to the non-soft branch.
|
||||
- **Known gaps.** C# and Python have no soft decision, and the C# Mesh flag is dead; no follow-up is registered (h6 `undone` names it). `writer-dangling-link-tolerance` is settled: the non-soft branch refuses a dangling link too.
|
||||
|
||||
### What no cut owns yet
|
||||
|
||||
|
|
|
|||
|
|
@ -107,8 +107,8 @@ Authority map (also in the spec):
|
|||
- `variants:follow_up:public-store-writes-behind-cache`: **leave**. It asks who may stage behind an attached cache (API
|
||||
shape); this cut changes what any staging is judged by. No collision: direct `Push`/`CommitBatch` reach the same on-disk
|
||||
judge, so they get the per-key rule for free. Hands must not give direct staging a different predicate.
|
||||
- `variants:follow_up:writer-dangling-link-tolerance`: **leave**. Rust/TS whole-store `push_all` reading a dangling link is
|
||||
a reader/writer tolerance question, not ownership; this cut touches no leaf.
|
||||
- `variants:follow_up:writer-dangling-link-tolerance`: **settled** (the reader owns the verdict; every runtime's whole-store
|
||||
`push_all` refuses a dangling link, as `push` does).
|
||||
|
||||
## Deferred doc findings this cut fixes
|
||||
|
||||
|
|
|
|||
|
|
@ -4120,7 +4120,7 @@ struct DiskStore {
|
|||
}
|
||||
|
||||
impl DiskStore {
|
||||
/// A file that is gone, or a dangling link a whole-file replace is about to replace: an empty v1 store.
|
||||
/// Nothing at the path: an empty v1 store. A dangling link is something at the path and is refused, not read as this.
|
||||
fn absent() -> Self {
|
||||
Self { absent: true, format: STORE_FORMAT_V1, envelopes: Vec::new(), catalog: Vec::new() }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,8 @@ All notable changes to `@gamecult/cultcache-ts` are recorded here, following
|
|||
|
||||
### Breaking
|
||||
|
||||
- `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 and the dangling-link case are 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.
|
||||
- `SingleFileMessagePackBackingStore.pushAll` through a dangling symbolic link at the store path is refused with `ENOENT`, as `push` and `pullAll` are, and writes nothing; it used to replace the link with a new store. The reader owns the verdict on what is at the path.
|
||||
- `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.
|
||||
- `indexes` are unique, as C# `[CultIndex]`: a write or a load that leaves two records of a type holding one non-blank
|
||||
value under one alias is refused naming both keys and the alias, and writes nothing. Index text is the C# canonical
|
||||
|
|
|
|||
|
|
@ -87,26 +87,15 @@ export class SingleFileMessagePackBackingStore implements CacheBackingStore {
|
|||
// another writer added. The store is read by the same reader as `pullAll`, so one it would refuse (not exactly one
|
||||
// store, a variant, a body it cannot decode) is refused and left as it is. A soft flush asks that reader too: nothing
|
||||
// at the path is written, a store is left alone.
|
||||
let held: CultCacheEnvelope[] = [];
|
||||
try {
|
||||
const disk = this.#readDisk();
|
||||
if (options.soft && !disk.absent) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.#format = disk.format;
|
||||
held = disk.envelopes;
|
||||
} catch (error) {
|
||||
// Only a dangling link reaches here with ENOENT: it holds no store, so a flush has nothing to merge with and no
|
||||
// header to keep, and replaces the link (R3 pins this). A soft flush does not replace what it cannot read, and every
|
||||
// merging writer has already refused the link when it read the current entries.
|
||||
if (options.soft || (error as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||
throw error;
|
||||
}
|
||||
|
||||
this.#format = STORE_FORMAT_VERSION;
|
||||
// The reader owns the verdict on what is at the path: a dangling link is refused here as it is for open and push.
|
||||
const disk = this.#readDisk();
|
||||
if (options.soft && !disk.absent) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.#format = disk.format;
|
||||
const held = disk.envelopes;
|
||||
|
||||
// A key is held once: of same-type entries sharing a key in one batch, the last is kept.
|
||||
const supplied = [...new Map(entries.map((entry) => [entry.key, entry])).values()];
|
||||
requireOneTypePerKey(this.filePath, entries, held);
|
||||
|
|
|
|||
|
|
@ -2525,14 +2525,15 @@ test("R3 decides resolve-or-refuse: a push through a live link replaces the link
|
|||
assert.deepEqual(await readFile(target), before);
|
||||
});
|
||||
|
||||
test("R3 decides resolve-or-refuse: a pushAll through a dangling link replaces the link", { skip: process.platform === "win32" }, async () => {
|
||||
test("a pushAll through a dangling link is refused like push, and the link and its volume are left as they were", { skip: process.platform === "win32" }, async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "cultcache-dangling-push-all-"));
|
||||
const volume = join(dir, "volume");
|
||||
await mkdir(volume);
|
||||
const link = join(dir, "store.cc");
|
||||
await symlink(join(volume, "store.cc"), link);
|
||||
await new SingleFileMessagePackBackingStore(link).pushAll([{ key: "a", type: "t", payload: documentPayloadFromBytes(Uint8Array.of(0x90)), storedAt: "2026-09-30T00:00:00Z" }]);
|
||||
assert.ok((await lstat(link)).isFile());
|
||||
const envelope = { key: "a", type: "t", payload: documentPayloadFromBytes(Uint8Array.of(0x90)), storedAt: "2026-09-30T00:00:00Z" };
|
||||
await assert.rejects(() => new SingleFileMessagePackBackingStore(link).pushAll([envelope]), (error: NodeJS.ErrnoException) => error.code === "ENOENT");
|
||||
assert.ok((await lstat(link)).isSymbolicLink());
|
||||
assert.deepEqual(await readdir(volume), []);
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue