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
4 changes: 2 additions & 2 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,8 +171,8 @@ Activities have no restrictions — they run once, result is cached.

Release manifests must resolve `duroxide` and `duroxide-pg` from crates.io:
```toml
duroxide = { version = "0.1.30", features = ["sqlite"] }
duroxide-pg = "0.1.35"
duroxide = { version = "0.1.32", features = ["sqlite"] }
duroxide-pg = "0.1.36"
```

Temporary local `[patch.crates-io]` sections or inline `path =` overrides are
Expand Down
49 changes: 49 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,55 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed

- **Bumped `duroxide` dependency** — `0.1.30` → `0.1.32` (race fixes and shutdown, below).
`0.1.31` was reserved and never published.
- **Bumped `duroxide-pg` dependency** — `0.1.35` → `0.1.36`. Under heavy load, an orchestration
fetched from PostgreSQL could come with a lock that had already expired. Every ack then failed
with `Invalid lock token`, and the message was retried for as long as the load lasted. The lock
now starts when the instance is claimed (migration `0025`). An SDK version built with
`duroxide-pg` 0.1.35 refuses a database that has this migration.
- **`runtime.shutdown(timeoutMs)` returns as soon as the runtime stops.** `timeoutMs` (default
1000) is now the longest it waits for in-flight work before cancelling the rest, not a fixed
delay. Shutdown also stops every task the runtime started, so no poller keeps a database
connection open after it returns.
- **A signal sent to a dropped `waitForEvent` is discarded.** When a `waitForEvent` loses a
`ctx.race()`, a signal already given to it is no longer handed to a later wait for the same
event. The later wait receives the next signal. Before, it could receive the stale value, fail
replay, or wait forever.
- **A duplicate start does not change a running instance.** `client.startOrchestration()` for
an instance that already has history is ignored; it can no longer replace the instance's
orchestration name, version or input. A sub-orchestration whose instance id collides with a
running instance now fails its parent, as it already did for a finished one.

### Fixed

- **A queue read that loses a race takes no message.** A drain loop that races
`ctx.dequeueEvent()` against a short timer could fail with
`nondeterministic: history schedule but no emitted action`, skip a message, or read one twice
when the timer won and a message arrived in the same batch. A dropped read is now cancelled
where it is dropped, so replay picks the same winners as the first run.
- **`continueAsNew` carries exactly the unread messages.** A dropped or still-pending read
counted as a consumer, so a message could be lost at continue-as-new. The runtime now carries
every message that no read returned (at most 100, as before).
- **Inputs that arrive while an instance continues as new are kept.** Queue messages, signals
and cancellation requests that arrived between two executions could be deleted. They now go to
the next execution.
- **`ctx.getKvAllKeys()` and `ctx.pruneKvValuesUpdatedBefore()` use key order.** Replay could
fail with `nondeterministic: kv clear mismatch` after a prune.

The queue, signal and continue-as-new changes apply to executions started by `duroxide` 0.1.32
or later. An execution started by an older runtime keeps the old behaviour until it completes or
continues as new. See the `duroxide` migration guide for details and rolling upgrades.

### Added

- `__tests__/races.test.js` — drain loops that race `ctx.dequeueEvent()` against a timer: every
message arrives once and in order, also across `continueAsNew`.
- `__tests__/kv_store.test.js` — KV keys are listed and pruned in key order, and a replayed prune
matches.

## [0.2.0] - 2026-10-04

### Changed
Expand Down
4 changes: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ edition = "2021"
crate-type = ["cdylib"]

