fix: ADR 0050 callback-error precedence, and the honest limits of transaction control
CI / verify (pull_request) Successful in 1m4s
CI / infrastructure-smoke (pull_request) Successful in 38s
CI / migration-smoke (pull_request) Successful in 1m54s
CI / image-build (pull_request) Successful in 22s
CI / deploy-smoke (same-origin) (pull_request) Successful in 51s
CI / deploy-smoke (split) (pull_request) Successful in 1m37s

Narrow contract and implementation adjustments from review. No SDK, manifest,
packaging, or Signage work.

Callback-error precedence:
- A transaction or savepoint callback that rejects keeps its own error, even
  while sibling work it started is still settling (one failed half of a
  Promise.all). Core drains that work, rolls back, and never commits or
  releases.
- A callback that resolves with work outstanding (a forgotten await) still
  fails with MODULE_DATABASE_SCOPE_WORK_OUTSTANDING and rolls back.

Transaction-control SQL:
- ADR 0050 forbids it through query() as a conformance rule for trusted code,
  not a security guarantee. Core does not parse SQL, so PostgreSQL has run
  the statement before Core sees it. Core detects the violation from the
  command tag or the transaction status, and claims nothing it cannot do: a
  module's own COMMIT has committed.
- After a violation, the transaction's scopes refuse every later statement
  without sending it (it would otherwise autocommit). Core never commits the
  transaction itself, and the connection is destroyed rather than reused,
  for top-level violations too.
- The commit and rollback guarantees apply to conforming use.

Duplicate object-mode columns: documented as found only after execution, so a
mutation can have taken effect; atomicity needs an ABI transaction.

Tests prove each of these against PostgreSQL: Promise.all failures in a
transaction and a savepoint keep 23505; a forgotten await still rolls back; a
module COMMIT's prior work is committed while nothing after it runs; a backend
PID changes after every violation; duplicate RETURNING columns persist at the
top level and roll back in a transaction.

