diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index efe6175..9bc51f8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,6 +27,20 @@ jobs: - run: pnpm run test:recovery - run: pnpm audit --audit-level=high + floor: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 + with: + node-version: "24.4.0" + - run: corepack enable + - run: pnpm install --frozen-lockfile + - run: pnpm run test + - run: pnpm run build + - run: pnpm run test:package + - run: pnpm run test:recovery + postgresql: runs-on: ubuntu-latest strategy: @@ -127,7 +141,7 @@ jobs: publish: if: startsWith(github.ref, 'refs/tags/v') - needs: [quality, postgresql, mysql, browser, redis] + needs: [quality, floor, postgresql, mysql, browser, redis] runs-on: ubuntu-latest permissions: contents: write diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c29d53..4818f14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,32 @@ # Changelog +## 0.13.3 - 2026-08-18 + +- Lower the supported Node.js floor from 24.15.0 to 24.4.0. Node.js 24.4.0 is + the first release that accepts `readBigInts` on the `DatabaseSync` + constructor, which the SQLite adapter needs to read 64-bit integers without + losing precision. Node.js 24.0.0 through 24.3.x ignore the option, and the + effect recovery and transaction retry tests fail there. A new CI job runs the + default suite, the build, the packaged artifact smoke test, and the recovery + demo on the floor. +- Record that `node:sqlite` stays experimental until Node.js 24.15.0 and prints + a warning on stderr before it. +- Make the failure-recovery demo's serialization proof count messages rather + than executions. A worker that loses its lease mid-operation leaves the + replacement to execute the same message again, which is the at-least-once + contract, so the proof failed on slower machines for behaviour it documents + elsewhere. Each serialization event now carries its attempt and process, so a + start pairs with its own finish instead of with whichever finish came next. + The proof asserts that every start has its own finish, that exactly the two + sent messages ran, and that the surviving attempt of each message never + overlaps another message's surviving attempt; a superseded attempt may + overlap anything, because it keeps running until it notices the lost lease and + its write is fenced out. The committed state check is unchanged, and the demo + reports the executions it saw. `assertSerializedExecution` moved into its own + module with unit coverage for the clean, retried, superseded-overlap, + still-running-replacement, unexplained-overlap, boundary, unfinished, + unmatched-finish, double-start, restart-after-finish, and lost-message cases. + ## 0.13.2 - 2026-08-17 - Accept a `key` on `schedule`, naming a reminder for the item it is waiting diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6a4917c..5823a25 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,7 +7,10 @@ lowest layer that can prove it. ## Setup Install Node.js 24.15 or newer, enable Corepack, and install the locked -dependencies: +dependencies. The package supports Node.js 24.4.0 or newer, and CI runs the +default suite, the build, the packaged artifact smoke test, and the recovery +demo on that floor. Node.js 24.15 is where `node:sqlite` stops printing an +experimental warning: ```bash corepack enable diff --git a/README.md b/README.md index 8fdfa3a..2d985d5 100644 --- a/README.md +++ b/README.md @@ -60,11 +60,12 @@ processes submit them concurrently. ## Run it now with SQLite -Node.js 24.15 or newer is required. The `0.13.2` release includes a -packaged quickstart: +Node.js 24.4.0 or newer is required. Node.js 24.15 or newer is preferred, +because `node:sqlite` prints an experimental warning before it. The `0.13.3` +release includes a packaged quickstart: ```bash -npm exec --yes --package=solid-objects@0.13.2 -- solid-objects quickstart +npm exec --yes --package=solid-objects@0.13.3 -- solid-objects quickstart ``` The command needs no repository checkout, database server, Redis, container, or @@ -258,7 +259,8 @@ edge placement, cross-identity transactions, and operational data access—is in ## Requirements and supported systems -- Node.js 24.15 or newer +- Node.js 24.4.0 or newer; 24.15 or newer to avoid the `node:sqlite` + experimental warning - TypeScript 5.9 or newer for TypeScript applications - SQLite through `node:sqlite`, PostgreSQL 14 or newer, or MySQL 8.0 or newer with InnoDB diff --git a/docs/parity.md b/docs/parity.md index 3203cfd..245bcb9 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -4,11 +4,11 @@ This ledger tracks capability parity with the Ruby `solid_objects` gem. Parity means preserving a capability and its correctness or security boundary, not copying a Rails API into Node. -Reference: Ruby `solid_objects` 0.13.2. The JavaScript package began at the +Reference: Ruby `solid_objects` 0.13.3. The JavaScript package began at the Ruby design's `0.12` capability generation; that version number did not imply earlier JavaScript releases. -The Node `0.13.2` implementation has capability parity with that reference. Its +The Node `0.13.3` implementation has capability parity with that reference. Its relational runtime, correctness boundaries, administration, diagnostics, operator dashboard, realtime projections, browser behavior, and supported adapters have native equivalents. Rails-specific rendering surfaces are diff --git a/docs/releasing.md b/docs/releasing.md index a4973d6..d551d66 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -39,8 +39,8 @@ npm trust github solid-objects \ 4. Create and push an annotated tag matching the package version: ```shell - git tag -a v0.13.2 -m "Version 0.13.2" - git push origin v0.13.2 + git tag -a v0.13.3 -m "Version 0.13.3" + git push origin v0.13.3 ``` The tag runs the complete CI matrix. The publish job starts only after every diff --git a/docs/support.md b/docs/support.md index 5e2c379..a849cef 100644 --- a/docs/support.md +++ b/docs/support.md @@ -4,7 +4,7 @@ | Component | Supported or tested range | | -------------- | ----------------------------------------------------------- | -| Node.js | 24.15 or newer; CI uses 24.15 | +| Node.js | 24.4.0 or newer; CI runs 24.4.0 and 24.15.0 | | TypeScript | 5.9 or newer for TypeScript applications | | SQLite | Node's built-in `node:sqlite` on the supported Node runtime | | PostgreSQL | 14 or newer; CI runs 14 and 18 | @@ -15,6 +15,17 @@ The package is ESM-only. PostgreSQL, MySQL, and Redis require their optional peer dependency. SQLite has no driver dependency beyond Node.js. +The Node.js floor is 24.4.0 because the SQLite adapter reads integer columns as +`BigInt`. Node.js 24.4.0 is the first release that accepts `readBigInts` on the +`DatabaseSync` constructor. Node.js 24.0.0 through 24.3.x ignore the option and +return `Number`, which loses precision on 64-bit values and fails the effect +recovery and transaction retry tests. + +Node.js 24.15.0 is the first release where `node:sqlite` is no longer +experimental. Between 24.4.0 and 24.14.x the module works but prints +`ExperimentalWarning: SQLite is an experimental feature` on stderr, and its API +can change. Prefer 24.15.0 or newer where the choice is free. + ## What the matrix covers The default suite exercises actor definitions, mailbox ordering, state diff --git a/examples/failure-recovery/actor.ts b/examples/failure-recovery/actor.ts index a6235b1..c372732 100644 --- a/examples/failure-recovery/actor.ts +++ b/examples/failure-recovery/actor.ts @@ -25,16 +25,14 @@ export class RecoveryCounter extends Actor { async serialize({ controlDirectory }: { controlDirectory: string }): Promise { const message = this.currentMessage if (!message) throw new Error("serialize requires a durable message") - await appendFile( - join(controlDirectory, "serialization.jsonl"), - `${JSON.stringify({ event: "start", messageId: message.id, at: Date.now() })}\n`, - ) + // The attempt and the process identify the execution, so a start pairs with + // its own finish even when a superseded attempt outlives its replacement. + const execution = { messageId: message.id, attempt: message.attempt, processId: process.pid } + const path = join(controlDirectory, "serialization.jsonl") + await appendFile(path, `${JSON.stringify({ event: "start", ...execution, at: Date.now() })}\n`) await new Promise((resolve) => setTimeout(resolve, 100)) this.count += 1 - await appendFile( - join(controlDirectory, "serialization.jsonl"), - `${JSON.stringify({ event: "finish", messageId: message.id, at: Date.now() })}\n`, - ) + await appendFile(path, `${JSON.stringify({ event: "finish", ...execution, at: Date.now() })}\n`) return this.count } } diff --git a/examples/failure-recovery/demo.ts b/examples/failure-recovery/demo.ts index 9ab5edd..ba0f3a7 100644 --- a/examples/failure-recovery/demo.ts +++ b/examples/failure-recovery/demo.ts @@ -8,6 +8,11 @@ import { fork, type ChildProcess } from "node:child_process" import { createRuntime, type ActorReference, type MessageReference } from "solid-objects" import { sqlite } from "solid-objects/database/sqlite" import { RecoveryCounter } from "./actor.ts" +import { + assertSerializedExecution, + parseSerializationEvent, + type SerializationProof, +} from "./serialization.ts" interface WorkerMessage { event: string @@ -15,12 +20,6 @@ interface WorkerMessage { processed?: number } -interface SerializationEvent { - event: "start" | "finish" - messageId: string - at: number -} - interface ExternalEffectEvent { messageId: string attempt: number @@ -59,7 +58,7 @@ try { assert.equal(existsSync(directory), false) -async function proveSerialization(): Promise<{ finalState: number; overlap: false }> { +async function proveSerialization(): Promise { const controlDirectory = join(directory, "serialization") await mkdir(controlDirectory) const reference = runtime.ref(RecoveryCounter, "serialized") @@ -74,15 +73,10 @@ async function proveSerialization(): Promise<{ finalState: number; overlap: fals join(controlDirectory, "serialization.jsonl"), parseSerializationEvent, ) - assert.equal(events.length, 4) - const starts = events.filter((event) => event.event === "start") - const finishes = events.filter((event) => event.event === "finish") - assert.equal(starts.length, 2) - assert.equal(finishes.length, 2) - assert(Number(starts[1]?.at) >= Number(finishes[0]?.at)) + const proof = assertSerializedExecution(events, { messageCount: 2 }) const snapshot = await reference.snapshot() assert.equal(snapshot.count, 2) - return { finalState: snapshot.count, overlap: false } + return { ...proof, finalState: snapshot.count } } async function proveCrashRecovery(): Promise<{ @@ -200,18 +194,6 @@ async function readJsonLines( return (await readFile(path, "utf8")).trim().split("\n").filter(Boolean).map(parse) } -function parseSerializationEvent(line: string): SerializationEvent { - const event = JSON.parse(line) as Partial - if ( - (event.event !== "start" && event.event !== "finish") || - typeof event.messageId !== "string" || - typeof event.at !== "number" - ) { - throw new TypeError("invalid serialization event") - } - return { event: event.event, messageId: event.messageId, at: event.at } -} - function parseExternalEffectEvent(line: string): ExternalEffectEvent { const event = JSON.parse(line) as Partial if ( diff --git a/examples/failure-recovery/serialization.ts b/examples/failure-recovery/serialization.ts new file mode 100644 index 0000000..61cdd64 --- /dev/null +++ b/examples/failure-recovery/serialization.ts @@ -0,0 +1,133 @@ +import assert from "node:assert/strict" + +export interface SerializationEvent { + event: "start" | "finish" + messageId: string + attempt: number + processId: number + at: number +} + +export interface SerializationProof { + executions: number + retried: boolean + supersededOverlap: boolean +} + +interface Execution { + messageId: string + attempt: number + processId: number + startedAt: number + finishedAt: number +} + +export function parseSerializationEvent(line: string): SerializationEvent { + const event = JSON.parse(line) as Partial + if ( + (event.event !== "start" && event.event !== "finish") || + typeof event.messageId !== "string" || + typeof event.attempt !== "number" || + typeof event.processId !== "number" || + typeof event.at !== "number" + ) { + throw new TypeError("invalid serialization event") + } + return { + event: event.event, + messageId: event.messageId, + attempt: event.attempt, + processId: event.processId, + at: event.at, + } +} + +// One identity commits one state transition at a time. The control file is +// written outside the transaction, so it records execution attempts rather than +// commits: a worker that loses its lease keeps running until it notices, and its +// replacement executes the same message under a higher attempt. The superseded +// attempt may therefore overlap anything, because its write is fenced out and +// the committed state is what proves it. +// +// Each event carries its attempt and process, so a start pairs with its own +// finish rather than with whichever finish arrived next. Without that, a +// superseded attempt finishing late reads as its replacement finishing, and a +// second message could then overlap a replacement that is still running. +export function assertSerializedExecution( + events: readonly SerializationEvent[], + options: { messageCount: number }, +): SerializationProof { + const executions = pairExecutions(events) + + const messageIds = new Set(executions.map((execution) => execution.messageId)) + assert.equal( + messageIds.size, + options.messageCount, + `expected ${options.messageCount} messages to run, saw ${messageIds.size}`, + ) + + const survivingAttempt = new Map() + for (const execution of executions) { + const highest = survivingAttempt.get(execution.messageId) ?? 0 + if (execution.attempt > highest) survivingAttempt.set(execution.messageId, execution.attempt) + } + const surviving = executions.filter( + (execution) => survivingAttempt.get(execution.messageId) === execution.attempt, + ) + + for (const [index, execution] of surviving.entries()) { + for (const other of surviving.slice(index + 1)) { + assert( + !overlaps(execution, other), + `${describe(execution)} and ${describe(other)} overlap, and neither was superseded`, + ) + } + } + + const supersededOverlap = executions.some((execution) => + executions.some((other) => other !== execution && overlaps(execution, other)), + ) + + return { + executions: executions.length, + retried: executions.length > options.messageCount, + supersededOverlap, + } +} + +function pairExecutions(events: readonly SerializationEvent[]): Execution[] { + const started = new Map() + const executions: Execution[] = [] + + for (const event of [...events].sort((left, right) => left.at - right.at)) { + const key = `${event.messageId}#${event.attempt}#${event.processId}` + if (event.event === "start") { + assert(!started.has(key), `${describe(event)} started twice`) + started.set(key, event) + continue + } + const start = started.get(key) + assert(start !== undefined, `${describe(event)} finished with no matching start`) + started.delete(key) + executions.push({ + messageId: event.messageId, + attempt: event.attempt, + processId: event.processId, + startedAt: start.at, + finishedAt: event.at, + }) + } + + const unfinished = [...started.values()].map(describe) + assert.equal(unfinished.length, 0, `${unfinished.join(", ")} never wrote a finish`) + + return executions +} + +function overlaps(left: Execution, right: Execution): boolean { + return left.startedAt < right.finishedAt && right.startedAt < left.finishedAt +} + +function describe(execution: { messageId: string; attempt: number }): string { + return `${execution.messageId} attempt ${execution.attempt}` +} diff --git a/package.json b/package.json index d76e6ad..30eea2d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "solid-objects", - "version": "0.13.2", + "version": "0.13.3", "description": "Race-free realtime state per application identity, backed by your SQL database", "type": "module", "license": "MIT", @@ -23,7 +23,7 @@ "url": "git+https://github.com/cardmagic/solid-objects-js.git" }, "engines": { - "node": ">=24.15.0" + "node": ">=24.4.0" }, "packageManager": "pnpm@11.11.0", "bin": { diff --git a/src/version.ts b/src/version.ts index 8cc92a6..729c18a 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const VERSION = "0.13.2" +export const VERSION = "0.13.3" diff --git a/test/failure-recovery-serialization.test.ts b/test/failure-recovery-serialization.test.ts new file mode 100644 index 0000000..0982178 --- /dev/null +++ b/test/failure-recovery-serialization.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it } from "vitest" +import { + assertSerializedExecution, + type SerializationEvent, +} from "../examples/failure-recovery/serialization.js" + +function execution(options: { + messageId: string + attempt: number + startedAt: number + finishedAt: number + processId?: number +}): SerializationEvent[] { + const { messageId, attempt, startedAt, finishedAt, processId = attempt } = options + return [ + { event: "start", messageId, attempt, processId, at: startedAt }, + { event: "finish", messageId, attempt, processId, at: finishedAt }, + ] +} + +describe("serialization proof", () => { + it("accepts two executions that do not overlap", () => { + const events = [ + ...execution({ messageId: "a", attempt: 1, startedAt: 10, finishedAt: 20 }), + ...execution({ messageId: "b", attempt: 1, startedAt: 30, finishedAt: 40 }), + ] + + expect(assertSerializedExecution(events, { messageCount: 2 })).toEqual({ + executions: 2, + retried: false, + supersededOverlap: false, + }) + }) + + // A worker can lose its lease mid-operation, and the replacement executes the + // same message again under a higher attempt. That is the at-least-once + // contract, so the proof counts messages rather than executions. + it("accepts a message that executes twice after a lost lease", () => { + const events = [ + ...execution({ messageId: "a", attempt: 1, startedAt: 10, finishedAt: 20 }), + ...execution({ messageId: "a", attempt: 2, startedAt: 30, finishedAt: 40 }), + ...execution({ messageId: "b", attempt: 1, startedAt: 50, finishedAt: 60 }), + ] + + expect(assertSerializedExecution(events, { messageCount: 2 })).toEqual({ + executions: 3, + retried: true, + supersededOverlap: false, + }) + }) + + // The attempt that lost the lease is the one that was slow, so it is still + // running when its replacement starts. Its write is fenced out, and the + // committed state is what proves that, so the log may interleave here. + it("accepts a superseded attempt that outlives the start of its replacement", () => { + const events = [ + ...execution({ messageId: "a", attempt: 1, startedAt: 10, finishedAt: 30 }), + ...execution({ messageId: "a", attempt: 2, startedAt: 20, finishedAt: 40 }), + ...execution({ messageId: "b", attempt: 1, startedAt: 50, finishedAt: 60 }), + ] + + expect(assertSerializedExecution(events, { messageCount: 2 })).toEqual({ + executions: 3, + retried: true, + supersededOverlap: true, + }) + }) + + // The superseded attempt finishing late must not be read as its replacement + // finishing. The replacement is still running, so a second message that + // starts here is a real serialization failure. + it("rejects a second message that overlaps a still-running replacement", () => { + const events = [ + ...execution({ messageId: "a", attempt: 1, startedAt: 10, finishedAt: 35 }), + ...execution({ messageId: "a", attempt: 2, startedAt: 20, finishedAt: 60 }), + ...execution({ messageId: "b", attempt: 1, startedAt: 40, finishedAt: 50 }), + ] + + expect(() => assertSerializedExecution(events, { messageCount: 2 })).toThrow(/overlap/) + }) + + it("rejects executions that overlap when no message ran twice", () => { + const events = [ + ...execution({ messageId: "a", attempt: 1, startedAt: 10, finishedAt: 20 }), + ...execution({ messageId: "b", attempt: 1, startedAt: 15, finishedAt: 25 }), + ] + + expect(() => assertSerializedExecution(events, { messageCount: 2 })).toThrow(/overlap/) + }) + + it("accepts one execution that ends exactly as the next begins", () => { + const events = [ + ...execution({ messageId: "a", attempt: 1, startedAt: 10, finishedAt: 20 }), + ...execution({ messageId: "b", attempt: 1, startedAt: 20, finishedAt: 30 }), + ] + + expect(() => assertSerializedExecution(events, { messageCount: 2 })).not.toThrow() + }) + + it("rejects an execution that never finished", () => { + const events: SerializationEvent[] = [ + ...execution({ messageId: "a", attempt: 1, startedAt: 10, finishedAt: 20 }), + { event: "start", messageId: "b", attempt: 1, processId: 1, at: 30 }, + ] + + expect(() => assertSerializedExecution(events, { messageCount: 2 })).toThrow(/finish/) + }) + + it("rejects a finish with no start", () => { + const events: SerializationEvent[] = [ + ...execution({ messageId: "a", attempt: 1, startedAt: 10, finishedAt: 20 }), + { event: "finish", messageId: "b", attempt: 1, processId: 1, at: 30 }, + ] + + expect(() => assertSerializedExecution(events, { messageCount: 2 })).toThrow(/start/) + }) + + it("rejects one attempt that started twice", () => { + const events: SerializationEvent[] = [ + { event: "start", messageId: "a", attempt: 1, processId: 1, at: 10 }, + { event: "start", messageId: "a", attempt: 1, processId: 1, at: 15 }, + { event: "finish", messageId: "a", attempt: 1, processId: 1, at: 20 }, + { event: "finish", messageId: "a", attempt: 1, processId: 1, at: 25 }, + ...execution({ messageId: "b", attempt: 1, startedAt: 30, finishedAt: 40 }), + ] + + expect(() => assertSerializedExecution(events, { messageCount: 2 })).toThrow(/twice/) + }) + + it("rejects an attempt that starts again after it finished", () => { + const events: SerializationEvent[] = [ + ...execution({ messageId: "a", attempt: 1, startedAt: 10, finishedAt: 20 }), + { event: "start", messageId: "a", attempt: 1, processId: 1, at: 25 }, + ...execution({ messageId: "b", attempt: 1, startedAt: 30, finishedAt: 40 }), + ] + + expect(() => assertSerializedExecution(events, { messageCount: 2 })).toThrow(/finish/) + }) + + it("rejects a run that lost one of the messages", () => { + const events = [ + ...execution({ messageId: "a", attempt: 1, startedAt: 10, finishedAt: 20 }), + ...execution({ messageId: "a", attempt: 2, startedAt: 30, finishedAt: 40 }), + ] + + expect(() => assertSerializedExecution(events, { messageCount: 2 })).toThrow(/message/) + }) +}) diff --git a/test/process-administration.test.ts b/test/process-administration.test.ts index d2fd67f..4d36871 100644 --- a/test/process-administration.test.ts +++ b/test/process-administration.test.ts @@ -49,7 +49,7 @@ describe("process administration", () => { hostProcessId: process.pid, metadata: { nodeVersion: process.version, - solidObjectsVersion: "0.13.2", + solidObjectsVersion: "0.13.3", }, shutdownState: "running", shutdownRequestedAt: null,