[dependencies]
duroxide = { version = "0.1.30", features = ["sqlite"] }
duroxide-pg = "0.1.35"
duroxide = { version = "0.1.32", features = ["sqlite"] }
duroxide-pg = "0.1.36"
napi = { version = "2", features = ["async", "napi6", "serde-json"] }
napi-derive = "2"
async-trait = "0.1"
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ All scheduling methods return descriptors that must be **yielded**:
| `ctx.clearValue(key)` | Remove a single KV entry (no yield) |
| `ctx.clearAllValues()` | Remove all KV entries (no yield) |
| `ctx.getKvAllValues()` | Snapshot all KV entries for the current instance |
| `ctx.getKvAllKeys()` | List all KV keys for the current instance |
| `ctx.getKvAllKeys()` | List all KV keys for the current instance, in key order |
| `ctx.getKvLength()` | Count KV entries for the current instance |
| `ctx.pruneKvValuesUpdatedBefore(cutoffMs)` | Remove persisted KV entries older than a cutoff |
| `yield ctx.getValueFromInstance(instanceId, key)` | Read another instance's KV entry |
Expand Down
30 changes: 30 additions & 0 deletions __tests__/kv_store.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,36 @@ describe('kv store', () => {
});
});

it('kv keys are listed and pruned in key order, and the prune replays', async () => {
// duroxide 0.1.32 lists and prunes keys in key order (microsoft/duroxide#57). Before, both
// used hash order, which differs between replays, so the replay of a prune that cleared
// several keys failed with "nondeterministic: kv clear mismatch".
const keys = ['k7', 'k3', 'k5', 'k1', 'k8', 'k2', 'k6', 'k4'];
await withRuntime((runtime) => {
runtime.registerOrchestration('KvKeyOrder', function* (ctx) {
for (const key of keys) ctx.setValue(key, key);
const listed = ctx.getKvAllKeys();
// A cutoff after every write prunes all eight keys.
const pruned = ctx.pruneKvValuesUpdatedBefore(Number.MAX_SAFE_INTEGER);
// The next turn replays the writes and the prune, and must clear the keys in the
// order this turn recorded.
yield ctx.scheduleTimer(5);
return { listed, pruned, after: ctx.getKvAllKeys() };
});
}, async (client) => {
const instanceId = uid('kv-key-order');
await client.startOrchestration(instanceId, 'KvKeyOrder', null);

const status = await client.waitForOrchestration(instanceId, 5000);
assert.strictEqual(status.status, 'Completed', `orchestration failed: ${status.error}`);
assert.deepStrictEqual(status.output, {
listed: ['k1', 'k2', 'k3', 'k4', 'k5', 'k6', 'k7', 'k8'],
pruned: 8,
after: [],
});
});
});