ADR 0050 stays Accepted: the executor conforms to the revised text.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
ekklesia
2026-10-07 16:12:01 -05:00
co-authored by Claude Opus 5.5
parent 6db50516b7
commit 317a4ce410
6 changed files with 300 additions and 62 deletions
+51 -18
View File
@@ -49,13 +49,18 @@ import {
* not parse SQL. A statement text that holds no statement at all is refused after PostgreSQL reports
* it empty.
*
* ### Transaction control belongs to Core
* ### Transaction control belongs to the callbacks
*
* A statement whose command tag is transaction control (`BEGIN`, `START`, `COMMIT`, `ROLLBACK`,
* `SAVEPOINT`, `RELEASE`, `PREPARE`), or that leaves the connection's transaction status other than
* expected, is refused. PostgreSQL has already run it by then, so inside a transaction it also marks
* the transaction failed: Core rolls back and never commits it. The command tag and the protocol's
* transaction status come from PostgreSQL, not from parsing the SQL.
* Transaction-control SQL (`BEGIN`, `START TRANSACTION`, `COMMIT`, `ROLLBACK`, `SAVEPOINT`,
* `RELEASE SAVEPOINT`, `PREPARE TRANSACTION`, and their aliases) is forbidden through `query`: a
* conformance rule for trusted module code, not a security guarantee. Core cannot refuse such a
* statement beforehand without parsing SQL, which it does not do, and it cannot undo one PostgreSQL
* has already executed: a `COMMIT` sent through a transaction's `query` has committed by the time
* Core sees it. What Core does is detect the violation afterwards, from what PostgreSQL itself
* reports (the statement's command tag, and the protocol's transaction status), and fail safely:
* the call rejects, an enclosing transaction is never committed by Core and rejects too, and the
* connection is destroyed rather than returned to the pool. The commit and rollback guarantees of
* the transaction API apply to conforming use.
*/
/** The settings the ABI's decoding and determinism depend on, pinned for every ABI operation. */
@@ -281,6 +286,15 @@ function assertActive(scope: Scope): void {
}
}
/** Once a transaction can no longer be committed, its scopes send nothing more. After a
* transaction-control violation in particular, a later statement would otherwise run outside any
* transaction, as its own autocommitted statement. */
function assertUsable(state: TransactionState): void {
if (state.failure !== undefined) {
throw new ModuleDatabaseError(state.failure.code);
}
}
function track<T>(state: TransactionState, promise: Promise<T>): Promise<T> {
const shadow = promise.then(
() => undefined,
@@ -379,7 +393,9 @@ export function createModuleDatabaseExecutor(
(raw.command !== null && transactionControlCommands.has(raw.command)) ||
client.getTransactionStatus() !== "I"
) {
// The connection is destroyed below if the statement left a transaction open.
// A transaction-control violation. PostgreSQL has already run it; the connection is
// destroyed below rather than returned in a state Core did not choose.
healthy = false;
throw new ModuleDatabaseError("MODULE_DATABASE_REQUEST_INVALID");
}
return buildResult(raw, prepared.rowMode);
@@ -393,6 +409,7 @@ export function createModuleDatabaseExecutor(
request: unknown,
): Promise<ModuleDatabaseQueryResult<ModuleDatabaseArrayRow | ModuleDatabaseObjectRow>> {
assertActive(scope);
assertUsable(state);
const prepared = prepareQuery(request);
scope.pending += 1;
try {
@@ -409,7 +426,10 @@ export function createModuleDatabaseExecutor(
(raw.command !== null && transactionControlCommands.has(raw.command)) ||
state.client.getTransactionStatus() === "I"
) {
// A transaction-control violation. PostgreSQL has already run it, and Core cannot undo
// that; it can only never commit this transaction itself, and destroy the connection.
state.failure ??= new ModuleDatabaseError("MODULE_DATABASE_REQUEST_INVALID");
state.healthy = false;
throw new ModuleDatabaseError("MODULE_DATABASE_REQUEST_INVALID");
}
return buildResult(raw, prepared.rowMode);
@@ -422,6 +442,7 @@ export function createModuleDatabaseExecutor(
callback: (savepoint: ModuleDatabaseTransaction) => Promise<T> | T,
): Promise<T> {
assertActive(scope);
assertUsable(state);
if (typeof callback !== "function") {
throw new ModuleDatabaseError("MODULE_DATABASE_REQUEST_INVALID");
}
@@ -444,20 +465,28 @@ export function createModuleDatabaseExecutor(
// The enclosing transaction finished first; it rolls back, and nothing more is sent.
throw new ModuleDatabaseError("MODULE_DATABASE_SCOPE_CLOSED");
}
if (outstanding || "error" in outcome) {
if ("error" in outcome) {
// A rejected callback's own error stays the error, even if sibling work it started was
// still settling: that work has been drained, and is undone here with everything else.
try {
await control(state, `ROLLBACK TO SAVEPOINT ${name}`);
if (!outstanding) {
await control(state, `RELEASE SAVEPOINT ${name}`);
}
await control(state, `RELEASE SAVEPOINT ${name}`);
} catch (error) {
state.failure ??= new ModuleDatabaseError("MODULE_DATABASE_TRANSACTION_FAILED");
throw error;
}
if (outstanding) {
throw new ModuleDatabaseError("MODULE_DATABASE_SCOPE_WORK_OUTSTANDING");
throw outcome.error;
}
if (outstanding) {
// A callback that resolved with work outstanding forgot an await. Roll back to the
// savepoint and never release it.
try {
await control(state, `ROLLBACK TO SAVEPOINT ${name}`);
} catch (error) {
state.failure ??= new ModuleDatabaseError("MODULE_DATABASE_TRANSACTION_FAILED");
throw error;
}
throw (outcome as { readonly error: unknown }).error;
throw new ModuleDatabaseError("MODULE_DATABASE_SCOPE_WORK_OUTSTANDING");
}
try {
await control(state, `RELEASE SAVEPOINT ${name}`);
@@ -522,14 +551,18 @@ export function createModuleDatabaseExecutor(
await drain(state);
state.finished = true;
if (outstanding) {
await rollback(state);
throw new ModuleDatabaseError("MODULE_DATABASE_SCOPE_WORK_OUTSTANDING");
}
if ("error" in outcome) {
// The callback's own error stays primary, even when sibling work it started (say, the
// other half of a `Promise.all`) was still settling. That work has been drained, and rolls
// back with the rest; outstanding work never leads to a commit.
await rollback(state);
throw outcome.error;
}
if (outstanding) {
// The callback resolved with work it never awaited: a forgotten `await`.
await rollback(state);
throw new ModuleDatabaseError("MODULE_DATABASE_SCOPE_WORK_OUTSTANDING");
}
if (state.failure !== undefined) {
await rollback(state);
throw state.failure;