Skip to content

Commit 666dc6c

Browse files
authored
Merge pull request #72 from cardmagic/fix/activation-lease-recovery
Fix lease renewal and expired-claim recovery
2 parents 84cebd6 + 7e98084 commit 666dc6c

7 files changed

Lines changed: 274 additions & 13 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,18 @@
11
# Changelog
22

3-
## Unreleased
4-
3+
## 0.17.7 - 2026-10-12
4+
5+
- Renew retained activations between short turns and across worker passes,
6+
even when synchronous work prevents renewal timers from running.
7+
- Keep an activation with an outstanding message claim discoverable after
8+
lease loss. The next worker pass recovers the message in mailbox order
9+
without a process restart, including with `maxAttempts: 1`. Reclaiming
10+
increments `attempt_count`; lease loss alone does not create a dead letter.
11+
- Run activation lease regressions in PostgreSQL and MySQL CI, reusing and
12+
resetting their test tables so repeated runs leave no messages or extra tables.
13+
Control the server-database test clocks to avoid runner scheduling delays;
14+
SQLite keeps real synchronous waits with a 200 ms lease.
15+
Allow 15 seconds for the 34-turn test's database round trips in CI.
516
- Add `scripts/check-compatibility-fixtures.mjs` and a workflow with the name
617
`Compatibility fixtures`. The script compares the `*.json` files in two
718
directories by name and by bytes. It fails with one line for each file that
@@ -10,9 +21,8 @@
1021
of solid-objects-ruby at `main`. It runs on each push to `main`, one time
1122
each day, and on demand. Before this change, no job compared the shared
1223
fixtures.
13-
- `docs/parity.md` now cites Ruby `solid_objects` 0.17.3. That Ruby release
14-
adds guides, tests, and one development dependency, and does not change the
15-
runtime, so the ledger has no new row.
24+
- Track Ruby `solid_objects` 0.17.4 in the parity ledger, including renewal
25+
between short turns and preservation of activation fences for claimed messages.
1626

1727
## 0.17.6 - 2026-10-09
1828

‎docs/parity.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ This ledger tracks capability parity with the Ruby `solid_objects` gem.
44
Parity preserves a capability and its correctness or security boundary. It does
55
not copy a Rails API into Node.
66

7-
Reference: Ruby `solid_objects` 0.17.3. The JavaScript package began at the Ruby
7+
Reference: Ruby `solid_objects` 0.17.4. The JavaScript package began at the Ruby
88
design's `0.12` capability generation; that version number did not imply earlier
99
JavaScript releases.
1010

@@ -54,7 +54,7 @@ is needed for the JavaScript stale-result race fix.
5454
| Query and projection purity | Native | Both runtimes reject state mutation and staged effects, recovery checks, commit actions, reminders, or outbound messages in queries, observable projections, and personalized payloads. Projection guards compare the complete staged work, including same-count replacements. Query and observable violations fail terminally with `QueryMutatedState`; payload violations fail only that payload. Snapshot projection reads enforce the same rule. |
5555
| JSON property preservation | Native | Reserved property names such as `__proto__`, `constructor`, and `prototype` remain ordinary data, including through size checks and immutable copies. Both suites consume `compatibility/json-values.json`. |
5656
| Fluent committed calls and background delivery | Native | `await reference.operation()` and `reference.send.operation()`. |
57-
| Ordered mailbox, sequence allocation, idempotency, retries, dead letters, leases, renewal, and fenced commits | Native | Relational ready/claimed membership tables, distinct generated request IDs and caller idempotency keys, durable history, adapter-appropriate sequence locking, and PostgreSQL/MySQL row locks held from fence validation through commit. |
57+
| Ordered mailbox, sequence allocation, idempotency, retries, dead letters, leases, renewal, and fenced commits | Native | Both runtimes renew between turns when due and retain the fence while a claim remains, allowing expired-turn recovery in mailbox order even with one allowed attempt. Reclaiming increments the attempt count; lease loss alone does not dead-letter a message. Relational ready/claimed membership tables, distinct generated request IDs and caller idempotency keys, durable history, adapter-appropriate sequence locking, and PostgreSQL/MySQL row locks held from fence validation through commit. |
5858
| Domain rejection and strict poison ordering | Native | Rejections accept JavaScript identifier-style codes and roll back without retry; invalid codes fail terminally, while retryable failures block later operations until completion or dead-lettering. |
5959
| Bounded activation passes and hot-actor fairness | Native | Configurable turn-count and elapsed-time budgets bound each pass, then move only that actor's already-due memberships behind actors already waiting. |
6060
| Bounded claim candidate scan | Native | A configurable ordered scan continues to another ready actor when a worker loses the first candidate's lease race. |

