Skip to content

Commit 4b818e3

Browse files
committed
fix: reject sync inside transactions
1 parent 6755751 commit 4b818e3

16 files changed

Lines changed: 269 additions & 74 deletions

‎CHANGELOG.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@
5454
PostgreSQL, and MySQL; distinguish an uncommitted enqueue timeout from a
5555
recoverable durable wait timeout; and report structured database, activation,
5656
and mailbox blockers.
57+
- Reject committed calls and message waits inside an ambient Solid Objects
58+
database transaction before they can self-deadlock.
5759

5860
## 0.1.0 - 2026-08-13
5961

‎README.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,13 @@ await counter
252252
Invocation options stay separate from actor arguments, so an actor may safely
253253
use argument names such as `timeoutMilliseconds` or `authorizationContext`.
254254

255+
Do not make a committed actor call or wait on a message from inside
256+
`database.transaction(...)` on the Solid Objects database. The runtime raises
257+
`SyncInsideTransaction` before enqueue or waiting, avoiding a self-deadlock on
258+
the transaction's checked-out connection. Send background work outside the
259+
transaction, or let the actor coordinate same-database changes through a commit
260+
action.
261+
255262
## Send background work without a queue service
256263

257264
Use the typed `send` dispatcher when the caller should not wait for execution:

‎docs/architecture.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,11 @@ Already-running JavaScript actor code is cooperative rather than forcefully
4747
preempted; leases and fenced commits remain authoritative if it outlives the
4848
caller's wait.
4949

50+
Each database adapter also tracks its active transaction through Node's async
51+
context. A committed call or message wait fails before enqueue or polling when
52+
the same logical call stack already owns a Solid Objects transaction, rather
53+
than waiting for a connection or serialized SQLite slot it cannot release.
54+
5055
PostgreSQL notifications are an opt-in latency layer. One event-driven client
5156
per runtime listens on role-specific channels before the worker checks durable
5257
state, which closes the listener-startup race without Ruby's connection per

‎docs/operations.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,12 @@ forcefully terminated. An actor operation that has started may therefore
6161
finish after the caller's timeout; durable leases and fenced commits remain the
6262
correctness boundary.
6363

64+
Committed calls and `message.wait()` fail with `SyncInsideTransaction` when
65+
invoked inside `database.transaction(...)` on the configured Solid Objects
66+
adapter. The check happens before enqueue for direct calls. This prevents the
67+
caller from waiting on a pool connection or SQLite access slot that its own
68+
ambient transaction still holds.
69+
6470
Authorized operators can inspect terminal actor failures with
6571
`runtime.deadLetters.all()` and retry one with `runtime.deadLetters.retry()`.
6672
Retry is idempotent per dead letter: the record retains the replacement message

‎docs/parity.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ Reference: Ruby `solid_objects` 0.12.0 at commit `a01b6f5`.
3131
| Actor-to-actor delivery | Native | `sendTo(reference).operation()` stages delivery in the source actor commit. |
3232
| One-shot and recurring reminders | Native | Scheduling, replacement events, catch-up policy, stale-claim recovery, pausing, authorized inspection, and idempotent resume are implemented. |
3333
| Same-database commit actions | Native | Registered actions receive the fenced transaction connection. |
34+
| Ambient transaction rejection | Native | Committed calls and message waits fail before blocking when the current async context already owns a transaction on the Solid Objects adapter. |
3435
| Direct application-write isolation during actor code | Partial | `guardApplicationDatabase()` fails closed for operations, projections, and migrations, while registered commit actions remain writable. Unwrapped ORM pools and third-party clients cannot be intercepted. |
3536
| Committed snapshots | Native | `snapshot()` returns authorized committed state; realtime replay reads the explicit observable projection with instance ID and revision without creating mailbox history. |
3637
| Actor destruction and incarnation fencing | Native | Authorized cascading deletion creates a fresh instance ID on recreation. |

‎src/application-database.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ class GuardedApplicationDatabase implements Database {
1515
this.schemaIdentity = database.schemaIdentity
1616
}
1717

18+
transactionActive(): boolean {
19+
return this.database.transactionActive?.() ?? false
20+
}
21+
1822
connection<Result>(
1923
callback: (connection: DatabaseConnection) => Promise<Result>,
2024
): Promise<Result> {

‎src/database/mysql.ts‎

Lines changed: 42 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
requireDatabaseDeadlineRemaining,
1414
} from "./deadline.js"
1515
import { DatabaseDeadlineExceeded } from "../errors.js"
16+
import { databaseTransactionActive, withDatabaseTransaction } from "./transaction-context.js"
1617