it('kv bulk snapshot and prune', async () => {
await withRuntime((runtime) => {
runtime.registerOrchestration('KvBulk', function* (ctx, input) {
Expand Down
103 changes: 103 additions & 0 deletions __tests__/races.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -596,3 +596,106 @@ describe('type preservation', () => {
assert.strictEqual(typeof r3.output.value, 'object');
});
});

// ─── dequeueEvent raced against a timer ──────────────────────────
//
// A drain loop races a queue read against a short timer. When the timer wins, the read is
// dropped. Since duroxide 0.1.32 a dropped read is cancelled at the drop: it never takes a
// message, replay picks the same winners as the live run, and continueAsNew carries exactly
// the messages that no read returned. Before, a timer win followed by a message in the same
// batch could fail the instance with "nondeterministic: history schedule but no emitted
// action", skip a message, or lose one at continueAsNew.

function decodeMessage(value) {
return typeof value === 'string' ? JSON.parse(value) : value;
}

// Numbered messages with deterministic 0-30 ms gaps, a 100 ms pause after every 50th
// message so the timer also wins, and a final { done: true }.
async function produceNumbered(client, instanceId, count) {
for (let i = 0; i < count; i++) {
await client.enqueueEvent(instanceId, 'q', { i });
await new Promise((r) => setTimeout(r, i % 50 === 49 ? 100 : (i * 17 + 7) % 31));
}
await client.enqueueEvent(instanceId, 'q', { done: true });
}

describe('dequeueEvent raced against a timer', () => {
it('a read that loses to the timer takes no message (FIFO, exactly once)', async () => {
const count = 300;
const instanceId = uid('race-drain-loop');
const client = new Client(provider);
const runtime = new Runtime(provider, {
orchestrationConcurrency: 2,
workerConcurrency: 2,
dispatcherPollIntervalMs: 10,
});

runtime.registerOrchestration('RaceDrainLoop', function* (ctx) {
const seq = [];
let timers = 0;
while (true) {
const winner = yield ctx.race(ctx.dequeueEvent('q'), ctx.scheduleTimer(20));
if (winner.index === 1) {
timers++;
continue;
}
const message = decodeMessage(winner.value);
if (message.done) return { seq, timers };
seq.push(message.i);
}
});

await runtime.start();
try {
await client.startOrchestration(instanceId, 'RaceDrainLoop', null);
await produceNumbered(client, instanceId, count);
const result = await client.waitForOrchestration(instanceId, 60000);

assert.strictEqual(result.status, 'Completed', `orchestration failed: ${result.error}`);
assert.deepStrictEqual(result.output.seq, Array.from({ length: count }, (_, i) => i));
assert.ok(result.output.timers > 0, 'the timer never won, so no read was dropped');
} finally {
await runtime.shutdown(100);
}
});

it('continueAsNew after a lost read carries every unread message', async () => {
const count = 200;
const instanceId = uid('race-drain-can');
const client = new Client(provider);
const runtime = new Runtime(provider, {
orchestrationConcurrency: 2,
workerConcurrency: 2,
dispatcherPollIntervalMs: 10,
});

runtime.registerOrchestration('RaceDrainCan', function* (ctx, state) {
let next = state.next;
while (true) {
const winner = yield ctx.race(ctx.dequeueEvent('q'), ctx.scheduleTimer(10));
if (winner.index === 1) {
// The read lost and is dropped. Messages it did not take move to the next execution.
return yield ctx.continueAsNew({ next, cans: state.cans + 1 });
}
const message = decodeMessage(winner.value);
if (message.done) return { next, cans: state.cans };
if (message.i !== next) throw new Error(`expected message ${next}, got ${message.i}`);
next++;
}
});

await runtime.start();
try {
await client.startOrchestration(instanceId, 'RaceDrainCan', { next: 0, cans: 0 });
await produceNumbered(client, instanceId, count);
const result = await client.waitForOrchestration(instanceId, 60000);

assert.strictEqual(result.status, 'Completed', `orchestration failed: ${result.error}`);
assert.strictEqual(result.output.next, count);
assert.ok(result.output.cans > 0, 'the timer never won, so the instance never continued as new');
} finally {
await runtime.shutdown(100);
}
});
});
6 changes: 3 additions & 3 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -249,8 +249,8 @@ Release manifests resolve both Rust dependencies from crates.io. Keep
not ship `[patch.crates-io]` sections or inline `path =` overrides:

```toml
duroxide = { version = "0.1.30", features = ["sqlite"] }
duroxide-pg = "0.1.35"
duroxide = { version = "0.1.32", features = ["sqlite"] }
duroxide-pg = "0.1.36"
```

Temporary local path overrides may be useful during cross-repository
Expand Down Expand Up @@ -389,7 +389,7 @@ File-based SQLite can hit "database is locked" errors under concurrent orchestra

### Runtime Shutdown

`Runtime.shutdown(timeoutMs)` waits for the full timeout duration unconditionally (the Rust runtime doesn't short-circuit when dispatchers drain). Use a small timeout (e.g., 100ms) in tests.
`Runtime.shutdown(timeoutMs)` returns as soon as all runtime work has stopped. `timeoutMs` (default 1000) is the longest it waits for in-flight work before cancelling the rest; it is not a fixed delay.

### Platform-Specific Binary

Expand Down
3 changes: 3 additions & 0 deletions docs/user-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,9 @@ Key properties:
- **FIFO ordering** — messages delivered in enqueue order
- **Survives continue-as-new** — queue state persists across restarts
- **Blocks until available** — orchestration suspends until a message arrives
- **Safe to race** — `yield ctx.race(ctx.dequeueEvent('tasks'), ctx.scheduleTimer(ms))` drains
with a timeout. A read that loses the race takes no message, and `continueAsNew` carries every
message that no read returned (at most 100; the runtime drops the rest with a warning)

### Retry with Session Affinity

Expand Down
Loading