‎package.json‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "solid-objects",
3-
"version": "0.17.6",
3+
"version": "0.17.7",
44
"description": "SQL-backed virtual actor library for TypeScript and Node.js, with durable state, ordered operations, and automatic activation on SQLite, PostgreSQL, or MySQL",
55
"type": "module",
66
"license": "MIT",
@@ -118,8 +118,8 @@
118118
"test": "vitest run",
119119
"test:browser": "pnpm run build && playwright test",
120120
"test:coverage": "vitest run --coverage",
121-
"test:postgresql": "vitest run test/postgresql.test.ts test/effect-recovery.test.ts test/instance-retention.test.ts test/transmit-ordering.test.ts",
122-
"test:mysql": "vitest run test/mysql.test.ts test/effect-recovery.test.ts test/instance-retention.test.ts test/transmit-ordering.test.ts",
121+
"test:postgresql": "vitest run test/postgresql.test.ts test/activation-lease.test.ts test/effect-recovery.test.ts test/instance-retention.test.ts test/transmit-ordering.test.ts",
122+
"test:mysql": "vitest run test/mysql.test.ts test/activation-lease.test.ts test/effect-recovery.test.ts test/instance-retention.test.ts test/transmit-ordering.test.ts",
123123
"test:package": "node scripts/release-artifact-smoke.mjs",
124124
"test:recovery": "pnpm run build && node examples/failure-recovery/demo.ts",
125125
"test:at-least-once": "pnpm run build && node examples/at-least-once/demo.ts",

‎src/repository.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -651,13 +651,18 @@ export class Repository {
651651
SET activation_owner_id = NULL, activation_token = NULL,
652652
activation_expires_at_ms = NULL, updated_at_ms = ?
653653
WHERE id = ? AND activation_owner_id = ? AND activation_token = ?
654-
AND activation_generation = ?`,
654+
AND activation_generation = ?
655+
AND NOT EXISTS (
656+
SELECT 1 FROM ${this.table("claimed_messages")} claimed
657+
WHERE claimed.instance_id = ?
658+
)`,
655659
[
656660
now,
657661
activation.instanceId,
658662
activation.processId,
659663
activation.activationToken,
660664
activation.activationGeneration,
665+
activation.instanceId,
661666
],
662667
)
663668
})

‎src/version.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
export const VERSION = "0.17.6"
1+
export const VERSION = "0.17.7"

‎src/worker.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,14 +87,17 @@ export class Worker {
8787
actor: execution.actor,
8888
turn,
8989
lastUsedAt: now,
90-
renewAt: now + this.runtime.settings.leaseRenewalIntervalMilliseconds,
90+
renewAt:
91+
cached?.renewAt ??
92+
passStartedAt + this.runtime.settings.leaseRenewalIntervalMilliseconds,
9193
}
9294
this.activations.set(turn.instance.id, cached)
9395
passExhausted =
9496
processed >= this.runtime.settings.maxMessagesPerActivationPass ||
9597
performance.now() - passStartedAt >=
9698
this.runtime.settings.maxActivationDurationMilliseconds
9799
if (passExhausted) break
100+
await this.maintainCachedActivations()
98101
turn = await this.runtime.repository.claim(this.processId, {
99102
activation: activationLease(cached.turn),
100103
})

0 commit comments

Comments
 (0)