Merge origin/main (cultcache-rs 0.3.1, the whole-store writer fix) into the C2a landing branch
The stamp functions (Rust refuse_unread_format, TS refuseUnreadFormat) are the single owner of the stamp rule, accept v1 and v3, and are called only from the one reader every whole-store write runs under the lock. The fix's header peek (TS refuseUnreadStoreBytes, peekHeader) is deleted. Its tests that v3 is refused and that a stampless file is healed are dropped; the reversal is a Breaking line in the Rust and TS CHANGELOGs. The 0xff-after-stamp test now runs over v1 and v3. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
f54472e5fe
6 changed files with 80 additions and 22 deletions
|
|
@ -9,4 +9,11 @@ only in git history.
|
|||
### Breaking
|
||||
|
||||
- A single-file store written as `cultcache.store.v3` is read and written: a record that holds an element id (an owned list element with an `[CultElementId]` member, carried through every write) marks the store v3, and a v3 store is carried through a rewrite with its ids. A runtime from before this change refuses a v3 store by name.
|
||||
- A write over a file the reader refuses (a zero-byte file, a truncated store, a stamp this runtime does not read) refuses with `CultCacheStoreUnreadable` and leaves the file as it is. A whole-store write used to replace such a file.
|
||||
- A write over a file the reader refuses (a zero-byte file, a truncated store, a stamp this runtime does not read) refuses with `CultCacheStoreUnreadable` and leaves the file as it is. A whole-store write used to replace such a file. This reverses 0.3.1, where `push_all` replaced a file with no readable stamp (healing it); the stamp rule now has one owner, the reader every write asks under the lock, and it accepts v1 and v3 (0.3.1 refused v3).
|
||||
|
||||
## [0.3.1]
|
||||
|
||||
### Fixed
|
||||
- `push_all` reads the store's header under the lock and refuses a format this runtime does
|
||||
not read, with `CultCacheStoreUnreadable` `UnsupportedFormat`, leaving the file as it was.
|
||||
A file with no readable stamp is still replaced, so a whole write heals it.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "cultcache-rs"
|
||||
version = "0.3.0"
|
||||
version = "0.3.1"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
publish = false
|
||||
|
|
|
|||
|
|
@ -1361,31 +1361,39 @@ impl SingleFileMessagePackBackingStore {
|
|||
.context(format!("failed to read {}", self.path.display())));
|
||||
}
|
||||
};
|
||||
let unreadable = |kind, detail: anyhow::Error| {
|
||||
// The detail is rebuilt as text that names no value read from the bytes. The
|
||||
// decoder reports a short read as an io::Error, and dropping it keeps an
|
||||
// io::Error in the chain meaning only that the file itself could not be read.
|
||||
anyhow!(describe_undecodable(&detail)).context(CultCacheStoreUnreadable {
|
||||
path: self.path.clone(),
|
||||
kind,
|
||||
})
|
||||
};
|
||||
let head = store_header(&bytes);
|
||||
match head.first {
|
||||
Some(header) if header.starts_with("cultcache.store.") && readable_store_format(&header).is_none() => Err(unreadable(
|
||||
self.refuse_unread_format(head.first.as_deref())?;
|
||||
head.whole
|
||||
.and_then(|()| decode_store_file(&bytes, head.first.as_deref()))
|
||||
.map_err(|error| self.unreadable(CultCacheStoreUnreadableKind::Undecodable, error))
|
||||
}
|
||||
|
||||
// The one owner of the stamp rule, asked only by the one reader: a cultcache.store.* header this runtime does not read
|
||||
// (it reads v1 and v3) is refused by name, judged from the header alone, so a newer store cut short is still a newer
|
||||
// store. Every write over a file asks that reader first, so a whole-store write judges a stamp as a read does.
|
||||
fn refuse_unread_format(&self, header: Option<&str>) -> Result<()> {
|
||||
match header {
|
||||
Some(header) if header.starts_with("cultcache.store.") && readable_store_format(header).is_none() => Err(self.unreadable(
|
||||
CultCacheStoreUnreadableKind::UnsupportedFormat,
|
||||
anyhow!(
|
||||
"its header is {}; this runtime reads {STORE_FORMAT_V1:?} and {STORE_FORMAT_ELEMENT_IDS:?} only",
|
||||
describe_header(&header)
|
||||
describe_header(header)
|
||||
),
|
||||
)),
|
||||
first => head
|
||||
.whole
|
||||
.and_then(|()| decode_store_file(&bytes, first.as_deref()))
|
||||
.map_err(|error| unreadable(CultCacheStoreUnreadableKind::Undecodable, error)),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn unreadable(&self, kind: CultCacheStoreUnreadableKind, detail: anyhow::Error) -> anyhow::Error {
|
||||
// The detail is rebuilt as text that names no value read from the bytes. The
|
||||
// decoder reports a short read as an io::Error, and dropping it keeps an
|
||||
// io::Error in the chain meaning only that the file itself could not be read.
|
||||
anyhow!(describe_undecodable(&detail)).context(CultCacheStoreUnreadable {
|
||||
path: self.path.clone(),
|
||||
kind,
|
||||
})
|
||||
}
|
||||
|
||||
/// Reads one filesystem snapshot without creating or opening the sibling
|
||||
/// CultCache lock file.
|
||||
///
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ All notable changes to `@gamecult/cultcache-ts` are recorded here, following
|
|||
- A single-file store written as `cultcache.store.v3` is read and written: a record that holds an element id (an owned list element with an `[CultElementId]` member, carried through every write) marks the store v3, and a v3 store is carried through a rewrite with its ids. A runtime from before this change refuses a v3 store by name.
|
||||
- A single-file store refuses a zero-byte file as `StoreUnreadableError`; only a path with nothing at it is an empty store. A header this runtime does not read is refused, and the refusal echoes the header only as `cultcache.store.v<digits>`.
|
||||
- A write over a file the reader refuses (`StoreUnreadableError`, from `push`, `pushAll` and `delete`) refuses and leaves the file as it is. A flush used to replace such a file. A write set cannot merge with a disk it cannot read, so the file is repaired or removed by its owner before the store writes again.
|
||||
- This reverses the 0.14.2 `pushAll` fix in two ways. A full `pushAll` over a file with no readable stamp no longer replaces it (healing it): the reader refuses it, and the file is left as it is. And a `cultcache.store.v3` stamp is read, not refused. The stamp rule has one owner, `refuseUnreadFormat`, asked only by the one reader that every write consults under the lock.
|
||||
- A dangling symbolic link at the store path is an I/O error with code `ENOENT`, not an empty store.
|
||||
|
||||
### Added
|
||||
|
|
@ -38,6 +39,13 @@ All notable changes to `@gamecult/cultcache-ts` are recorded here, following
|
|||
- Opening a legacy store that needs healing rewrites it under the lock, from a read taken
|
||||
under the lock.
|
||||
|
||||
### Fixed
|
||||
- A full `pushAll` reads the store's header under the lock first and refuses a store format
|
||||
this runtime does not read (the same error a read gives), leaving the file as it was. A
|
||||
file with no readable stamp is still replaced, so a whole write heals it. Soft `pushAll`
|
||||
is unchanged. The stamp is decoded by the decoder a read uses, so a stamp followed by bytes
|
||||
that are not UTF-8 is refused by a whole write as a read refuses it.
|
||||
|
||||
### Dependencies
|
||||
- `koffi` 3.3.0, loaded only on the first write.
|
||||
|
||||
|
|
|
|||
|
|
@ -22,13 +22,17 @@ export function isStoreSnapshot(decoded: unknown): decoded is [StoreFormat, ...u
|
|||
return false;
|
||||
}
|
||||
|
||||
if (decoded[0] !== STORE_FORMAT_VERSION && decoded[0] !== STORE_FORMAT_ELEMENT_IDS) {
|
||||
refuseUnreadFormat(decoded[0]);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** The one verdict on a stamp: this runtime reads v1 and v3, and any other `cultcache.store.*` header is refused by name. */
|
||||
function refuseUnreadFormat(header: string): void {
|
||||
if (header !== STORE_FORMAT_VERSION && header !== STORE_FORMAT_ELEMENT_IDS) {
|
||||
throw new Error(
|
||||
`CultCache store format ${describeHeader(decoded[0])} is not one this runtime reads; it reads "${STORE_FORMAT_VERSION}" and "${STORE_FORMAT_ELEMENT_IDS}" only.`,
|
||||
`CultCache store format ${describeHeader(header)} is not one this runtime reads; it reads "${STORE_FORMAT_VERSION}" and "${STORE_FORMAT_ELEMENT_IDS}" only.`,
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -2083,6 +2083,37 @@ test("SingleFileMessagePackBackingStore refusal says what was found and names ev
|
|||
}
|
||||
});
|
||||
|
||||
test("a stamp this runtime reads, followed by bytes that are not UTF-8, gets the same refusal from a read and from a whole write", async () => {
|
||||
const envelope = { key: "probe", type: "probe", payload: documentPayloadFromBytes(new Uint8Array([0x80])), storedAt: "2026-10-22T00:00:00Z" };
|
||||
for (const header of ["cultcache.store.v1", "cultcache.store.v3"]) {
|
||||
const stamp = [...Buffer.from(header, "utf8")];
|
||||
const forms = { fixstr: [0x91, 0xa0 | (stamp.length + 1), ...stamp, 0xff], str8: [0x91, 0xd9, stamp.length + 1, ...stamp, 0xff] };
|
||||
for (const [name, bytes] of Object.entries(forms)) {
|
||||
const file = join(await mkdtemp(join(tmpdir(), "cultcache-stamp-")), "store.msgpack");
|
||||
await writeFile(file, new Uint8Array(bytes));
|
||||
const store = new SingleFileMessagePackBackingStore(file);
|
||||
await assert.rejects(() => store.pullAll(), /not one this runtime reads/u, `${header} ${name} read`);
|
||||
await assert.rejects(() => store.pushAll([envelope]), /not one this runtime reads/u, `${header} ${name} pushAll`);
|
||||
assert.deepEqual([...(await readFile(file))], bytes, `${header} ${name} left as it was`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("pushAll leaves a store it reads alone when soft, and replaces one it reads otherwise", async () => {
|
||||
const vector = await readFile(resolve(cultLibRoot, "tests", "vectors", "document-variants-c2a", "v3-base.msgpack"));
|
||||
const envelope = { key: "probe", type: "probe", payload: documentPayloadFromBytes(new Uint8Array([0x80])), storedAt: "2026-10-22T00:00:00Z" };
|
||||
const existing = join(await mkdtemp(join(tmpdir(), "cultcache-pushall-soft-")), "store.msgpack");
|
||||
await writeFile(existing, vector);
|
||||
await new SingleFileMessagePackBackingStore(existing).pushAll([envelope], { soft: true });
|
||||
assert.equal(Buffer.compare(await readFile(existing), vector), 0);
|
||||
|
||||
const own = join(await mkdtemp(join(tmpdir(), "cultcache-pushall-own-")), "store.msgpack");
|
||||
const store = new SingleFileMessagePackBackingStore(own);
|
||||
await store.pushAll([envelope]);
|
||||
await store.pushAll([{ ...envelope, key: "second" }]);
|
||||
assert.deepEqual((await store.pullAll()).map((entry) => entry.key), ["second"]);
|
||||
});
|
||||
|
||||
test("SingleFileMessagePackBackingStore reads only a missing store as empty", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "cultcache-absent-"));
|
||||
assert.deepEqual(await new SingleFileMessagePackBackingStore(join(dir, "store.cc")).pullAll(), []);
|
||||
|
|
|
|||
Loading…
Reference in a new issue