feat: Core's Module Database ABI v1 executor, proven against PostgreSQL (ADR 0050) #69
@@ -49,13 +49,18 @@ import {
|
|||||||
* not parse SQL. A statement text that holds no statement at all is refused after PostgreSQL reports
|
* not parse SQL. A statement text that holds no statement at all is refused after PostgreSQL reports
|
||||||
* it empty.
|
* 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`,
|
* Transaction-control SQL (`BEGIN`, `START TRANSACTION`, `COMMIT`, `ROLLBACK`, `SAVEPOINT`,
|
||||||
* `SAVEPOINT`, `RELEASE`, `PREPARE`), or that leaves the connection's transaction status other than
|
* `RELEASE SAVEPOINT`, `PREPARE TRANSACTION`, and their aliases) is forbidden through `query`: a
|
||||||
* expected, is refused. PostgreSQL has already run it by then, so inside a transaction it also marks
|
* conformance rule for trusted module code, not a security guarantee. Core cannot refuse such a
|
||||||
* the transaction failed: Core rolls back and never commits it. The command tag and the protocol's
|
* statement beforehand without parsing SQL, which it does not do, and it cannot undo one PostgreSQL
|
||||||
* transaction status come from PostgreSQL, not from parsing the SQL.
|
* 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. */
|
/** 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> {
|
function track<T>(state: TransactionState, promise: Promise<T>): Promise<T> {
|
||||||
const shadow = promise.then(
|
const shadow = promise.then(
|
||||||
() => undefined,
|
() => undefined,
|
||||||
@@ -379,7 +393,9 @@ export function createModuleDatabaseExecutor(
|
|||||||
(raw.command !== null && transactionControlCommands.has(raw.command)) ||
|
(raw.command !== null && transactionControlCommands.has(raw.command)) ||
|
||||||
client.getTransactionStatus() !== "I"
|
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");
|
throw new ModuleDatabaseError("MODULE_DATABASE_REQUEST_INVALID");
|
||||||
}
|
}
|
||||||
return buildResult(raw, prepared.rowMode);
|
return buildResult(raw, prepared.rowMode);
|
||||||
@@ -393,6 +409,7 @@ export function createModuleDatabaseExecutor(
|
|||||||
request: unknown,
|
request: unknown,
|
||||||
): Promise<ModuleDatabaseQueryResult<ModuleDatabaseArrayRow | ModuleDatabaseObjectRow>> {
|
): Promise<ModuleDatabaseQueryResult<ModuleDatabaseArrayRow | ModuleDatabaseObjectRow>> {
|
||||||
assertActive(scope);
|
assertActive(scope);
|
||||||
|
assertUsable(state);
|
||||||
const prepared = prepareQuery(request);
|
const prepared = prepareQuery(request);
|
||||||
scope.pending += 1;
|
scope.pending += 1;
|
||||||
try {
|
try {
|
||||||
@@ -409,7 +426,10 @@ export function createModuleDatabaseExecutor(
|
|||||||
(raw.command !== null && transactionControlCommands.has(raw.command)) ||
|
(raw.command !== null && transactionControlCommands.has(raw.command)) ||
|
||||||
state.client.getTransactionStatus() === "I"
|
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.failure ??= new ModuleDatabaseError("MODULE_DATABASE_REQUEST_INVALID");
|
||||||
|
state.healthy = false;
|
||||||
throw new ModuleDatabaseError("MODULE_DATABASE_REQUEST_INVALID");
|
throw new ModuleDatabaseError("MODULE_DATABASE_REQUEST_INVALID");
|
||||||
}
|
}
|
||||||
return buildResult(raw, prepared.rowMode);
|
return buildResult(raw, prepared.rowMode);
|
||||||
@@ -422,6 +442,7 @@ export function createModuleDatabaseExecutor(
|
|||||||
callback: (savepoint: ModuleDatabaseTransaction) => Promise<T> | T,
|
callback: (savepoint: ModuleDatabaseTransaction) => Promise<T> | T,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
assertActive(scope);
|
assertActive(scope);
|
||||||
|
assertUsable(state);
|
||||||
if (typeof callback !== "function") {
|
if (typeof callback !== "function") {
|
||||||
throw new ModuleDatabaseError("MODULE_DATABASE_REQUEST_INVALID");
|
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.
|
// The enclosing transaction finished first; it rolls back, and nothing more is sent.
|
||||||
throw new ModuleDatabaseError("MODULE_DATABASE_SCOPE_CLOSED");
|
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 {
|
try {
|
||||||
await control(state, `ROLLBACK TO SAVEPOINT ${name}`);
|
await control(state, `ROLLBACK TO SAVEPOINT ${name}`);
|
||||||
if (!outstanding) {
|
|
||||||
await control(state, `RELEASE SAVEPOINT ${name}`);
|
await control(state, `RELEASE SAVEPOINT ${name}`);
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
state.failure ??= new ModuleDatabaseError("MODULE_DATABASE_TRANSACTION_FAILED");
|
state.failure ??= new ModuleDatabaseError("MODULE_DATABASE_TRANSACTION_FAILED");
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
if (outstanding) {
|
throw outcome.error;
|
||||||
throw new ModuleDatabaseError("MODULE_DATABASE_SCOPE_WORK_OUTSTANDING");
|
|
||||||
}
|
}
|
||||||
throw (outcome as { readonly error: unknown }).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 new ModuleDatabaseError("MODULE_DATABASE_SCOPE_WORK_OUTSTANDING");
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await control(state, `RELEASE SAVEPOINT ${name}`);
|
await control(state, `RELEASE SAVEPOINT ${name}`);
|
||||||
@@ -522,14 +551,18 @@ export function createModuleDatabaseExecutor(
|
|||||||
await drain(state);
|
await drain(state);
|
||||||
state.finished = true;
|
state.finished = true;
|
||||||
|
|
||||||
if (outstanding) {
|
|
||||||
await rollback(state);
|
|
||||||
throw new ModuleDatabaseError("MODULE_DATABASE_SCOPE_WORK_OUTSTANDING");
|
|
||||||
}
|
|
||||||
if ("error" in outcome) {
|
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);
|
await rollback(state);
|
||||||
throw outcome.error;
|
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) {
|
if (state.failure !== undefined) {
|
||||||
await rollback(state);
|
await rollback(state);
|
||||||
throw state.failure;
|
throw state.failure;
|
||||||
|
|||||||
@@ -228,6 +228,28 @@ describe("query", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("finds duplicate object-mode columns only after the statement ran, so atomicity needs a transaction", async () => {
|
||||||
|
await reset();
|
||||||
|
// At the top level, the mutation has happened by the time the result's shape is known.
|
||||||
|
await expectAbiError(
|
||||||
|
database.query({
|
||||||
|
text: "INSERT INTO example_records (id, value) VALUES (1, 'a') RETURNING id, id",
|
||||||
|
}),
|
||||||
|
"MODULE_DATABASE_REQUEST_INVALID",
|
||||||
|
);
|
||||||
|
expect(await storedIds()).toEqual([1]);
|
||||||
|
// Inside a transaction, the same error rolls the mutation back with everything else.
|
||||||
|
await expectAbiError(
|
||||||
|
database.transaction((tx) =>
|
||||||
|
tx.query({
|
||||||
|
text: "INSERT INTO example_records (id, value) VALUES (2, 'b') RETURNING id, id",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
"MODULE_DATABASE_REQUEST_INVALID",
|
||||||
|
);
|
||||||
|
expect(await storedIds()).toEqual([1]);
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps a column named __proto__ a column", async () => {
|
it("keeps a column named __proto__ a column", async () => {
|
||||||
const result = await database.query({ text: 'SELECT 1 AS "__proto__"' });
|
const result = await database.query({ text: 'SELECT 1 AS "__proto__"' });
|
||||||
const row = result.rows[0] as Record<string, unknown>;
|
const row = result.rows[0] as Record<string, unknown>;
|
||||||
@@ -838,22 +860,25 @@ describe("transactions", () => {
|
|||||||
expect(called).toBe(false);
|
expect(called).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("refuses transaction control sent through query, and never commits the transaction", async () => {
|
it("detects transaction control sent through query once PostgreSQL has run it, then sends nothing more and commits nothing", async () => {
|
||||||
|
// Forbidden by ADR 0050, and not refusable beforehand without parsing SQL. Core detects each
|
||||||
|
// from PostgreSQL's command tag or the protocol's transaction status.
|
||||||
|
for (const statement of ["SAVEPOINT mine", "BEGIN", "START TRANSACTION", "ROLLBACK", "ABORT"]) {
|
||||||
await reset();
|
await reset();
|
||||||
for (const statements of [
|
|
||||||
["SAVEPOINT mine"],
|
|
||||||
["SAVEPOINT mine", "RELEASE SAVEPOINT mine"],
|
|
||||||
["SAVEPOINT mine", "ROLLBACK TO SAVEPOINT mine"],
|
|
||||||
["BEGIN"],
|
|
||||||
["START TRANSACTION"],
|
|
||||||
]) {
|
|
||||||
await expectAbiError(
|
await expectAbiError(
|
||||||
database.transaction(async (tx) => {
|
database.transaction(async (tx) => {
|
||||||
await tx.query({ text: "INSERT INTO example_records (id, value) VALUES (1, 'a')" });
|
await tx.query({ text: "INSERT INTO example_records (id, value) VALUES (1, 'a')" });
|
||||||
for (const statement of statements) {
|
|
||||||
// PostgreSQL has run each statement by the time it is refused.
|
|
||||||
await expectAbiError(tx.query({ text: statement }), "MODULE_DATABASE_REQUEST_INVALID");
|
await expectAbiError(tx.query({ text: statement }), "MODULE_DATABASE_REQUEST_INVALID");
|
||||||
}
|
// Anything after the violation is refused without being sent: it would otherwise run
|
||||||
|
// outside any transaction.
|
||||||
|
await expectAbiError(
|
||||||
|
tx.query({ text: "INSERT INTO example_records (id, value) VALUES (2, 'b')" }),
|
||||||
|
"MODULE_DATABASE_REQUEST_INVALID",
|
||||||
|
);
|
||||||
|
await expectAbiError(
|
||||||
|
tx.savepoint(async () => undefined),
|
||||||
|
"MODULE_DATABASE_REQUEST_INVALID",
|
||||||
|
);
|
||||||
return "carried on";
|
return "carried on";
|
||||||
}),
|
}),
|
||||||
"MODULE_DATABASE_REQUEST_INVALID",
|
"MODULE_DATABASE_REQUEST_INVALID",
|
||||||
@@ -862,15 +887,25 @@ describe("transactions", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("refuses a COMMIT sent through query and fails the transaction", async () => {
|
it("cannot undo a COMMIT sent through query: PostgreSQL has committed, and Core reports the violation and commits nothing more", async () => {
|
||||||
|
for (const statement of ["COMMIT", "END"]) {
|
||||||
await reset();
|
await reset();
|
||||||
await expectAbiError(
|
await expectAbiError(
|
||||||
database.transaction(async (tx) => {
|
database.transaction(async (tx) => {
|
||||||
await expectAbiError(tx.query({ text: "COMMIT" }), "MODULE_DATABASE_REQUEST_INVALID");
|
await tx.query({ text: "INSERT INTO example_records (id, value) VALUES (1, 'a')" });
|
||||||
|
await expectAbiError(tx.query({ text: statement }), "MODULE_DATABASE_REQUEST_INVALID");
|
||||||
|
await expectAbiError(
|
||||||
|
tx.query({ text: "INSERT INTO example_records (id, value) VALUES (2, 'b')" }),
|
||||||
|
"MODULE_DATABASE_REQUEST_INVALID",
|
||||||
|
);
|
||||||
return "carried on";
|
return "carried on";
|
||||||
}),
|
}),
|
||||||
"MODULE_DATABASE_REQUEST_INVALID",
|
"MODULE_DATABASE_REQUEST_INVALID",
|
||||||
);
|
);
|
||||||
|
// The documented limit: the work before the module's own COMMIT was committed by PostgreSQL
|
||||||
|
// when it ran that statement. Nothing after it ran.
|
||||||
|
expect(await storedIds()).toEqual([1]);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("applies Core's statement bound inside a transaction, and transaction-local settings work", async () => {
|
it("applies Core's statement bound inside a transaction, and transaction-local settings work", async () => {
|
||||||
@@ -917,6 +952,65 @@ describe("outstanding work", () => {
|
|||||||
expect(await storedIds()).toEqual([]);
|
expect(await storedIds()).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps the failing query's own error when one half of a Promise.all fails while the other is still running", async () => {
|
||||||
|
await reset();
|
||||||
|
await database.query({ text: "INSERT INTO example_records (id, value) VALUES (1, 'a')" });
|
||||||
|
let sibling: Promise<unknown> | undefined;
|
||||||
|
await expectAbiError(
|
||||||
|
database.transaction(async (tx) => {
|
||||||
|
const conflicting = tx.query({
|
||||||
|
text: "INSERT INTO example_records (id, value) VALUES (1, 'dup')",
|
||||||
|
});
|
||||||
|
sibling = tx.query({
|
||||||
|
text: "INSERT INTO example_records (id, value) SELECT 2, 'b' FROM pg_sleep(0.1)",
|
||||||
|
});
|
||||||
|
sibling.catch(() => undefined);
|
||||||
|
await Promise.all([conflicting, sibling]);
|
||||||
|
}),
|
||||||
|
// The PostgreSQL failure, not MODULE_DATABASE_SCOPE_WORK_OUTSTANDING.
|
||||||
|
"MODULE_DATABASE_QUERY_FAILED",
|
||||||
|
"23505",
|
||||||
|
);
|
||||||
|
// The sibling was still outstanding when the callback rejected: it settled afterwards (failing,
|
||||||
|
// because the transaction was already aborted), before Core rolled back.
|
||||||
|
await expect(sibling).rejects.toMatchObject({ sqlState: "25P02" });
|
||||||
|
expect(await storedIds()).toEqual([1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a savepoint's failing query error from a Promise.all, and the transaction carries on", async () => {
|
||||||
|
await reset();
|
||||||
|
await database.query({ text: "INSERT INTO example_records (id, value) VALUES (1, 'a')" });
|
||||||
|
await database.transaction(async (tx) => {
|
||||||
|
await expectAbiError(
|
||||||
|
tx.savepoint(async (sp) => {
|
||||||
|
const sibling = sp.query({
|
||||||
|
text: "INSERT INTO example_records (id, value) SELECT 2, 'b' FROM pg_sleep(0.1)",
|
||||||
|
});
|
||||||
|
sibling.catch(() => undefined);
|
||||||
|
await Promise.all([
|
||||||
|
sp.query({ text: "INSERT INTO example_records (id, value) VALUES (1, 'dup')" }),
|
||||||
|
sibling,
|
||||||
|
]);
|
||||||
|
}),
|
||||||
|
"MODULE_DATABASE_QUERY_FAILED",
|
||||||
|
"23505",
|
||||||
|
);
|
||||||
|
await tx.query({ text: "INSERT INTO example_records (id, value) VALUES (3, 'c')" });
|
||||||
|
});
|
||||||
|
expect(await storedIds()).toEqual([1, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("commits a Promise.all whose queries all succeed", async () => {
|
||||||
|
await reset();
|
||||||
|
await database.transaction(async (tx) => {
|
||||||
|
await Promise.all([
|
||||||
|
tx.query({ text: "INSERT INTO example_records (id, value) VALUES (1, 'a')" }),
|
||||||
|
tx.query({ text: "INSERT INTO example_records (id, value) VALUES (2, 'b')" }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
expect(await storedIds()).toEqual([1, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
it("treats an unawaited savepoint as outstanding work", async () => {
|
it("treats an unawaited savepoint as outstanding work", async () => {
|
||||||
await reset();
|
await reset();
|
||||||
let forgotten: Promise<unknown> | undefined;
|
let forgotten: Promise<unknown> | undefined;
|
||||||
@@ -1259,6 +1353,34 @@ describe("pool safety", () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
it("destroys, never reuses, a connection after transaction-control misuse", async () => {
|
||||||
|
const pid = async (executor: ModuleDatabaseExecutor | ModuleDatabaseTransaction) =>
|
||||||
|
(
|
||||||
|
(await executor.query({ text: "SELECT pg_backend_pid() AS pid" })).rows[0] as {
|
||||||
|
pid: number;
|
||||||
|
}
|
||||||
|
).pid;
|
||||||
|
for (const statement of ["COMMIT", "ROLLBACK", "SAVEPOINT mine", "BEGIN"]) {
|
||||||
|
let used: number | undefined;
|
||||||
|
await rejection(
|
||||||
|
single.transaction(async (tx) => {
|
||||||
|
used = await pid(tx);
|
||||||
|
await rejection(tx.query({ text: statement }));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(used).toBeTypeOf("number");
|
||||||
|
expect(await pid(single)).not.toBe(used);
|
||||||
|
expect(await nextBorrower(onePool)).toEqual(clean);
|
||||||
|
}
|
||||||
|
for (const statement of ["BEGIN", "COMMIT"]) {
|
||||||
|
const before = await pid(single);
|
||||||
|
// With one connection, the next top-level query would reuse it unless it was destroyed.
|
||||||
|
await rejection(single.query({ text: statement }));
|
||||||
|
expect(await pid(single)).not.toBe(before);
|
||||||
|
expect(await nextBorrower(onePool)).toEqual(clean);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("reports a terminated connection without a SQLSTATE of its own making", async () => {
|
it("reports a terminated connection without a SQLSTATE of its own making", async () => {
|
||||||
const error = await rejection(
|
const error = await rejection(
|
||||||
single.transaction(async (tx) => {
|
single.transaction(async (tx) => {
|
||||||
|
|||||||
@@ -654,20 +654,25 @@ describe("transaction scopes", () => {
|
|||||||
expect(texts(script)).not.toContain("COMMIT");
|
expect(texts(script)).not.toContain("COMMIT");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reports outstanding work even when the callback itself rejected", async () => {
|
it("keeps a rejected callback's own error when sibling work is still settling, and still never commits", async () => {
|
||||||
const script = scriptedPool();
|
const script = scriptedPool();
|
||||||
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
|
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
|
||||||
|
const own = new Error("module failure");
|
||||||
let releaseHeld: (() => void) | undefined;
|
let releaseHeld: (() => void) | undefined;
|
||||||
|
|
||||||
const outcome = executor.transaction(async (tx) => {
|
const outcome = executor.transaction(async (tx) => {
|
||||||
releaseHeld = script.holdNext();
|
releaseHeld = script.holdNext();
|
||||||
void tx.query({ text: "INSERT INTO example_records VALUES (1)" });
|
void tx.query({ text: "INSERT INTO example_records VALUES (1)" });
|
||||||
throw new Error("module failure");
|
throw own;
|
||||||
});
|
});
|
||||||
await vi.waitFor(() => expect(releaseHeld).toBeDefined());
|
await vi.waitFor(() => expect(releaseHeld).toBeDefined());
|
||||||
|
// The sibling statement is drained before Core rolls back.
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
|
expect(texts(script)).not.toContain("ROLLBACK");
|
||||||
releaseHeld?.();
|
releaseHeld?.();
|
||||||
|
|
||||||
await expectAbiError(outcome, "MODULE_DATABASE_SCOPE_WORK_OUTSTANDING");
|
expect(await rejection(outcome)).toBe(own);
|
||||||
|
expect(texts(script).slice(-2)).toEqual(["ROLLBACK", "DISCARD ALL"]);
|
||||||
expect(texts(script)).not.toContain("COMMIT");
|
expect(texts(script)).not.toContain("COMMIT");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -688,6 +693,9 @@ describe("transaction scopes", () => {
|
|||||||
expect(error).toMatchObject({ code: "MODULE_DATABASE_REQUEST_INVALID" });
|
expect(error).toMatchObject({ code: "MODULE_DATABASE_REQUEST_INVALID" });
|
||||||
expect(texts(script)).not.toContain("COMMIT");
|
expect(texts(script)).not.toContain("COMMIT");
|
||||||
expect(texts(script)).toContain("ROLLBACK");
|
expect(texts(script)).toContain("ROLLBACK");
|
||||||
|
// The connection is destroyed, never reset and reused.
|
||||||
|
expect(texts(script)).not.toContain("DISCARD ALL");
|
||||||
|
expect(script.released).toEqual([true]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reports a COMMIT that PostgreSQL answered with ROLLBACK as a failed transaction", async () => {
|
it("reports a COMMIT that PostgreSQL answered with ROLLBACK as a failed transaction", async () => {
|
||||||
@@ -794,6 +802,32 @@ describe("savepoint scopes", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps a rejected savepoint callback's own error, rolling back and releasing after its sibling work settles", async () => {
|
||||||
|
const script = scriptedPool();
|
||||||
|
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
|
||||||
|
const own = new Error("savepoint failure");
|
||||||
|
|
||||||
|
await executor.transaction(async (tx) => {
|
||||||
|
const error = await rejection(
|
||||||
|
tx.savepoint(async (sp) => {
|
||||||
|
const release = script.holdNext();
|
||||||
|
void sp.query({ text: "INSERT INTO example_records VALUES (1)" });
|
||||||
|
setTimeout(release, 10);
|
||||||
|
throw own;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(error).toBe(own);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(texts(script).slice(2, -2)).toEqual([
|
||||||
|
"SAVEPOINT ekklesia_savepoint_1",
|
||||||
|
"INSERT INTO example_records VALUES (1)",
|
||||||
|
"ROLLBACK TO SAVEPOINT ekklesia_savepoint_1",
|
||||||
|
"RELEASE SAVEPOINT ekklesia_savepoint_1",
|
||||||
|
]);
|
||||||
|
expect(texts(script)).toContain("COMMIT");
|
||||||
|
});
|
||||||
|
|
||||||
it("counts an unawaited savepoint as the transaction's outstanding work", async () => {
|
it("counts an unawaited savepoint as the transaction's outstanding work", async () => {
|
||||||
const script = scriptedPool();
|
const script = scriptedPool();
|
||||||
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
|
const executor = createModuleDatabaseExecutor(script.pool, { moduleId: "example" });
|
||||||
|
|||||||
@@ -181,8 +181,13 @@ transaction(callback, options) → the callback's result
|
|||||||
- `"object"` rows are keyed by column name.
|
- `"object"` rows are keyed by column name.
|
||||||
- `"array"` rows are positional, in `fields` order. An ORM that selects the same column name twice,
|
- `"array"` rows are positional, in `fields` order. An ORM that selects the same column name twice,
|
||||||
as joins do, needs this mode.
|
as joins do, needs this mode.
|
||||||
- Core rejects an object-mode result that has duplicate column names, rather than silently
|
- Duplicate column names are invalid in object mode. Core rejects such a result rather than
|
||||||
dropping one.
|
silently dropping a column.
|
||||||
|
- With the current PostgreSQL and driver execution path, the result's shape is only known once
|
||||||
|
the statement has run. So a mutating statement can already have had its effects when the
|
||||||
|
result-shape error is reported.
|
||||||
|
- This is not a security guarantee. A caller that needs atomicity runs the mutation inside an ABI
|
||||||
|
transaction, where the error rolls the mutation back with everything else.
|
||||||
- **`command`** is PostgreSQL's command tag word, such as `SELECT`, `INSERT`, `UPDATE`, or `DELETE`.
|
- **`command`** is PostgreSQL's command tag word, such as `SELECT`, `INSERT`, `UPDATE`, or `DELETE`.
|
||||||
- **`rowCount`** is the number of rows PostgreSQL reports as processed, or `null` when it reports
|
- **`rowCount`** is the number of rows PostgreSQL reports as processed, or `null` when it reports
|
||||||
none.
|
none.
|
||||||
@@ -331,15 +336,37 @@ transaction(async (tx) => { … }, { isolation, readOnly })
|
|||||||
transaction call rejects with the callback's error. There is no `rollback()` method: to roll back,
|
transaction call rejects with the callback's error. There is no `rollback()` method: to roll back,
|
||||||
the module throws.
|
the module throws.
|
||||||
- **All work is awaited inside the callback.** Module code must await every database operation it
|
- **All work is awaited inside the callback.** Module code must await every database operation it
|
||||||
starts through `tx` before its callback returns.
|
starts through `tx` before its callback returns. What happens when work is still outstanding
|
||||||
- If the callback settles while an operation launched through that scope is still outstanding, the
|
depends on how the callback settled:
|
||||||
module has used the scope incorrectly.
|
- **Resolved with work outstanding.** The module forgot an `await` and has used the scope
|
||||||
- The transaction is then never committed: Core rolls it back, and the transaction call rejects
|
incorrectly. The transaction is never committed: Core rolls it back, and the transaction call
|
||||||
with the scope-usage `ModuleDatabaseError`.
|
rejects with the scope-usage `ModuleDatabaseError`. A forgotten `await` therefore fails. It never
|
||||||
- A forgotten `await` therefore fails. It never quietly changes what the transaction contains.
|
quietly changes what the transaction contains.
|
||||||
|
- **Rejected with work outstanding.** The callback's own rejection stays the transaction's error.
|
||||||
|
Core rolls back and does not replace that error, a PostgreSQL failure included, with the
|
||||||
|
scope-usage error just because sibling work was still settling. This is what happens when one
|
||||||
|
query in `await Promise.all([tx.query(a), tx.query(b)])` fails while the other is still running.
|
||||||
|
- Either way, outstanding work never leads to a commit.
|
||||||
- Core may let operations that already started finish, or settle, before it rolls back and releases
|
- Core may let operations that already started finish, or settle, before it rolls back and releases
|
||||||
the connection, so that a pooled connection is never handed on mid-operation. Waiting for them
|
the connection, so that a pooled connection is never handed on mid-operation. Waiting for them
|
||||||
never turns into a commit.
|
never turns into a commit.
|
||||||
|
- **Transaction control belongs to the callbacks.** Transaction-control SQL is forbidden through
|
||||||
|
`query`: `BEGIN`, `START TRANSACTION`, `COMMIT`, `ROLLBACK`, `SAVEPOINT`, `RELEASE SAVEPOINT`, and
|
||||||
|
every other statement that starts, ends, or marks a transaction. Modules control a transaction's
|
||||||
|
lifecycle only through the `transaction` and `savepoint` callbacks.
|
||||||
|
- This is a conformance rule for trusted module code, not a sandbox or a security guarantee.
|
||||||
|
- Core does not parse SQL to enforce it. Such a statement is valid PostgreSQL, so PostgreSQL
|
||||||
|
executes it before Core can see its result.
|
||||||
|
- Core detects a violation where the driver and protocol make that possible: from the statement's
|
||||||
|
command tag, and from the connection's transaction status. It then fails safely:
|
||||||
|
- the call rejects;
|
||||||
|
- the transaction's scopes refuse every later statement without sending it, because it would
|
||||||
|
otherwise run outside any transaction;
|
||||||
|
- Core never commits the enclosing transaction itself, and that transaction rejects too;
|
||||||
|
- the connection is destroyed rather than reused.
|
||||||
|
- Core does not claim to undo a transaction-control statement PostgreSQL has already executed. A
|
||||||
|
`COMMIT` sent through a transaction's `query` has already committed the work before it.
|
||||||
|
- The commit and rollback guarantees in this section apply to conforming use of the ABI.
|
||||||
- **Revocation.** When the callback settles, `tx` is revoked, and it stays revoked once the scope has
|
- **Revocation.** When the callback settles, `tx` is revoked, and it stays revoked once the scope has
|
||||||
finished. Any later call on it fails with the
|
finished. Any later call on it fails with the
|
||||||
closed-scope `ModuleDatabaseError` and sends nothing to PostgreSQL. A `tx` that escapes its
|
closed-scope `ModuleDatabaseError` and sends nothing to PostgreSQL. A `tx` that escapes its
|
||||||
@@ -362,9 +389,12 @@ tx.savepoint(async (sp) => { … })
|
|||||||
- **Naming.** Core generates every savepoint identifier. Modules never provide one.
|
- **Naming.** Core generates every savepoint identifier. Modules never provide one.
|
||||||
- **Release and rollback.** A resolved callback releases the savepoint. A rejected one rolls back
|
- **Release and rollback.** A resolved callback releases the savepoint. A rejected one rolls back
|
||||||
to it, releases it, and rejects with the callback's error.
|
to it, releases it, and rejects with the callback's error.
|
||||||
- **Outstanding work.** The awaiting rule for transactions applies unchanged. A savepoint callback
|
- **Outstanding work.** The awaiting rule for transactions applies unchanged.
|
||||||
that settles with operations still outstanding is a scope-usage error. The savepoint is rolled
|
- A savepoint callback that resolves with operations still outstanding is a scope-usage error.
|
||||||
back, never released, and the call rejects with that error.
|
The savepoint is rolled back, never released, and the call rejects with that error.
|
||||||
|
- A savepoint callback that rejects keeps its own error. The savepoint is rolled back as for any
|
||||||
|
rejection.
|
||||||
|
- Either way, outstanding work never leads to a release.
|
||||||
- **One active scope.** Only the innermost open scope may run statements. While a savepoint is
|
- **One active scope.** Only the innermost open scope may run statements. While a savepoint is
|
||||||
open, its parent executor refuses statements and new savepoints, so savepoint scopes can never
|
open, its parent executor refuses statements and new savepoints, so savepoint scopes can never
|
||||||
overlap.
|
overlap.
|
||||||
|
|||||||
@@ -522,7 +522,12 @@ because every claim is a property of PostgreSQL or of the protocol:
|
|||||||
- commit, rollback, isolation levels, read-only, no retries, and an aborted transaction never being
|
- commit, rollback, isolation levels, read-only, no retries, and an aborted transaction never being
|
||||||
committed;
|
committed;
|
||||||
- callback-scoped savepoints;
|
- callback-scoped savepoints;
|
||||||
- a forgotten `await` rolling back instead of committing;
|
- a forgotten `await` rolling back instead of committing, while a rejected callback (one half of a
|
||||||
|
failed `Promise.all`) keeps its own error;
|
||||||
|
- transaction-control SQL sent through `query` detected only after PostgreSQL has run it: Core
|
||||||
|
cannot undo a module's own `COMMIT`, so the suite pins what it does do: it refuses everything
|
||||||
|
after, never commits, and destroys the connection;
|
||||||
|
- duplicate object-mode columns found only after a mutation ran, unless it ran in a transaction;
|
||||||
- revoked executors reaching nothing.
|
- revoked executors reaching nothing.
|
||||||
|
|
||||||
The suite's database is created with deliberately hostile defaults: a non-UTC time zone, a non-ISO
|
The suite's database is created with deliberately hostile defaults: a non-UTC time zone, a non-ISO
|
||||||
|
|||||||
@@ -105,11 +105,17 @@ export interface ModuleDatabaseTransactionOptions {
|
|||||||
* that callback settles it is revoked, and every later call fails without reaching PostgreSQL.
|
* that callback settles it is revoked, and every later call fails without reaching PostgreSQL.
|
||||||
*
|
*
|
||||||
* Only the innermost open scope may run statements: while a savepoint is open, its parent refuses
|
* Only the innermost open scope may run statements: while a savepoint is open, its parent refuses
|
||||||
* statements and new savepoints. A callback must await every operation it starts before it returns;
|
* statements and new savepoints. A callback must await every operation it starts before it returns.
|
||||||
* one that settles with work outstanding is never committed or released.
|
* One that resolves with work outstanding forgot an `await`: it fails with
|
||||||
|
* `MODULE_DATABASE_SCOPE_WORK_OUTSTANDING` and is rolled back. One that rejects keeps its own error.
|
||||||
|
* Either way, outstanding work never leads to a commit or a release.
|
||||||
|
*
|
||||||
|
* Transaction-control SQL (`BEGIN`, `COMMIT`, `ROLLBACK`, `SAVEPOINT`, `RELEASE SAVEPOINT`, …) is
|
||||||
|
* forbidden through `query`; the callbacks control the transaction.
|
||||||
*/
|
*/
|
||||||
export interface ModuleDatabaseTransaction extends ModuleDatabaseQueryable {
|
export interface ModuleDatabaseTransaction extends ModuleDatabaseQueryable {
|
||||||
/** Core names the savepoint. A resolved callback releases it; a rejected one rolls back to it. */
|
/** Core names the savepoint. A resolved callback with no work outstanding releases it; a rejected
|
||||||
|
* one rolls back to it and keeps its own error. */
|
||||||
savepoint<T>(callback: (savepoint: ModuleDatabaseTransaction) => Promise<T> | T): Promise<T>;
|
savepoint<T>(callback: (savepoint: ModuleDatabaseTransaction) => Promise<T> | T): Promise<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,7 +126,9 @@ export interface ModuleDatabaseTransaction extends ModuleDatabaseQueryable {
|
|||||||
*/
|
*/
|
||||||
export interface ModuleDatabaseExecutor extends ModuleDatabaseQueryable {
|
export interface ModuleDatabaseExecutor extends ModuleDatabaseQueryable {
|
||||||
/** Core begins the transaction, commits when `callback` resolves with no work outstanding, and
|
/** Core begins the transaction, commits when `callback` resolves with no work outstanding, and
|
||||||
* rolls back otherwise. Core never retries it. */
|
* rolls back otherwise; a rejected callback's own error is the transaction's error. Core never
|
||||||
|
* retries it. These guarantees apply to conforming use: no transaction-control SQL through
|
||||||
|
* `query`. */
|
||||||
transaction<T>(
|
transaction<T>(
|
||||||
callback: (transaction: ModuleDatabaseTransaction) => Promise<T> | T,
|
callback: (transaction: ModuleDatabaseTransaction) => Promise<T> | T,
|
||||||
options?: ModuleDatabaseTransactionOptions,
|
options?: ModuleDatabaseTransactionOptions,
|
||||||
@@ -135,7 +143,12 @@ export interface ModuleDatabaseExecutor extends ModuleDatabaseQueryable {
|
|||||||
export const MODULE_DATABASE_ERROR_CODES = [
|
export const MODULE_DATABASE_ERROR_CODES = [
|
||||||
/** The call is not valid ABI use: a malformed query request or callback, a statement text that
|
/** The call is not valid ABI use: a malformed query request or callback, a statement text that
|
||||||
* holds no statement, an object-mode result with duplicate column names, or a transaction-control
|
* holds no statement, an object-mode result with duplicate column names, or a transaction-control
|
||||||
* statement sent through `query`. A programming error. */
|
* statement sent through `query`. A programming error.
|
||||||
|
*
|
||||||
|
* The last two are only detectable once PostgreSQL has run the statement, so its effects may
|
||||||
|
* already have happened: a mutation with duplicate result columns has been applied (inside a
|
||||||
|
* transaction, it rolls back with it), and transaction control has taken effect, which Core cannot
|
||||||
|
* undo. */
|
||||||
"MODULE_DATABASE_REQUEST_INVALID",
|
"MODULE_DATABASE_REQUEST_INVALID",
|
||||||
/** A parameter value is outside the ABI's parameter set. Refused before anything reaches
|
/** A parameter value is outside the ABI's parameter set. Refused before anything reaches
|
||||||
* PostgreSQL. */
|
* PostgreSQL. */
|
||||||
@@ -154,8 +167,9 @@ export const MODULE_DATABASE_ERROR_CODES = [
|
|||||||
"MODULE_DATABASE_SCOPE_CLOSED",
|
"MODULE_DATABASE_SCOPE_CLOSED",
|
||||||
/** The executor's scope is open but not active, because a savepoint inside it is open. */
|
/** The executor's scope is open but not active, because a savepoint inside it is open. */
|
||||||
"MODULE_DATABASE_SCOPE_INACTIVE",
|
"MODULE_DATABASE_SCOPE_INACTIVE",
|
||||||
/** A transaction or savepoint callback settled while work it started was still outstanding. The
|
/** A transaction or savepoint callback resolved while work it started was still outstanding: a
|
||||||
* scope was rolled back, never committed or released. */
|
* forgotten `await`. The scope was rolled back, never committed or released. (A callback that
|
||||||
|
* rejects keeps its own error instead.) */
|
||||||
"MODULE_DATABASE_SCOPE_WORK_OUTSTANDING",
|
"MODULE_DATABASE_SCOPE_WORK_OUTSTANDING",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user