Skip to content

Commit dab286e

Browse files
committed
fix: Preserve transmit order across runtimes
Persist effect positions within each source message and preserve legacy rows during schema upgrades. Reject null transmit arguments and refresh the parity contract for message reads, administration, and wake-up behavior.
1 parent 7ede319 commit dab286e

11 files changed

Lines changed: 221 additions & 43 deletions

File tree

‎CHANGELOG.md‎

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

33
## Unreleased
44

5+
- Preserve transmit staging order within a source message through a persisted
6+
effect position, including retries. Schema migration 14 adds `effects.position`;
7+
run `runtime.install()` before starting upgraded workers. Legacy rows retain
8+
their existing ID tie-break because their original order cannot be recovered.
9+
- Reject explicit null transmit arguments, matching Ruby; omission still defaults
10+
to an empty object. Expand the shared wire fixtures and adapter ordering tests.
11+
- Refresh the parity ledger for authorized message reads and retained background
12+
results, bulk redrive/audit records, reminder cancellation, and automatic wake-up.
13+
514
- Patch the Cloudflare development tooling's Undici dependency to 7.29.1 to resolve the high-severity WebSocket and TLS advisories reported by CI.
615

716
- Add portable observability envelopes, metric definitions, actor observers, and bounded authorization-aware diagnostics across SQL runtimes and the Durable Objects host. Isolate failing instrumentation and error loggers from actor work.

‎compatibility/transmit-envelopes.json‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,16 @@
8484
"operation": "increment",
8585
"arguments": [1]
8686
}
87+
},
88+
{
89+
"name": "null arguments",
90+
"envelope": {
91+
"effectId": "fixture-effect-0008",
92+
"actorType": "transmit-counters",
93+
"actorId": "fixture-counter",
94+
"operation": "increment",
95+
"arguments": null
96+
}
8797
}
8898
]
8999
}

‎docs/api.md‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ envelope. The envelope carries its name in `invalidations`. A component registry
105105
can then refresh a reauthorized endpoint, and the value stays private.
106106

107107
`MessageReference` does not retain an invocation's authorization context.
108-
Supply `authorizationContext` to each `status()`, `result()`, and `wait()` call;
108+
Supply `authorizationContext` to each `status()`, `result()`, `outcome()`, and `wait()` call;
109109
the runtime reauthorizes the persisted operation every time. Durable results
110110
are JSON, so an operation that returns `undefined` or is declared `void`
111111
resolves as `null`.
@@ -904,6 +904,11 @@ open SAH pool otherwise blocks the next candidate until the worker dies.
904904

905905
## `solid-objects/transmit`
906906

907+
Schema migration 14 persists an effect's staging position within its source message.
908+
Run `runtime.install()` before upgraded workers start. New effects preserve staging
909+
order even when two transmits originate in one turn. Legacy rows use position zero
910+
and their existing ID tie-break; their original order cannot be recovered.
911+
907912
The transactional outbox bridge between a local runtime and a server
908913
runtime. An actor stages a transmit intent with `this.transmit()`
909914
in the same transaction as its state change. The effect worker drains the
@@ -926,7 +931,7 @@ outbox with at-least-once delivery, per-actor order, and retry backoff.
926931
`runtime.deadLetters.retry` re-queues it.
927932
- `receiveTransmitEnvelope(options)`: idempotent server ingest. `arguments`
928933
is optional in the envelope and defaults to an empty object, matching the
929-
staging side and the Ruby ingest. It enqueues an
934+
staging side and the Ruby ingest. Explicit `null` and arrays are invalid. It enqueues an
930935
internal message with `transmit:<effectId>` as the idempotency key, so a
931936
replayed envelope applies once. The host must authenticate the sender
932937
before this call; internal delivery skips `authorizeMessage`. The call

‎docs/observability.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@ Events describe local observations. Concurrent deletion, crashes, and failed
4343
exporters can omit events. Never infer exactly-once delivery from event counts.
4444
Additional existing runtime events retain their names.
4545

