Skip to content

Commit 5d64169

Browse files
committed
Separate request identity
1 parent 8603127 commit 5d64169

13 files changed

Lines changed: 166 additions & 38 deletions

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
## Unreleased
44

5+
- Separate generated request identity from caller-supplied idempotency keys and
6+
expose both with enqueue time through immutable actor message context.
57
- Preserve isolated runtime ownership across operation, lifecycle, observable,
68
and payload callbacks while keeping lifecycle callbacks message-free.
79
- Retry transient SQLite writer acquisition with bounded capped backoff without

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,10 @@ await counter
265265

266266
Invocation options stay separate from actor arguments, so an actor may safely
267267
use argument names such as `timeoutMilliseconds` or `authorizationContext`.
268+
Every invocation receives a generated `requestId`; `idempotencyKey` remains the
269+
caller's deduplication key and is never reused as request identity. During an
270+
operation, `this.currentMessage` exposes both values along with `id`,
271+
`enqueuedAt`, `actorType`, `actorId`, `sequence`, and `attempt`.
268272

269273
Do not make a committed actor call or wait on a message from inside
270274
`database.transaction(...)` on the Solid Objects database. The runtime raises

docs/architecture.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@ persists JSON state between activations.
66

77
A durable message envelope selects an actor `operation` and records its
88
`delivery_mode` as `async`, `sync`, or `internal`. An operation is actor code;
9-
a message is the durable delivery record that invokes it.
9+
a message is the durable delivery record that invokes it. Each message has a
10+
generated request ID independent of its optional caller-supplied idempotency
11+
key. Matching idempotency keys are scoped to one actor and must identify the
12+
same operation, delivery mode, and arguments.
1013

1114
The correctness contract is:
1215

docs/parity.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ Reference: Ruby `solid_objects` 0.12.0 at commit `a01b6f5`.
2323
| ------------------------------------------------------------------------------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
2424
| Actor registry, durable identity, JSON state, and adjacent state migrations | Native | Ordinary classes, static actor types, inferred state, explicit migrations, and isolated runtime context across every actor-instance callback. |
2525
| Fluent committed calls and background delivery | Native | `await reference.operation()` and `reference.send.operation()`. |
26-
| Ordered mailbox, sequence allocation, idempotency, retries, dead letters, leases, renewal, and fenced commits | Native | Relational ready/claimed membership tables, durable history, and adapter-appropriate sequence locking. |
26+
| 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, and adapter-appropriate sequence locking. |
2727
| Domain rejection and strict poison ordering | Native | Rejections roll back without retry; retryable failures block later operations until completion or dead-lettering. |
2828
| 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. |
2929
| Bounded claim candidate scan | Native | A configurable ordered scan continues to another ready actor when a worker loses the first candidate's lease race. |