1718
export interface MySQLDatabaseOptions {
1819
connectionString: string
@@ -151,42 +152,48 @@ export class MySQLDatabase implements Database {
151152
async transaction<Result>(
152153
callback: (connection: DatabaseConnection) => Promise<Result>,
153154
): Promise<Result> {
154-
const deadlineActive = databaseDeadlineRemainingMilliseconds() !== undefined
155-
const connection = await acquireBeforeDatabaseDeadline(
156-
this.pool.getConnection(),
157-
(lateConnection) => lateConnection.release(),
158-
)
159-
try {
160-
await connection.beginTransaction()
161-
const remaining = requireDatabaseDeadlineRemaining()
162-
if (remaining !== undefined) {
163-
await connection.query("SET SESSION innodb_lock_wait_timeout = ?", [
164-
Math.max(Math.ceil(remaining / 1_000), 1),
165-
])
166-
await connection.query("SET SESSION max_execution_time = ?", [Math.max(remaining, 1)])
155+
return withDatabaseTransaction(this, async () => {
156+
const deadlineActive = databaseDeadlineRemainingMilliseconds() !== undefined
157+
const connection = await acquireBeforeDatabaseDeadline(
158+
this.pool.getConnection(),
159+
(lateConnection) => lateConnection.release(),
160+
)
161+
try {
162+
await connection.beginTransaction()
163+
const remaining = requireDatabaseDeadlineRemaining()
164+
if (remaining !== undefined) {
165+
await connection.query("SET SESSION innodb_lock_wait_timeout = ?", [
166+
Math.max(Math.ceil(remaining / 1_000), 1),
167+
])
168+
await connection.query("SET SESSION max_execution_time = ?", [Math.max(remaining, 1)])
169+
}
170+
const result = await callback(new MySQLConnection(connection))
171+
requireDatabaseDeadlineRemaining()
172+
await connection.commit()
173+
return result
174+
} catch (error) {
175+
await connection.rollback().catch(() => undefined)
176+
if (
177+
error instanceof DatabaseDeadlineExceeded ||
178+
(deadlineActive && mysqlDeadlineError(error))
179+
) {
180+
throw databaseDeadlineError(error)
181+
}
182+
throw error
183+
} finally {
184+
if (deadlineActive) {
185+
await connection
186+
.query("SET SESSION innodb_lock_wait_timeout = DEFAULT")
187+
.catch(() => undefined)
188+
await connection.query("SET SESSION max_execution_time = DEFAULT").catch(() => undefined)
189+
}
190+
connection.release()
167191
}
168-
const result = await callback(new MySQLConnection(connection))
169-
requireDatabaseDeadlineRemaining()
170-
await connection.commit()
171-
return result
172-
} catch (error) {
173-
await connection.rollback().catch(() => undefined)
174-
if (
175-
error instanceof DatabaseDeadlineExceeded ||
176-
(deadlineActive && mysqlDeadlineError(error))
177-
) {
178-
throw databaseDeadlineError(error)
179-
}
180-
throw error
181-
} finally {
182-
if (deadlineActive) {
183-
await connection
184-
.query("SET SESSION innodb_lock_wait_timeout = DEFAULT")
185-
.catch(() => undefined)
186-
await connection.query("SET SESSION max_execution_time = DEFAULT").catch(() => undefined)
187-
}
188-
connection.release()
189-
}
192+
})
193+
}
194+
195+
transactionActive(): boolean {
196+
return databaseTransactionActive(this)
190197
}
191198

192199
async close(): Promise<void> {

‎src/database/postgresql.ts‎

Lines changed: 32 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
requireDatabaseDeadlineRemaining,
1010
} from "./deadline.js"
1111
import { DatabaseDeadlineExceeded } from "../errors.js"
12+
import { databaseTransactionActive, withDatabaseTransaction } from "./transaction-context.js"
1213

1314
export {
1415
PostgreSQLWakeUpAdapter,
@@ -152,32 +153,38 @@ export class PostgreSQLDatabase implements Database {
152153
async transaction<Result>(
153154
callback: (connection: DatabaseConnection) => Promise<Result>,
154155
): Promise<Result> {
155-
const deadlineActive = databaseDeadlineRemainingMilliseconds() !== undefined
156-
const client = await acquireBeforeDatabaseDeadline(this.pool.connect(), (lateClient) =>
157-
lateClient.release(),
158-
)
159-
try {
160-
await client.query("BEGIN")
161-
const remaining = requireDatabaseDeadlineRemaining()
162-
if (remaining !== undefined) {
163-
await applyPostgreSQLDeadline({ client, milliseconds: remaining, scope: "transaction" })
156+
return withDatabaseTransaction(this, async () => {
157+
const deadlineActive = databaseDeadlineRemainingMilliseconds() !== undefined
158+
const client = await acquireBeforeDatabaseDeadline(this.pool.connect(), (lateClient) =>
159+
lateClient.release(),
160+
)
161+
try {
162+
await client.query("BEGIN")
163+
const remaining = requireDatabaseDeadlineRemaining()
164+
if (remaining !== undefined) {
165+
await applyPostgreSQLDeadline({ client, milliseconds: remaining, scope: "transaction" })
166+
}
167+
const result = await callback(new PostgreSQLConnection(client))
168+
requireDatabaseDeadlineRemaining()
169+
await client.query("COMMIT")
170+
return result
171+
} catch (error) {
172+
await client.query("ROLLBACK").catch(() => undefined)
173+
if (
174+
error instanceof DatabaseDeadlineExceeded ||
175+
(deadlineActive && postgresqlDeadlineError(error))
176+
) {
177+
throw databaseDeadlineError(error)
178+
}
179+
throw error
180+
} finally {
181+
client.release()
164182
}
165-
const result = await callback(new PostgreSQLConnection(client))
166-
requireDatabaseDeadlineRemaining()
167-
await client.query("COMMIT")
168-
return result
169-
} catch (error) {
170-
await client.query("ROLLBACK").catch(() => undefined)
171-
if (
172-
error instanceof DatabaseDeadlineExceeded ||
173-
(deadlineActive && postgresqlDeadlineError(error))
174-
) {
175-
throw databaseDeadlineError(error)
176-
}
177-
throw error
178-
} finally {
179-
client.release()
180-
}
183+
})
184+
}
185+
186+
transactionActive(): boolean {
187+
return databaseTransactionActive(this)
181188
}
182189

183190
async close(): Promise<void> {

‎src/database/sqlite.ts‎

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { DatabaseSync } from "node:sqlite"
22
import type { Database, DatabaseConnection, RunResult } from "./types.js"
33
import { databaseDeadlineError, requireDatabaseDeadlineRemaining } from "./deadline.js"
44
import { DatabaseDeadlineExceeded } from "../errors.js"
5+
import { databaseTransactionActive, withDatabaseTransaction } from "./transaction-context.js"
56

67
export interface SQLiteDatabaseOptions {
78
path: string
@@ -69,17 +70,23 @@ export class SQLiteDatabase implements Database {
6970
async transaction<Result>(
7071
callback: (connection: DatabaseConnection) => Promise<Result>,
7172
): Promise<Result> {
72-
return this.withAccess(async () => {
73-
this.database.exec("BEGIN IMMEDIATE")
74-
try {
75-
const result = await callback(this.databaseConnection)
76-
this.database.exec("COMMIT")
77-
return result
78-
} catch (error) {
79-
this.database.exec("ROLLBACK")
80-
throw error
81-
}
82-
})
73+
return withDatabaseTransaction(this, () =>
74+
this.withAccess(async () => {
75+
this.database.exec("BEGIN IMMEDIATE")
76+
try {
77+
const result = await callback(this.databaseConnection)
78+
this.database.exec("COMMIT")
79+
return result
80+
} catch (error) {
81+
this.database.exec("ROLLBACK")
82+
throw error
83+
}
84+
}),
85+
)
86+
}
87+
88+
transactionActive(): boolean {
89+
return databaseTransactionActive(this)
8390
}
8491

8592
async close(): Promise<void> {
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { AsyncLocalStorage } from "node:async_hooks"
2+
3+
interface TransactionScope {
4+
database: object
5+
active: boolean
6+
}
7+
8+
const transactionScopes = new AsyncLocalStorage<readonly TransactionScope[]>()
9+
10+
export function withDatabaseTransaction<Result>(
11+
database: object,
12+
operation: () => Promise<Result>,
13+
): Promise<Result> {
14+
const scope: TransactionScope = { database, active: true }
15+
const scopes = [...(transactionScopes.getStore() ?? []), scope]
16+
return transactionScopes.run(scopes, async () => {
17+
try {
18+
return await operation()
19+
} finally {
20+
scope.active = false
21+
}
22+
})
23+
}
24+
25+
export function databaseTransactionActive(database: object): boolean {
26+
return (
27+
transactionScopes.getStore()?.some((scope) => scope.database === database && scope.active) ??
28+
false
29+
)
30+
}

0 commit comments

Comments
 (0)