Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 15 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -53,7 +53,7 @@ class Cart extends Actor {
}
}

const runtime = createRuntime({
const runtime = configure({
database: sqlite({ path: "cart.sqlite3" }),
authorizeMessage: () => true,
authorizeQuery: () => true,
Expand All @@ -62,7 +62,7 @@ const runtime = createRuntime({
await runtime.install()

try {
const cart = runtime.ref(Cart, "cart-123")
const cart = Cart.ref("cart-123")
Comment thread
greptile-apps[bot] marked this conversation as resolved.
await Promise.all([cart.add({ sku: "blue-shirt" }), cart.add({ sku: "green-hat" })])
} finally {
await runtime.close()
Expand All @@ -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,
Expand Down
7 changes: 7 additions & 0 deletions docs/operations.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
76 changes: 76 additions & 0 deletions test/background-pickup.test.ts
Original file line number Diff line number Diff line change
@@ -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)
Comment thread
greptile-apps[bot] marked this conversation as resolved.
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<number>
timeoutMilliseconds: number
}): Promise<MessageStatus> {
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
}