src/doctor.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ const EXPECTED_COLUMNS: Readonly<Record<string, readonly string[]>> = {
4444
messages: [
4545
"id",
4646
"request_id",
47+
"idempotency_key",
4748
"instance_id",
4849
"operation",
4950
"delivery_mode",
@@ -182,11 +183,11 @@ export class Doctor {
182183
message: `incompatible schema identity ${wrongIdentity.schema_identity}`,
183184
})
184185
}
185-
if (versions.join(",") !== "1,2") {
186+
if (versions.join(",") !== "1,2,3") {
186187
return check({
187188
name: "schema",
188189
status: "fail",
189-
message: `expected schema migrations 1, 2; found ${versions.join(", ")}`,
190+
message: `expected schema migrations 1, 2, 3; found ${versions.join(", ")}`,
190191
})
191192
}
192193
return check({

src/records.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export interface InstanceRow {
2929
export interface MessageRow {
3030
id: string
3131
request_id: string
32+
idempotency_key: string | null
3233
instance_id: string
3334
actor_type: string
3435
actor_id: string
@@ -42,6 +43,8 @@ export interface MessageRow {
4243
attempt_count: number | bigint
4344
max_attempts: number | bigint
4445
completed_at_ms: number | bigint | null
46+
created_at_ms: number | bigint
47+
updated_at_ms: number | bigint
4548
}
4649

4750
export interface DeadLetterRow {

src/repository.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -221,14 +221,15 @@ export class Repository {
221221
}
222222
if (!instance) throw new ActorDestroyed("actor disappeared during enqueue")
223223

224-
if (input.idempotencyKey) {
224+
if (input.idempotencyKey !== undefined) {
225225
const existing = await connection.get<MessageRow>(
226-
`SELECT * FROM ${this.table("messages")} WHERE actor_type = ? AND actor_id = ? AND request_id = ?`,
226+
`SELECT * FROM ${this.table("messages")} WHERE actor_type = ? AND actor_id = ? AND idempotency_key = ?`,
227227
[input.actorType, input.actorId, input.idempotencyKey],
228228
)
229229
if (existing) {
230230
if (
231231
existing.operation !== input.operation ||
232+
existing.delivery_mode !== input.deliveryMode ||
232233
existing.arguments !== JSON.stringify(input.arguments)
233234
) {
234235
throw new IdempotencyConflict("idempotency key identifies a different invocation")
@@ -253,20 +254,21 @@ export class Repository {
253254

254255
const sequence = BigInt(instance.next_message_sequence)
255256
const messageId = randomUUID()
256-
const requestId = input.idempotencyKey ?? randomUUID()
257+
const requestId = randomUUID()
257258
await connection.run(
258259
`UPDATE ${this.table("instances")} SET next_message_sequence = next_message_sequence + 1,
259260
updated_at_ms = ? WHERE id = ?`,
260261
[now, instance.id],
261262
)
262263
await connection.run(
263264
`INSERT INTO ${this.table("messages")}
264-
(id, request_id, instance_id, actor_type, actor_id, sequence, operation, delivery_mode,
265-
arguments, max_attempts, created_at_ms, updated_at_ms)
266-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
265+
(id, request_id, idempotency_key, instance_id, actor_type, actor_id, sequence, operation,
266+
delivery_mode, arguments, max_attempts, created_at_ms, updated_at_ms)
267+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
267268
[
268269
messageId,
269270
requestId,
271+
input.idempotencyKey ?? null,
270272
instance.id,
271273
input.actorType,
272274
input.actorId,

src/runtime.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1783,14 +1783,16 @@ function messageInstrumentation(message: MessageRow): JsonObject {
17831783
}
17841784

17851785
function actorMessageContext(message: MessageRow): MessageContext {
1786-
return {
1786+
return Object.freeze({
17871787
id: message.id,
17881788
requestId: message.request_id,
1789+
idempotencyKey: message.idempotency_key,
1790+
enqueuedAt: new Date(Number(message.created_at_ms)),
17891791
actorType: message.actor_type,
17901792
actorId: message.actor_id,
17911793
sequence: BigInt(message.sequence),
17921794
attempt: Number(message.attempt_count),
1793-
}
1795+
})
17941796
}
17951797

17961798
function restoreActorState(options: {

src/schema.ts

Lines changed: 54 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ import { UnsupportedDatabase } from "./errors.js"
22
import type { DatabaseConnection, DatabaseFamily } from "./database/types.js"
33

44
const BASE_VERSION = 1
5-
const LATEST_VERSION = 2
5+
const RETRY_LINK_VERSION = 2
6+
const MESSAGE_IDENTITY_VERSION = 3
7+
const LATEST_VERSION = MESSAGE_IDENTITY_VERSION
68

79
export async function installSchema(options: {
810
connection: DatabaseConnection
@@ -189,27 +191,61 @@ export async function installSchema(options: {
189191
[BASE_VERSION, schemaIdentity, now],
190192
)
191193

192-
const retryLinkMigration = installedMigrations.some(
193-
({ version }) => Number(version) === LATEST_VERSION,
194-
)
195-
if (retryLinkMigration) return
194+
const installedVersions = new Set(installedMigrations.map(({ version }) => Number(version)))
195+
if (!installedVersions.has(RETRY_LINK_VERSION)) {
196+
await connection.run(
197+
`ALTER TABLE ${table("dead_letters")} ADD COLUMN ${family === "postgresql" ? "IF NOT EXISTS " : ""}retried_message_id ${family === "mysql" ? "VARCHAR(255)" : "TEXT"}
198+
REFERENCES ${table("messages")}(id) ON DELETE SET NULL`,
199+
)
200+
await createIndex({
201+
connection,
202+
family,
203+
table: table("dead_letters"),
204+
name: `${prefix}dead_letters_retried_message`,
205+
columns: "retried_message_id",
206+
})
207+
await recordMigration({
208+
connection,
209+
table: table("schema_migrations"),
210+
version: RETRY_LINK_VERSION,
211+
schemaIdentity,
212+
})
213+
}
196214

215+
if (installedVersions.has(MESSAGE_IDENTITY_VERSION)) return
216+
await connection.run(
217+
`ALTER TABLE ${table("messages")} ADD COLUMN ${family === "postgresql" ? "IF NOT EXISTS " : ""}idempotency_key ${family === "mysql" ? "VARCHAR(255)" : "TEXT"}`,
218+
)
197219
await connection.run(
198-
`ALTER TABLE ${table("dead_letters")} ADD COLUMN ${family === "postgresql" ? "IF NOT EXISTS " : ""}retried_message_id ${family === "mysql" ? "VARCHAR(255)" : "TEXT"}
199-
REFERENCES ${table("messages")}(id) ON DELETE SET NULL`,
220+
`UPDATE ${table("messages")} SET idempotency_key = request_id WHERE idempotency_key IS NULL`,
200221
)
201222
await createIndex({
202223
connection,
203224
family,
204-
table: table("dead_letters"),
205-
name: `${prefix}dead_letters_retried_message`,
206-
columns: "retried_message_id",
225+
table: table("messages"),
226+
name: `${prefix}messages_idempotency`,
227+
columns: "actor_type, actor_id, idempotency_key",
228+
kind: "unique",
207229
})
208-
const migratedAt = await connection.nowMilliseconds()
209-
await connection.run(
210-
`INSERT INTO ${table("schema_migrations")}(version, schema_identity, installed_at_ms)
230+
await recordMigration({
231+
connection,
232+
table: table("schema_migrations"),
233+
version: MESSAGE_IDENTITY_VERSION,
234+
schemaIdentity,
235+
})
236+
}
237+
238+
async function recordMigration(options: {
239+
connection: DatabaseConnection
240+
table: string
241+
version: number
242+
schemaIdentity: string
243+
}): Promise<void> {
244+
const installedAt = await options.connection.nowMilliseconds()
245+
await options.connection.run(
246+
`INSERT INTO ${options.table}(version, schema_identity, installed_at_ms)
211247
VALUES (?, ?, ?) ON CONFLICT(version) DO NOTHING`,
212-
[LATEST_VERSION, schemaIdentity, migratedAt],
248+
[options.version, options.schemaIdentity, installedAt],
213249
)
214250
}
215251

@@ -233,10 +269,12 @@ async function createIndex(options: {
233269
table: string
234270
name: string
235271
columns: string
272+
kind?: "unique"
236273
}): Promise<void> {
274+
const kind = options.kind === "unique" ? "UNIQUE " : ""
237275
if (options.family !== "mysql") {
238276
await options.connection.run(
239-
`CREATE INDEX IF NOT EXISTS ${options.name} ON ${options.table}(${options.columns})`,
277+
`CREATE ${kind}INDEX IF NOT EXISTS ${options.name} ON ${options.table}(${options.columns})`,
240278
)
241279
return
242280
}
@@ -247,6 +285,6 @@ async function createIndex(options: {
247285
)
248286
if (Number(existing?.present ?? 0) > 0) return
249287
await options.connection.run(
250-
`CREATE INDEX ${options.name} ON ${options.table}(${options.columns})`,
288+
`CREATE ${kind}INDEX ${options.name} ON ${options.table}(${options.columns})`,
251289
)
252290
}

src/types.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -37,12 +37,14 @@ export interface AdministrationOptions<AuthorizationContext = unknown> {
3737
}
3838

3939
export interface MessageContext {
40-
id: string
41-
requestId: string
42-
actorType: string
43-
actorId: string
44-
sequence: bigint
45-
attempt: number
40+
readonly id: string
41+
readonly requestId: string
42+
readonly idempotencyKey: string | null
43+
readonly enqueuedAt: Date
44+
readonly actorType: string
45+
readonly actorId: string
46+
readonly sequence: bigint
47+
readonly attempt: number
4648
}
4749

4850
export interface EffectContext {

0 commit comments

Comments
 (0)