@sixb/pg 0.1.13 → 0.1.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth-storage/auth-storage.d.ts +2 -1
- package/dist/auth-storage/auth-storage.d.ts.map +1 -1
- package/dist/auth-storage/rows.d.ts +1 -1
- package/dist/auth-storage/rows.d.ts.map +1 -1
- package/dist/index.d.ts +8 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +779 -357
- package/dist/index.js.map +16 -16
- package/dist/latest-run-query.d.ts +2 -0
- package/dist/latest-run-query.d.ts.map +1 -1
- package/dist/migrations.d.ts.map +1 -1
- package/dist/ontology-storage/materialization-state.d.ts.map +1 -1
- package/dist/ontology-storage/shared.d.ts +12 -9
- package/dist/ontology-storage/shared.d.ts.map +1 -1
- package/dist/ontology-storage/source-cleanup.d.ts +7 -1
- package/dist/ontology-storage/source-cleanup.d.ts.map +1 -1
- package/dist/ontology-storage/source-roots.d.ts +8 -0
- package/dist/ontology-storage/source-roots.d.ts.map +1 -1
- package/dist/ontology-storage/sources.d.ts +8 -3
- package/dist/ontology-storage/sources.d.ts.map +1 -1
- package/dist/pg-client.d.ts +2 -0
- package/dist/pg-client.d.ts.map +1 -1
- package/dist/pg-projection-run-storage.d.ts +3 -1
- package/dist/pg-projection-run-storage.d.ts.map +1 -1
- package/dist/storage-errors.d.ts +13 -3
- package/dist/storage-errors.d.ts.map +1 -1
- package/dist/transactions.d.ts +23 -1
- package/dist/transactions.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/auth-storage/auth-storage.ts +43 -75
- package/src/auth-storage/rows.ts +1 -1
- package/src/index.ts +8 -1
- package/src/latest-run-query.ts +10 -2
- package/src/migrations/050-directory-group-memberships.sql +5 -0
- package/src/migrations/051-projection-run-supersession.sql +43 -0
- package/src/migrations/052-compact-source-storage.sql +118 -0
- package/src/migrations.ts +30 -23
- package/src/ontology-storage/materialization-state.ts +20 -46
- package/src/ontology-storage/shared.ts +18 -12
- package/src/ontology-storage/source-cleanup.ts +66 -22
- package/src/ontology-storage/source-roots.ts +164 -102
- package/src/ontology-storage/sources.ts +150 -120
- package/src/pg-client.ts +132 -2
- package/src/pg-projection-run-storage.ts +56 -2
- package/src/storage-errors.ts +101 -15
- package/src/transactions.ts +113 -20
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
immutableDatasetVersionConflict,
|
|
20
20
|
mergeProjectionRunProgress,
|
|
21
21
|
type PersistedProjectionRunRecord,
|
|
22
|
+
parseProjectionAttemptFailure,
|
|
22
23
|
planProjectionRunFinish,
|
|
23
24
|
planProjectionRunReclaim,
|
|
24
25
|
projectionRunNotFound,
|
|
@@ -26,12 +27,14 @@ import {
|
|
|
26
27
|
requireTelemetryProjectionRun,
|
|
27
28
|
restoreProjectionRun,
|
|
28
29
|
type StoredProjectionRunRecord,
|
|
30
|
+
SUPERSEDING_PROJECTION_RUN_STATUSES,
|
|
29
31
|
staleProjectionRunExecution,
|
|
30
32
|
} from "@sixb/core/internal/projection-run-storage-provider"
|
|
31
33
|
import type {
|
|
32
34
|
AdvanceProjectionTelemetryCheckpointInput,
|
|
33
35
|
ExecutionStorage,
|
|
34
36
|
FailProjectionRunEnqueueInput,
|
|
37
|
+
FindSupersedingProjectionRunInput,
|
|
35
38
|
FinishProjectionRunInput,
|
|
36
39
|
ListLatestProjectionRunsInput,
|
|
37
40
|
ListLatestProjectionRunsResult,
|
|
@@ -44,6 +47,7 @@ import type {
|
|
|
44
47
|
ProjectionRunStatus,
|
|
45
48
|
ProjectionRunStorage,
|
|
46
49
|
QueueProjectionRunInput,
|
|
50
|
+
RecordProjectionAttemptFailureInput,
|
|
47
51
|
RecordProjectionMissingTargetInput,
|
|
48
52
|
StartOrReclaimProjectionRunInput,
|
|
49
53
|
TelemetryProjectionRunRecord,
|
|
@@ -159,8 +163,7 @@ export class PgProjectionRunStorage implements ProjectionRunStorage {
|
|
|
159
163
|
const [updated] = await tx<DatabaseRow[]>`
|
|
160
164
|
UPDATE projection_runs
|
|
161
165
|
SET status = ${"running"}, started_at = COALESCE(started_at, ${input.startedAt ?? new Date()}),
|
|
162
|
-
finished_at = ${null}, attempt = ${attempt}, execution_token = ${executionToken}
|
|
163
|
-
error = ${null}
|
|
166
|
+
finished_at = ${null}, attempt = ${attempt}, execution_token = ${executionToken}
|
|
164
167
|
WHERE project_id = ${input.projectId} AND id = ${input.id}
|
|
165
168
|
AND (
|
|
166
169
|
(status = ${"queued"} AND execution_token IS NULL)
|
|
@@ -229,6 +232,26 @@ export class PgProjectionRunStorage implements ProjectionRunStorage {
|
|
|
229
232
|
})
|
|
230
233
|
}
|
|
231
234
|
|
|
235
|
+
async recordAttemptFailure(
|
|
236
|
+
input: RecordProjectionAttemptFailureInput
|
|
237
|
+
): Promise<ProjectionRunRecord> {
|
|
238
|
+
return runPgTransaction(this.sql, async (tx) => {
|
|
239
|
+
const existingRow = await requireMaterializationExecution(tx, input)
|
|
240
|
+
const error = parseProjectionAttemptFailure(restoreProjectionRunRow(existingRow), input.error)
|
|
241
|
+
const [updated] = await tx<DatabaseRow[]>`
|
|
242
|
+
UPDATE projection_runs
|
|
243
|
+
SET error = ${serializeSixbFailure(error, PROJECTION_RUN_FAILURE_CODES)}::text::jsonb
|
|
244
|
+
WHERE project_id = ${input.projectId}
|
|
245
|
+
AND id = ${input.id}
|
|
246
|
+
AND status = ${"running"}
|
|
247
|
+
AND execution_token = ${input.executionToken}
|
|
248
|
+
RETURNING *
|
|
249
|
+
`
|
|
250
|
+
if (!updated) throw staleProjectionRunExecution(input.id)
|
|
251
|
+
return rowToProjectionRunRecord(updated)
|
|
252
|
+
})
|
|
253
|
+
}
|
|
254
|
+
|
|
232
255
|
async finish(input: FinishProjectionRunInput): Promise<ProjectionRunRecord> {
|
|
233
256
|
return runPgTransaction(this.sql, async (tx) => {
|
|
234
257
|
const existingRow = await requireMaterializationExecution(tx, input)
|
|
@@ -411,6 +434,36 @@ export class PgProjectionRunStorage implements ProjectionRunStorage {
|
|
|
411
434
|
return { runs, hasMore: offset + runs.length < total, total }
|
|
412
435
|
}
|
|
413
436
|
|
|
437
|
+
async findSupersedingRun(
|
|
438
|
+
input: FindSupersedingProjectionRunInput
|
|
439
|
+
): Promise<ProjectionRunRecord | null> {
|
|
440
|
+
const [run] = await this.sql<
|
|
441
|
+
Pick<
|
|
442
|
+
DatabaseRow,
|
|
443
|
+
"projection_id" | "materialization_protocol" | "dataset_version_created_at"
|
|
444
|
+
>[]
|
|
445
|
+
>`
|
|
446
|
+
SELECT projection_id, materialization_protocol, dataset_version_created_at
|
|
447
|
+
FROM projection_runs
|
|
448
|
+
WHERE project_id = ${input.projectId} AND id = ${input.id}
|
|
449
|
+
`
|
|
450
|
+
if (!run) throw projectionRunNotFound(input.projectId, input.id)
|
|
451
|
+
if (run.materialization_protocol !== "replacement") return null
|
|
452
|
+
// Canonical UTC ISO timestamps order as text, but only under a binary collation: a
|
|
453
|
+
// linguistic one may weigh their punctuation differently.
|
|
454
|
+
const [newer] = await this.sql<DatabaseRow[]>`
|
|
455
|
+
SELECT * FROM projection_runs
|
|
456
|
+
WHERE project_id = ${input.projectId}
|
|
457
|
+
AND projection_id = ${run.projection_id}
|
|
458
|
+
AND materialization_protocol = ${"replacement"}
|
|
459
|
+
AND status = ANY(${this.sql.array([...SUPERSEDING_PROJECTION_RUN_STATUSES])}::text[])
|
|
460
|
+
AND dataset_version_created_at COLLATE "C" > ${run.dataset_version_created_at} COLLATE "C"
|
|
461
|
+
ORDER BY dataset_version_created_at COLLATE "C" DESC, id DESC
|
|
462
|
+
LIMIT 1
|
|
463
|
+
`
|
|
464
|
+
return newer ? rowToProjectionRunRecord(newer) : null
|
|
465
|
+
}
|
|
466
|
+
|
|
414
467
|
async listLatestByProjectionIds(
|
|
415
468
|
input: ListLatestProjectionRunsInput
|
|
416
469
|
): Promise<ListLatestProjectionRunsResult> {
|
|
@@ -421,6 +474,7 @@ export class PgProjectionRunStorage implements ProjectionRunStorage {
|
|
|
421
474
|
ownerIds: input.projectionIds,
|
|
422
475
|
projectId: input.projectId,
|
|
423
476
|
ownerIdFor: (row) => row.projection_id,
|
|
477
|
+
excludedStatus: "superseded",
|
|
424
478
|
})
|
|
425
479
|
|
|
426
480
|
return { runs: rows.map(rowToProjectionRunRecord) }
|
package/src/storage-errors.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { createSixbError } from "@sixb/core/internal/errors"
|
|
2
|
+
|
|
1
3
|
// Map thrown errors to their PostgreSQL/porsager meaning by code rather than by matching
|
|
2
4
|
// message text. Database errors arrive in the native Postgres format with a SQLSTATE on
|
|
3
|
-
// `.code`; connection-layer failures arrive with porsager's own
|
|
4
|
-
//
|
|
5
|
+
// `.code` and the server's `severity`; connection-layer failures arrive with porsager's own
|
|
6
|
+
// codes or the socket's system error code.
|
|
5
7
|
|
|
6
8
|
/** PostgreSQL SQLSTATE for a unique-constraint violation. */
|
|
7
9
|
const UNIQUE_VIOLATION = "23505"
|
|
@@ -17,20 +19,52 @@ const FOREIGN_KEY_VIOLATION = "23503"
|
|
|
17
19
|
*/
|
|
18
20
|
const RETRYABLE_TRANSACTION_CONFLICT_CODES = new Set(["40001", "40P01"])
|
|
19
21
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
22
|
+
/**
|
|
23
|
+
* Codes that mean the connection itself is gone, not that a statement failed: porsager's own codes
|
|
24
|
+
* for a connection that closed under a query or that the pool destroyed, and the socket errors a
|
|
25
|
+
* live connection can die with. Connect-time failures (`ECONNREFUSED`, `CONNECT_TIMEOUT`, ...) are
|
|
26
|
+
* left out on purpose: they reject before any session exists, so there is nothing to give up.
|
|
27
|
+
*/
|
|
28
|
+
const CONNECTION_LOST_CODES = new Set([
|
|
23
29
|
"CONNECTION_CLOSED",
|
|
24
30
|
"CONNECTION_DESTROYED",
|
|
25
|
-
"CONNECTION_ENDED",
|
|
26
|
-
// Node socket errors
|
|
27
|
-
"ECONNREFUSED",
|
|
28
31
|
"ECONNRESET",
|
|
32
|
+
"EPIPE",
|
|
29
33
|
"ETIMEDOUT",
|
|
34
|
+
])
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Codes of a connection that could not be opened: refused, unresolvable, unreachable, or not
|
|
38
|
+
* answered in time. A server that accepts the socket and then refuses the session (too many
|
|
39
|
+
* connections, starting up) answers with a `FATAL` error, which `isConnectionLost` covers.
|
|
40
|
+
*/
|
|
41
|
+
const CONNECT_FAILED_CODES = new Set([
|
|
42
|
+
"CONNECT_TIMEOUT",
|
|
43
|
+
"ECONNREFUSED",
|
|
30
44
|
"ENOTFOUND",
|
|
31
45
|
"EAI_AGAIN",
|
|
46
|
+
"EHOSTUNREACH",
|
|
47
|
+
"ENETUNREACH",
|
|
32
48
|
])
|
|
33
49
|
|
|
50
|
+
/**
|
|
51
|
+
* The shared lock tables PostgreSQL can fill, keyed by the setting its hint names. Both raise
|
|
52
|
+
* SQLSTATE 53200 (`out_of_memory`) with the message "out of shared memory", which reads like the
|
|
53
|
+
* server ran out of RAM; the hint is the only part that says which table filled up.
|
|
54
|
+
*/
|
|
55
|
+
const LOCK_TABLES = [
|
|
56
|
+
{
|
|
57
|
+
setting: "max_pred_locks_per_transaction",
|
|
58
|
+
table: "predicate lock table",
|
|
59
|
+
note:
|
|
60
|
+
" A long serializable transaction also keeps the predicate locks of every serializable" +
|
|
61
|
+
" transaction that commits while it is open.",
|
|
62
|
+
},
|
|
63
|
+
{ setting: "max_locks_per_transaction", table: "lock table", note: "" },
|
|
64
|
+
] as const
|
|
65
|
+
|
|
66
|
+
const OUT_OF_MEMORY = "53200"
|
|
67
|
+
|
|
34
68
|
/** The SQLSTATE / system error code carried by a thrown database or connection error. */
|
|
35
69
|
export function pgErrorCode(error: unknown): string | undefined {
|
|
36
70
|
if (error instanceof Error) {
|
|
@@ -63,16 +97,68 @@ export function isRetryableTransactionConflict(error: unknown): boolean {
|
|
|
63
97
|
}
|
|
64
98
|
|
|
65
99
|
/**
|
|
66
|
-
* True when the error
|
|
67
|
-
*
|
|
100
|
+
* True when the error means the session is gone: PostgreSQL has already rolled back its open
|
|
101
|
+
* transaction and released its locks, and nothing more may be sent on that connection.
|
|
102
|
+
*
|
|
103
|
+
* PostgreSQL ends the session after every `FATAL` or `PANIC` error — `pg_terminate_backend` (57P01),
|
|
104
|
+
* a server shutdown (57P02), `idle_in_transaction_session_timeout` (25P03) — so the severity decides
|
|
105
|
+
* rather than a list of SQLSTATEs.
|
|
68
106
|
*/
|
|
69
|
-
export function
|
|
107
|
+
export function isConnectionLost(error: unknown): boolean {
|
|
70
108
|
const code = pgErrorCode(error)
|
|
71
|
-
if (code !== undefined &&
|
|
109
|
+
if (code !== undefined && CONNECTION_LOST_CODES.has(code)) {
|
|
72
110
|
return true
|
|
73
111
|
}
|
|
74
|
-
|
|
75
|
-
|
|
112
|
+
const severity =
|
|
113
|
+
error instanceof Error ? (error as { readonly severity?: unknown }).severity : null
|
|
114
|
+
return severity === "FATAL" || severity === "PANIC"
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Replace an error that says something about the database rather than the statement: a lost or
|
|
119
|
+
* unopenable connection becomes the retryable `storage.unavailable`, a full lock table an error
|
|
120
|
+
* naming the table and the setting that sizes it. Every other error is returned unchanged.
|
|
121
|
+
*/
|
|
122
|
+
export function explainPgError(error: unknown): unknown {
|
|
123
|
+
if (isStorageUnavailable(error)) {
|
|
124
|
+
return storageUnavailable(error)
|
|
76
125
|
}
|
|
77
|
-
|
|
126
|
+
if (!(error instanceof Error) || pgErrorCode(error) !== OUT_OF_MEMORY) {
|
|
127
|
+
return error
|
|
128
|
+
}
|
|
129
|
+
const hint = (error as { readonly hint?: unknown }).hint
|
|
130
|
+
const lockTable =
|
|
131
|
+
typeof hint === "string" ? LOCK_TABLES.find(({ setting }) => hint.includes(setting)) : undefined
|
|
132
|
+
if (!lockTable) {
|
|
133
|
+
return error
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const { setting, table, note } = lockTable
|
|
137
|
+
return new Error(
|
|
138
|
+
`[SixbPg] PostgreSQL aborted the transaction because its shared ${table} is full ` +
|
|
139
|
+
`(SQLSTATE 53200 "${error.message}"). The table holds ${setting} × (max_connections + ` +
|
|
140
|
+
`max_prepared_transactions) entries shared by all sessions.${note} Raise ${setting} ` +
|
|
141
|
+
"(applied at server restart) or shorten the transactions that overlap.",
|
|
142
|
+
{ cause: error }
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function isStorageUnavailable(error: unknown): boolean {
|
|
147
|
+
const code = pgErrorCode(error)
|
|
148
|
+
return isConnectionLost(error) || (code !== undefined && CONNECT_FAILED_CODES.has(code))
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Whether a lost connection's transaction committed is unknown when the loss interrupted its
|
|
153
|
+
* COMMIT, so the message does not say it rolled back. Retrying is still safe for Sixb's own
|
|
154
|
+
* writes: each is fenced or idempotent.
|
|
155
|
+
*/
|
|
156
|
+
function storageUnavailable(error: unknown): Error {
|
|
157
|
+
const code = pgErrorCode(error)
|
|
158
|
+
const reason = error instanceof Error ? error.message : String(error)
|
|
159
|
+
return createSixbError(
|
|
160
|
+
"storage.unavailable",
|
|
161
|
+
`[SixbPg] PostgreSQL is unavailable: the connection was lost or could not be opened (${code ?? "no code"}: ${reason}).`,
|
|
162
|
+
{ cause: error, ...(code === undefined ? {} : { details: { code } }) }
|
|
163
|
+
)
|
|
78
164
|
}
|
package/src/transactions.ts
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
import { createHash } from "node:crypto"
|
|
2
|
-
import type { SQL, SQLClient } from "./pg-client"
|
|
2
|
+
import type { ReservedSQL, SQL, SQLClient } from "./pg-client"
|
|
3
|
+
import { explainPgError, isConnectionLost } from "./storage-errors"
|
|
4
|
+
|
|
5
|
+
// Why transactions are driven here instead of through porsager's `sql.begin`:
|
|
6
|
+
//
|
|
7
|
+
// postgres.js 3.4.9 writes to a connection without checking that its socket is still open. When
|
|
8
|
+
// the server or the network drops a connection during a transaction, `sql.begin` answers the
|
|
9
|
+
// failed statement by sending ROLLBACK on it; the write is deferred to `setImmediate` and throws
|
|
10
|
+
// `null is not an object (evaluating 'socket.write')` there, outside any promise, which kills the
|
|
11
|
+
// process. `reserve().release()` has the matching flaw: it hands the connection back to the pool
|
|
12
|
+
// without looking at it, and the next query sent on it crashes the same way.
|
|
13
|
+
//
|
|
14
|
+
// Holding a reserved connection and sending BEGIN/COMMIT/ROLLBACK ourselves lets us act on the
|
|
15
|
+
// loss first. PostgreSQL has already rolled the transaction back and ended the session, so nothing
|
|
16
|
+
// more is sent on that connection and it is never released: the pool reconnects a closed
|
|
17
|
+
// connection on its own. The upstream fix is porsager/postgres#1209; until a release carries it,
|
|
18
|
+
// a connection that drops *between* two statements still crashes, because nothing reports the loss
|
|
19
|
+
// before the next statement's deferred write.
|
|
3
20
|
|
|
4
21
|
export type PgStoreClient = SQL | SQLClient
|
|
5
22
|
|
|
@@ -10,6 +27,16 @@ export interface RunPgTransactionOptions {
|
|
|
10
27
|
|
|
11
28
|
type PgTransactionIsolation = "unverifiedDefault" | "repeatableRead" | "serializable"
|
|
12
29
|
|
|
30
|
+
// The isolation level is folded into `BEGIN` rather than a separate `SET TRANSACTION`: one
|
|
31
|
+
// round-trip instead of two, and the level takes effect before any statement of the transaction.
|
|
32
|
+
const BEGIN_STATEMENTS: Readonly<Record<PgTransactionIsolation, string>> = {
|
|
33
|
+
unverifiedDefault: "BEGIN",
|
|
34
|
+
repeatableRead: "BEGIN ISOLATION LEVEL REPEATABLE READ",
|
|
35
|
+
serializable: "BEGIN ISOLATION LEVEL SERIALIZABLE",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Every client handed to a transaction callback; a reserved connection can never start one. */
|
|
39
|
+
const pgTransactionClients = new WeakSet<PgStoreClient>()
|
|
13
40
|
const activePgTransactions = new WeakMap<PgStoreClient, PgTransactionIsolation>()
|
|
14
41
|
|
|
15
42
|
export async function runPgTransaction<T>(
|
|
@@ -21,30 +48,93 @@ export async function runPgTransaction<T>(
|
|
|
21
48
|
return run(sql)
|
|
22
49
|
}
|
|
23
50
|
|
|
24
|
-
// porsager passes the callback a transaction-scoped client (a TransactionSql, which is an
|
|
25
|
-
// ISql == SQLClient). The `as Promise<T>` only unwraps porsager's UnwrapPromiseArray return
|
|
26
|
-
// type (our callbacks never return the pipelined-array form), not a structural cast.
|
|
27
|
-
//
|
|
28
|
-
// The isolation level is folded into `BEGIN` (`BEGIN ISOLATION LEVEL SERIALIZABLE`) rather than
|
|
29
|
-
// a separate `SET TRANSACTION` statement: one round-trip instead of two, and the level is
|
|
30
|
-
// guaranteed to take effect before any data statement of the transaction runs.
|
|
31
51
|
const isolation = options.isolation ?? "unverifiedDefault"
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
return
|
|
36
|
-
|
|
37
|
-
|
|
52
|
+
try {
|
|
53
|
+
return await withReservedPgConnection(sql, (tx) => {
|
|
54
|
+
pgTransactionClients.add(tx)
|
|
55
|
+
return runPgTransactionOn(
|
|
56
|
+
tx,
|
|
57
|
+
async () => {
|
|
58
|
+
activePgTransactions.set(tx, isolation)
|
|
59
|
+
try {
|
|
60
|
+
return await run(tx)
|
|
61
|
+
} finally {
|
|
62
|
+
activePgTransactions.delete(tx)
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
isolation
|
|
66
|
+
)
|
|
67
|
+
})
|
|
68
|
+
} catch (error) {
|
|
69
|
+
throw explainPgError(error)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Hold one pool connection for `run`, and hand it back to the pool only while it is still usable.
|
|
75
|
+
* A connection whose session is gone stays out of the pool, which replaces it on its own.
|
|
76
|
+
*/
|
|
77
|
+
export async function withReservedPgConnection<T>(
|
|
78
|
+
sql: SQL,
|
|
79
|
+
run: (connection: ReservedSQL) => Promise<T>
|
|
80
|
+
): Promise<T> {
|
|
81
|
+
const connection = await sql.reserve()
|
|
82
|
+
let usable = true
|
|
83
|
+
try {
|
|
84
|
+
return await run(connection)
|
|
85
|
+
} catch (error) {
|
|
86
|
+
usable = !isConnectionLost(error)
|
|
87
|
+
throw error
|
|
88
|
+
} finally {
|
|
89
|
+
if (usable) {
|
|
90
|
+
connection.release()
|
|
38
91
|
}
|
|
39
92
|
}
|
|
93
|
+
}
|
|
40
94
|
|
|
41
|
-
|
|
42
|
-
|
|
95
|
+
/**
|
|
96
|
+
* Run `run` inside one transaction on a reserved connection.
|
|
97
|
+
*
|
|
98
|
+
* PostgreSQL answers COMMIT with a `ROLLBACK` tag, not an error, when a statement of the
|
|
99
|
+
* transaction failed and the callback caught that error and carried on. That outcome is surfaced
|
|
100
|
+
* as an error: returning normally would report writes that were discarded.
|
|
101
|
+
*/
|
|
102
|
+
export async function runPgTransactionOn<T>(
|
|
103
|
+
connection: ReservedSQL,
|
|
104
|
+
run: () => Promise<T>,
|
|
105
|
+
isolation: PgTransactionIsolation = "unverifiedDefault"
|
|
106
|
+
): Promise<T> {
|
|
107
|
+
await connection.unsafe(BEGIN_STATEMENTS[isolation])
|
|
108
|
+
const result = await undoOnFailure(run, () => connection.unsafe("ROLLBACK"))
|
|
109
|
+
const commit = await connection.unsafe("COMMIT")
|
|
110
|
+
if (commit.command !== "COMMIT") {
|
|
111
|
+
throw new Error(
|
|
112
|
+
"[SixbPg] PostgreSQL rolled the transaction back instead of committing it: a statement in it" +
|
|
113
|
+
" failed and its error was caught without being rethrown. A failed statement aborts the" +
|
|
114
|
+
" whole transaction, so let its error propagate out of the transaction callback."
|
|
115
|
+
)
|
|
43
116
|
}
|
|
44
|
-
|
|
45
|
-
|
|
117
|
+
return result
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Run `run`; when it throws, first undo the session state it left on its connection (an open
|
|
122
|
+
* transaction, a session lock), then rethrow. Nothing is undone when the connection itself was lost:
|
|
123
|
+
* the server has already discarded that state, and anything more sent on it would crash
|
|
124
|
+
* postgres.js (see the top of this file).
|
|
125
|
+
*/
|
|
126
|
+
export async function undoOnFailure<T>(
|
|
127
|
+
run: () => Promise<T>,
|
|
128
|
+
undo: () => Promise<unknown>
|
|
129
|
+
): Promise<T> {
|
|
130
|
+
try {
|
|
131
|
+
return await run()
|
|
132
|
+
} catch (error) {
|
|
133
|
+
if (!isConnectionLost(error)) {
|
|
134
|
+
await undo()
|
|
135
|
+
}
|
|
136
|
+
throw error
|
|
46
137
|
}
|
|
47
|
-
return sql.begin(trackedRun) as Promise<T>
|
|
48
138
|
}
|
|
49
139
|
|
|
50
140
|
/**
|
|
@@ -95,5 +185,8 @@ function advisoryLockParts(key: string): readonly [number, number] {
|
|
|
95
185
|
}
|
|
96
186
|
|
|
97
187
|
function canStartPgTransaction(sql: PgStoreClient): sql is SQL {
|
|
98
|
-
return
|
|
188
|
+
return (
|
|
189
|
+
!pgTransactionClients.has(sql) &&
|
|
190
|
+
typeof (sql as { readonly reserve?: unknown }).reserve === "function"
|
|
191
|
+
)
|
|
99
192
|
}
|