46+
Portable `polling.interval_changed` events carry `previousIntervalMilliseconds`,
47+
`currentIntervalMilliseconds`, and a string `reason`. Ruby converts its native
48+
second-based notification values while preserving the original notification.
49+
4650
## Metrics and tracing
4751

4852
Metrics are sample descriptions. Exporting them is opt-in: the runtime does not

‎docs/parity.md‎

Lines changed: 34 additions & 27 deletions
Large diffs are not rendered by default.

‎package.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -115,8 +115,8 @@
115115
"test": "vitest run",
116116
"test:browser": "pnpm run build && playwright test",
117117
"test:coverage": "vitest run --coverage",
118-
"test:postgresql": "vitest run test/postgresql.test.ts test/effect-recovery.test.ts test/instance-retention.test.ts",
119-
"test:mysql": "vitest run test/mysql.test.ts test/effect-recovery.test.ts test/instance-retention.test.ts",
118+
"test:postgresql": "vitest run test/postgresql.test.ts test/effect-recovery.test.ts test/instance-retention.test.ts test/transmit-ordering.test.ts",
119+
"test:mysql": "vitest run test/mysql.test.ts test/effect-recovery.test.ts test/instance-retention.test.ts test/transmit-ordering.test.ts",
120120
"test:package": "node scripts/release-artifact-smoke.mjs",
121121
"test:recovery": "pnpm run build && node examples/failure-recovery/demo.ts",
122122
"test:at-least-once": "pnpm run build && node examples/at-least-once/demo.ts",

‎src/doctor.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ const EXPECTED_COLUMNS: Readonly<Record<string, readonly string[]>> = {
7878
"claimed_at_ms",
7979
],
8080
reminders: ["id", "instance_id", "operation", "message_operation", "run_at_ms", "status"],
81-
effects: ["id", "message_id", "instance_id", "name", "status", "available_at_ms"],
81+
effects: ["id", "message_id", "instance_id", "name", "status", "available_at_ms", "position"],
8282
effect_recoveries: [
8383
"effect_id",
8484
"instance_id",

‎src/repository.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -731,13 +731,13 @@ export class Repository {
731731
turn.message.id,
732732
])
733733

