diff --git a/package-lock.json b/package-lock.json index 8c1b7a4b..9100d8db 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1184,7 +1184,7 @@ }, "packages/cultcache-ts": { "name": "@gamecult/cultcache-ts", - "version": "0.14.1", + "version": "0.14.2", "license": "MIT", "dependencies": { "@msgpack/msgpack": "^3.1.3", diff --git a/packages/cultcache-ts/CHANGELOG.md b/packages/cultcache-ts/CHANGELOG.md index f43bf60a..14d32a33 100644 --- a/packages/cultcache-ts/CHANGELOG.md +++ b/packages/cultcache-ts/CHANGELOG.md @@ -3,6 +3,26 @@ All notable changes to `@gamecult/cultcache-ts` are recorded here, following [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [0.14.2] + +### Added +- `SingleFileMessagePackBackingStore` takes a cross-process write lock on `.lock`, + interoperable with the C# and Rust runtimes (`flock` on Unix, `LockFileEx` on Windows). + Each write reads the current file, merges and replaces it under the lock, so concurrent + writers no longer lose each other's records. +- The staged file is fsynced before the replace, and on Unix the directory after it. A + directory sync failure after the replace is reported as replaced but not durable. + +### Changed +- The Windows replace is a POSIX-semantics rename first (it lands while C# and Rust readers + hold the store open), falling back to a write-through `MoveFileExW`, with 25 attempts 20 ms + apart on errors 5, 32 and 33. Writers wait up to 30 s for the lock. +- Opening a legacy store that needs healing rewrites it under the lock, from a read taken + under the lock. + +### Dependencies +- `koffi` 3.3.0, loaded only on the first write. + ## [0.14.1] ### Added diff --git a/packages/cultcache-ts/README.md b/packages/cultcache-ts/README.md index 9e208dfe..192d4b5d 100644 --- a/packages/cultcache-ts/README.md +++ b/packages/cultcache-ts/README.md @@ -358,7 +358,8 @@ That is the TypeScript version of the original CultCache trick: - each record is `[key, schemaId, storedAt, payload]` - each catalog entry is `[schemaId, schemaName, schemaVersion, contentHash, canonicalSchemaJson, compatibleSchemaIds, members]` - it still reads the older TS/Rust-style envelope array and rewrites repaired legacy payloads as v1 snapshots -- it queues local writes so one process does not step on its own shoelaces +- it queues local writes in order, then takes the shared `.lock` (`flock` on Unix, `LockFileEx` on Windows, via koffi, loaded on the first write) for each one, so C#, Rust and TypeScript writers exclude each other; the read a write merges from is taken under that lock +- it fsyncs the staged file and, on Unix, the directory after the replace; on Windows it replaces with a POSIX-semantics rename and falls back to a write-through `MoveFileExW`, retrying briefly on sharing violations - it persists raw MessagePack payload bytes inside schema-addressed records -It is a control-plane store, not a tiny god. If multiple processes hammer the same file, you still need a coordinator or an external lock instead of prayer. +It is a control-plane store, not a tiny god: every write rewrites the whole file. diff --git a/packages/cultcache-ts/package.json b/packages/cultcache-ts/package.json index 501b511d..c48e54e5 100644 --- a/packages/cultcache-ts/package.json +++ b/packages/cultcache-ts/package.json @@ -1,6 +1,6 @@ { "name": "@gamecult/cultcache-ts", - "version": "0.14.1", + "version": "0.14.2", "description": "CultCache-style typed persistence for TypeScript with schema-backed polymorphic MessagePack storage.", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/src/GameCult.Caching/Contracts/cultcache-store-composition.md b/src/GameCult.Caching/Contracts/cultcache-store-composition.md index a19a8839..173662f4 100644 --- a/src/GameCult.Caching/Contracts/cultcache-store-composition.md +++ b/src/GameCult.Caching/Contracts/cultcache-store-composition.md @@ -240,8 +240,13 @@ replaces; writers bump a minted timestamp by one tick when it is not later. wait bound (30 seconds). - A conditional commit on a store with staged single-record writes throws: flush first. -- Single-file stores lock a sidecar `.lock` opened exclusively. Directory - stores use their existing commit lease. +- Single-file stores lock a sidecar `.lock`: C# by an exclusive + (share-none) open, Rust and TypeScript by an exclusive `LockFileEx` on a + shared open on Windows, and every runtime by `flock(LOCK_EX)` on Unix. Each + excludes the others (TypeScript tests: "a TypeScript write waits while a C# + writer holds the store lock", "... a Rust writer ...", "a C# writer waits + while a TypeScript writer holds the store lock", "a Rust writer waits ..."). + Directory stores use their existing commit lease. - A directory store with no `.commit.lock` (no writer has created it yet, or the store was copied without it) gives a pull no lease, and opening it creates none. Such a pull re-reads the manifest after loading its pages and @@ -265,9 +270,11 @@ against another writer; processes sharing a store must all use it. in that store, and leaves the store clean. - A conditional commit lands the batch onto the file as it is under the lock. -A store is written by one runtime at a time. Whether a Rust `fs2` lock and a C# -exclusive open of the same lock file exclude each other is not established, and -another runtime need not obey the `storedAt` rule. +C#, Rust and TypeScript writers exclude each other on the shared lock file; +Python takes no cross-process lock yet (`variants:follow_up:py-shared-write-lock`), +and Rust's open fails rather than waits while a C# writer holds the lock +(`variants:follow_up:rust-lock-open-waits`). Another runtime need not obey the +`storedAt` rule. ## Serialization options