@sixb/pg 0.1.14 → 0.1.16

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.
Files changed (40) hide show
  1. package/dist/index.d.ts +8 -1
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +743 -313
  4. package/dist/index.js.map +15 -15
  5. package/dist/latest-run-query.d.ts +2 -0
  6. package/dist/latest-run-query.d.ts.map +1 -1
  7. package/dist/migrations.d.ts.map +1 -1
  8. package/dist/ontology-storage/materialization-state.d.ts.map +1 -1
  9. package/dist/ontology-storage/shared.d.ts +12 -9
  10. package/dist/ontology-storage/shared.d.ts.map +1 -1
  11. package/dist/ontology-storage/source-cleanup.d.ts +7 -1
  12. package/dist/ontology-storage/source-cleanup.d.ts.map +1 -1
  13. package/dist/ontology-storage/source-roots.d.ts +8 -0
  14. package/dist/ontology-storage/source-roots.d.ts.map +1 -1
  15. package/dist/ontology-storage/sources.d.ts +8 -3
  16. package/dist/ontology-storage/sources.d.ts.map +1 -1
  17. package/dist/pg-client.d.ts +2 -0
  18. package/dist/pg-client.d.ts.map +1 -1
  19. package/dist/pg-projection-run-storage.d.ts +3 -1
  20. package/dist/pg-projection-run-storage.d.ts.map +1 -1
  21. package/dist/storage-errors.d.ts +13 -3
  22. package/dist/storage-errors.d.ts.map +1 -1
  23. package/dist/transactions.d.ts +23 -1
  24. package/dist/transactions.d.ts.map +1 -1
  25. package/package.json +3 -3
  26. package/src/index.ts +8 -1
  27. package/src/latest-run-query.ts +10 -2
  28. package/src/migrations/051-projection-run-supersession.sql +43 -0
  29. package/src/migrations/052-compact-source-storage.sql +118 -0
  30. package/src/migrations.ts +26 -23
  31. package/src/objects/query-compiler.ts +1 -1
  32. package/src/ontology-storage/materialization-state.ts +20 -46
  33. package/src/ontology-storage/shared.ts +18 -12
  34. package/src/ontology-storage/source-cleanup.ts +66 -22
  35. package/src/ontology-storage/source-roots.ts +164 -102
  36. package/src/ontology-storage/sources.ts +150 -120
  37. package/src/pg-client.ts +132 -2
  38. package/src/pg-projection-run-storage.ts +56 -2
  39. package/src/storage-errors.ts +101 -15
  40. 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) }
@@ -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 codes or, for Node socket
4
- // errors, as an `AggregateError` of `ECONNREFUSED`/`ETIMEDOUT`/… entries.
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
- const CONNECTION_ERROR_CODES = new Set([
21
- // porsager-specific
22
- "CONNECT_TIMEOUT",
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 originates from the connection layer (porsager connection codes, or a
67
- * Node socket failure surfaced as an `AggregateError`) rather than from a SQL statement.
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 isConnectionError(error: unknown): boolean {
107
+ export function isConnectionLost(error: unknown): boolean {
70
108
  const code = pgErrorCode(error)
71
- if (code !== undefined && CONNECTION_ERROR_CODES.has(code)) {
109
+ if (code !== undefined && CONNECTION_LOST_CODES.has(code)) {
72
110
  return true
73
111
  }
74
- if (error instanceof AggregateError) {
75
- return error.errors.some(isConnectionError)
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
- return false
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
  }
@@ -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
- const trackedRun = async (tx: SQLClient): Promise<T> => {
33
- activePgTransactions.set(tx, isolation)
34
- try {
35
- return await run(tx)
36
- } finally {
37
- activePgTransactions.delete(tx)
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
- if (isolation === "serializable") {
42
- return sql.begin("isolation level serializable", trackedRun) as Promise<T>
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
- if (isolation === "repeatableRead") {
45
- return sql.begin("isolation level repeatable read", trackedRun) as Promise<T>
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 typeof (sql as { readonly begin?: unknown }).begin === "function"
188
+ return (
189
+ !pgTransactionClients.has(sql) &&
190
+ typeof (sql as { readonly reserve?: unknown }).reserve === "function"
191
+ )
99
192
  }