734-
for (const effect of input.intents.effects) {
734+
for (const [position, effect] of input.intents.effects.entries()) {
735735
const effectId = effect.id ?? randomUUID()
736736
await connection.run(
737737
`INSERT INTO ${this.table("effects")}
738738
(id, message_id, instance_id, name, arguments, success_operation, failure_operation,
739-
status, max_attempts, available_at_ms)
740-
VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?)`,
739+
status, max_attempts, available_at_ms, position)
740+
VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?)`,
741741
[
742742
effectId,
743743
turn.message.id,
@@ -748,6 +748,7 @@ export class Repository {
748748
effect.failureOperation ?? null,
749749
this.settings.maxAttempts,
750750
now,
751+
position,
751752
],
752753
)
753754
if (effect.recoveryOperation !== undefined || effect.statusOperation !== undefined) {

‎src/schema.ts‎

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ const INSTANCE_RETENTION_INDEX_VERSION = 10
1414
const DEAD_LETTER_REDRIVE_VERSION = 11
1515
const REQUEST_ID_LOOKUP_VERSION = 12
1616
const REMEMBERED_KEYS_VERSION = 13
17-
const LATEST_VERSION = REMEMBERED_KEYS_VERSION
17+
const EFFECT_POSITION_VERSION = 14
18+
const LATEST_VERSION = EFFECT_POSITION_VERSION
1819

1920
export const SCHEMA_VERSIONS: readonly number[] = Object.freeze(
2021
Array.from({ length: LATEST_VERSION }, (_unused, index) => index + 1),
@@ -390,6 +391,16 @@ export async function installSchema(options: {
390391
})
391392
}
392393

394+
if (!installedVersions.has(EFFECT_POSITION_VERSION)) {
395+
await addEffectPosition({ connection, family, table: table("effects") })
396+
await recordMigration({
397+
connection,
398+
table: table("schema_migrations"),
399+
version: EFFECT_POSITION_VERSION,
400+
schemaIdentity,
401+
})
402+
}
403+
393404
if (installedVersions.has(POLLING_INDEXES_VERSION)) return
394405
const pollingIndexes = [
395406
["effects", `${prefix}effects_poll`, "status, available_at_ms, id"],
@@ -471,12 +482,23 @@ async function installRedrive(options: {
471482
})
472483
}
473484

485+
async function addEffectPosition(options: {
486+
connection: DatabaseConnection
487+
family: DatabaseFamily
488+
table: string
489+
}): Promise<void> {
490+
if (await hasColumn({ ...options, column: "position" })) return
491+
await options.connection.run(
492+
`ALTER TABLE ${options.table} ADD COLUMN position INTEGER NOT NULL DEFAULT 0`,
493+
)
494+
}
495+
474496
async function addFailedAt(options: {
475497
connection: DatabaseConnection
476498
family: DatabaseFamily
477499
table: string
478500
}): Promise<void> {
479-
if (await hasFailedAt(options)) return
501+
if (await hasColumn({ ...options, column: "failed_at_ms" })) return
480502

481503
const type = options.family === "sqlite" ? "INTEGER" : "BIGINT"
482504
await options.connection.run(`ALTER TABLE ${options.table} ADD COLUMN failed_at_ms ${type}`)
@@ -485,22 +507,23 @@ async function addFailedAt(options: {
485507
)
486508
}
487509

488-
async function hasFailedAt(options: {
510+
async function hasColumn(options: {
489511
connection: DatabaseConnection
490512
family: DatabaseFamily
491513
table: string
514+
column: string
492515
}): Promise<boolean> {
493516
if (options.family === "sqlite") {
494517
const columns = await options.connection.all<{ name: string }>(
495518
`PRAGMA table_info(${options.table})`,
496519
)
497-
return columns.some(({ name }) => name === "failed_at_ms")
520+
return columns.some(({ name }) => name === options.column)
498521
}
499522
const schema = options.family === "postgresql" ? "current_schema()" : "DATABASE()"
500523
const found = await options.connection.get<{ found: number | bigint }>(
501524
`SELECT COUNT(*) AS found FROM information_schema.columns
502-
WHERE table_schema = ${schema} AND table_name = ? AND column_name = 'failed_at_ms'`,
503-
[options.table],
525+
WHERE table_schema = ${schema} AND table_name = ? AND column_name = ?`,
526+
[options.table, options.column],
504527
)
505528
return Number(found?.found ?? 0) > 0
506529
}

‎src/transmit.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ export async function receiveTransmitEnvelope(options: {
5050
throw new InvalidPayload(`transmit envelope requires a non-empty ${field}`)
5151
}
5252
}
53-
const argumentsValue = envelope.arguments ?? {}
53+
const argumentsValue = envelope.arguments === undefined ? {} : envelope.arguments
5454
if (!isJsonObject(argumentsValue)) {
5555
throw new InvalidPayload("transmit envelope arguments must be a JSON object")
5656
}
@@ -115,7 +115,7 @@ async function undeliveredEnvelopesThrough(input: {
115115
AND messages.actor_type = ?
116116
AND messages.actor_id = ?
117117
AND messages.sequence <= (SELECT sequence FROM ${messages} WHERE id = ?)
118-
ORDER BY messages.sequence, effects.id`,
118+
ORDER BY messages.sequence, effects.position, effects.id`,
119119
[effectName, context.actorType, context.actorId, context.sourceMessageId],
120120
),
121121
)

0 commit comments

Comments
 (0)