diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b7bc66..a1136cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,20 @@ ## Unreleased +- State that background pickup needs `runtime.run(signal)` + ([#22](https://github.com/cardmagic/solid-objects-js/issues/22)). The + README's programming-model example works without it because the caller's + own path executes the call, and nothing on that page said that a process + which installs and then waits claims nothing. An external prober built a + two-process harness from the README and read the unclaimed messages as + stranded. The README and `docs/operations.md` now state it, and + `test/background-pickup.test.ts` pins it: a sent message reads `ready` + after `install()`, and `completed` once `run(signal)` starts the roles. +- Build the same example with `configure()` and address the actor as + `Cart.ref("cart-123")`, matching every other reference example in the + documentation. `createRuntime()` deliberately leaves the process default + unset, so the static form needs `configure()`. + - Add `examples/at-least-once` and `pnpm run test:at-least-once` ([#23](https://github.com/cardmagic/solid-objects-js/issues/23)): an executable proof that the at-least-once clause fires and that the diff --git a/README.md b/README.md index 99a58ac..d557c5d 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ contract. See [Solid Objects in the browser](#solid-objects-in-the-browser). ## The programming model ```typescript -import { Actor, createRuntime } from "solid-objects" +import { Actor, configure } from "solid-objects" import { sqlite } from "solid-objects/database/sqlite" class Cart extends Actor { @@ -53,7 +53,7 @@ class Cart extends Actor { } } -const runtime = createRuntime({ +const runtime = configure({ database: sqlite({ path: "cart.sqlite3" }), authorizeMessage: () => true, authorizeQuery: () => true, @@ -62,7 +62,7 @@ const runtime = createRuntime({ await runtime.install() try { - const cart = runtime.ref(Cart, "cart-123") + const cart = Cart.ref("cart-123") await Promise.all([cart.add({ sku: "blue-shirt" }), cart.add({ sku: "green-hat" })]) } finally { await runtime.close() @@ -73,6 +73,18 @@ Both calls enter the durable mailbox for `cart-123`. They execute in order and commit one state transition at a time, even when different requests or Node.js processes submit them concurrently. +`install()` prepares the database and starts nothing. The example above finishes +because the caller's own path executes each call. A process serves background +work only after `runtime.run(signal)` starts its roles, so a process that +installs and then waits never claims a ready message. Nothing is lost while no +process runs. The message stays ready until one does. + +```typescript +const controller = new AbortController() +process.on("SIGTERM", () => controller.abort()) +await runtime.run(controller.signal) +``` + ## Run it now with SQLite Node.js 24.4.0 or newer is required. Node.js 24.15 or newer is preferred, diff --git a/docs/operations.md b/docs/operations.md index 89b4199..e17def4 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -1,5 +1,12 @@ # Operations +`install()` prepares the database and starts nothing. A process serves +background work only after `runtime.run(signal)` starts its roles. A process +that registers actors, installs, and then waits never claims a ready message, +and work enqueued with `send` stays ready until some process runs the roles. +A direct call or an explicit `sync` needs no running role, because the caller's +own path executes it. + Runtime roles use durable polling as the correctness fallback. Consecutive empty passes double each role's wait from `pollingIntervalMilliseconds` to `idlePollingIntervalMilliseconds`, which defaults to one second. Processed diff --git a/test/background-pickup.test.ts b/test/background-pickup.test.ts new file mode 100644 index 0000000..9efec19 --- /dev/null +++ b/test/background-pickup.test.ts @@ -0,0 +1,76 @@ +import { afterEach, describe, expect, it } from "vitest" +import { Actor } from "../src/actor.js" +import { sqlite } from "../src/database/sqlite.js" +import type { MessageReference } from "../src/reference.js" +import type { MessageStatus } from "../src/types.js" +import { createRuntime, type SolidObjectsRuntime } from "../src/runtime.js" + +class Mailbox extends Actor { + static override readonly actorType = "BackgroundPickupMailbox" + + delivered = 0 + + receive(): number { + this.delivered += 1 + return this.delivered + } +} + +let runtime: SolidObjectsRuntime | undefined + +afterEach(async () => { + await runtime?.close() + runtime = undefined +}) + +function startedRuntime(): SolidObjectsRuntime { + return createRuntime({ + database: sqlite({ path: ":memory:" }), + pollingIntervalMilliseconds: 10, + workerCount: 1, + effectWorkerCount: 0, + reminderSchedulerCount: 0, + retentionIntervalMilliseconds: 0, + deadProcessCleanupIntervalMilliseconds: 0, + authorizeMessage: () => true, + authorizeQuery: () => true, + }) +} + +describe("background pickup", () => { + it("leaves a message ready until run() starts the roles", async () => { + runtime = startedRuntime() + runtime.register(Mailbox) + await runtime.install() + + const message = await runtime.ref(Mailbox, "inbox").send.receive() + await new Promise((resolve) => setTimeout(resolve, 200)) + + expect(await message.status()).toBe("ready") + + const controller = new AbortController() + const running = runtime.run(controller.signal) + let observed: MessageStatus + try { + observed = await pollStatus({ message, timeoutMilliseconds: 2_000 }) + } finally { + controller.abort() + await running + } + + expect(observed).toBe("completed") + }) +}) + +async function pollStatus(options: { + message: MessageReference + timeoutMilliseconds: number +}): Promise { + const deadline = performance.now() + options.timeoutMilliseconds + let status = await options.message.status() + while (status !== "completed" && performance.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 10)) + status = await options.message.status() + } + return status +}