@rocicorp/zero 1.11.0-canary.35 → 1.11.0-canary.36

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 (54) hide show
  1. package/out/zero/package.js +1 -1
  2. package/out/zero/package.js.map +1 -1
  3. package/out/zero-cache/src/config/zero-config.d.ts +20 -0
  4. package/out/zero-cache/src/config/zero-config.d.ts.map +1 -1
  5. package/out/zero-cache/src/config/zero-config.js +52 -0
  6. package/out/zero-cache/src/config/zero-config.js.map +1 -1
  7. package/out/zero-cache/src/server/syncer.d.ts.map +1 -1
  8. package/out/zero-cache/src/server/syncer.js +17 -1
  9. package/out/zero-cache/src/server/syncer.js.map +1 -1
  10. package/out/zero-cache/src/services/change-source/pg/backfill-stream.js +1 -1
  11. package/out/zero-cache/src/services/change-source/pg/backfill-stream.js.map +1 -1
  12. package/out/zero-cache/src/services/change-source/pg/replication-slots.d.ts +27 -7
  13. package/out/zero-cache/src/services/change-source/pg/replication-slots.d.ts.map +1 -1
  14. package/out/zero-cache/src/services/change-source/pg/replication-slots.js +124 -62
  15. package/out/zero-cache/src/services/change-source/pg/replication-slots.js.map +1 -1
  16. package/out/zero-cache/src/services/view-syncer/batch-pacer.d.ts +79 -0
  17. package/out/zero-cache/src/services/view-syncer/batch-pacer.d.ts.map +1 -0
  18. package/out/zero-cache/src/services/view-syncer/batch-pacer.js +249 -0
  19. package/out/zero-cache/src/services/view-syncer/batch-pacer.js.map +1 -0
  20. package/out/zero-cache/src/services/view-syncer/cvr.d.ts +27 -1
  21. package/out/zero-cache/src/services/view-syncer/cvr.d.ts.map +1 -1
  22. package/out/zero-cache/src/services/view-syncer/cvr.js +107 -17
  23. package/out/zero-cache/src/services/view-syncer/cvr.js.map +1 -1
  24. package/out/zero-cache/src/services/view-syncer/pipeline-driver.d.ts +85 -3
  25. package/out/zero-cache/src/services/view-syncer/pipeline-driver.d.ts.map +1 -1
  26. package/out/zero-cache/src/services/view-syncer/pipeline-driver.js +301 -20
  27. package/out/zero-cache/src/services/view-syncer/pipeline-driver.js.map +1 -1
  28. package/out/zero-cache/src/services/view-syncer/push-cost.d.ts +132 -0
  29. package/out/zero-cache/src/services/view-syncer/push-cost.d.ts.map +1 -0
  30. package/out/zero-cache/src/services/view-syncer/push-cost.js +164 -0
  31. package/out/zero-cache/src/services/view-syncer/push-cost.js.map +1 -0
  32. package/out/zero-cache/src/services/view-syncer/push-cutoff.d.ts +55 -0
  33. package/out/zero-cache/src/services/view-syncer/push-cutoff.d.ts.map +1 -0
  34. package/out/zero-cache/src/services/view-syncer/push-cutoff.js +56 -0
  35. package/out/zero-cache/src/services/view-syncer/push-cutoff.js.map +1 -0
  36. package/out/zero-cache/src/services/view-syncer/query-stats.d.ts +7 -0
  37. package/out/zero-cache/src/services/view-syncer/query-stats.d.ts.map +1 -1
  38. package/out/zero-cache/src/services/view-syncer/query-stats.js +20 -1
  39. package/out/zero-cache/src/services/view-syncer/query-stats.js.map +1 -1
  40. package/out/zero-cache/src/services/view-syncer/snapshotter.d.ts +136 -5
  41. package/out/zero-cache/src/services/view-syncer/snapshotter.d.ts.map +1 -1
  42. package/out/zero-cache/src/services/view-syncer/snapshotter.js +246 -18
  43. package/out/zero-cache/src/services/view-syncer/snapshotter.js.map +1 -1
  44. package/out/zero-cache/src/services/view-syncer/view-syncer.d.ts +8 -2
  45. package/out/zero-cache/src/services/view-syncer/view-syncer.d.ts.map +1 -1
  46. package/out/zero-cache/src/services/view-syncer/view-syncer.js +381 -16
  47. package/out/zero-cache/src/services/view-syncer/view-syncer.js.map +1 -1
  48. package/out/zero-client/src/client/version.js +1 -1
  49. package/out/zero-react/src/use-query.js +1 -1
  50. package/out/zero-react/src/use-query.js.map +1 -1
  51. package/out/zero-server/src/queries/process-queries.d.ts.map +1 -1
  52. package/out/zero-server/src/queries/process-queries.js +1 -1
  53. package/out/zero-server/src/queries/process-queries.js.map +1 -1
  54. package/package.json +1 -1
@@ -254,7 +254,7 @@ async function createSnapshotTransaction(lc, upstreamURI, db, slotNamePrefix) {
254
254
  watermark
255
255
  };
256
256
  } finally {
257
- await replicationSession.end();
257
+ await replicationSession.end({ timeout: 5 });
258
258
  }
259
259
  }
260
260
  function validateSchema(lc, tx, publications, bf, watermark) {
@@ -1 +1 @@
1
- {"version":3,"file":"backfill-stream.js","names":[],"sources":["../../../../../../../zero-cache/src/services/change-source/pg/backfill-stream.ts"],"sourcesContent":["import {\n PG_UNDEFINED_COLUMN,\n PG_UNDEFINED_TABLE,\n} from '@drdgvhbh/postgres-error-codes';\nimport type {LogContext} from '@rocicorp/logger';\nimport postgres from 'postgres';\nimport {assert} from '../../../../../shared/src/asserts.ts';\nimport {must} from '../../../../../shared/src/must.ts';\nimport {Queue} from '../../../../../shared/src/queue.ts';\nimport {randomCharacters} from '../../../../../shared/src/random-values.ts';\nimport {equals} from '../../../../../shared/src/set-utils.ts';\nimport * as v from '../../../../../shared/src/valita.ts';\nimport {READONLY} from '../../../db/mode-enum.ts';\nimport {\n BinaryCopyParser,\n hasBinaryDecoder,\n makeBinaryDecoder,\n textCastDecoder,\n} from '../../../db/pg-copy-binary.ts';\nimport {TsvParser} from '../../../db/pg-copy.ts';\nimport {getTypeParsers} from '../../../db/pg-type-parser.ts';\nimport type {PublishedTableSpec} from '../../../db/specs.ts';\nimport {importSnapshot, TransactionPool} from '../../../db/transaction-pool.ts';\nimport {connectPgClient, pgClient, type PostgresDB} from '../../../types/pg.ts';\nimport {id} from '../../../types/sql.ts';\nimport {\n SchemaIncompatibilityError,\n type BackfillMessage,\n} from '../common/backfill-manager.ts';\nimport {resumePoint} from '../protocol/backfill-progress.ts';\nimport type {\n BackfillCompleted,\n BackfillProgressMark,\n BackfillRequest,\n DownloadStatus,\n JSONValue,\n} from '../protocol/current.ts';\nimport {\n columnMetadataSchema,\n tableMetadataSchema,\n} from './backfill-metadata.ts';\nimport {\n makeBinarySelectExprs,\n makeDownloadStatements,\n type DownloadStatements,\n} from './initial-sync.ts';\nimport {toStateVersionString} from './lsn.ts';\nimport {createReplicationSlot} from './replication-slots.ts';\nimport {getPublicationInfo} from './schema/published.ts';\nimport type {Replica} from './schema/shard.ts';\n\ntype BackfillParams = Omit<BackfillCompleted, 'tag' | 'progressMarks'>;\n\n/**\n * Determines how the progress of a backfill is marked, and from where it is\n * resumed.\n */\ntype Progress = {\n /**\n * The timeline of the progress marks. For plain tables, this is the table's\n * `relfilenode`, which identifies the physical \"generation\" of the table's\n * data (changed by heap rewrites like `VACUUM FULL`, `CLUSTER`, or\n * `TRUNCATE`), within which `ctid`s are comparable.\n */\n timeline: string;\n\n /**\n * Whether the progress marks are resumable `ctid`s. Tables without\n * (their own) storage, such as partitioned tables, are not resumable and\n * are instead marked with the number of rows streamed, on a timeline that\n * is unique to the run.\n */\n resumable: boolean;\n\n /** The mark from which the backfill resumes, if any. */\n start: BackfillProgressMark | undefined;\n};\n\n/**\n * Encodes a `ctid` text representation, e.g. `(123,4)`, as a\n * fixed-width string that sorts lexicographically in `ctid` order.\n */\nconst CTID_RE = /^\\((\\d+),(\\d+)\\)$/;\nconst CTID_MARK_RE = /^(\\d{10})\\.(\\d{5})$/;\n\nexport function ctidToProgressMark(ctid: string): string {\n const match = CTID_RE.exec(ctid);\n assert(match, () => `invalid ctid: ${ctid}`);\n return `${match[1].padStart(10, '0')}.${match[2].padStart(5, '0')}`;\n}\n\n/** The inverse of {@link ctidToProgressMark}, as a `tid` literal. */\nexport function progressMarkToCtid(mark: string): string {\n const match = CTID_MARK_RE.exec(mark);\n assert(match, () => `invalid ctid progress mark: ${mark}`);\n return `(${Number(match[1])},${Number(match[2])})`;\n}\n\nfunction progressMarks(\n previous: BackfillProgressMark | undefined,\n current: BackfillProgressMark,\n) {\n return previous ? {previous, current} : {current};\n}\n\n/** The `ctid` of each row is appended as the last column of the download. */\nconst CTID_SELECT = 'ctid::text';\n\n/**\n * Settings that ensure that the COPY scans the table in physical (i.e.\n * `ctid`) order, which is necessary for the progress marks of a resumable\n * backfill to be meaningful. (Monotonicity is nonetheless verified as rows\n * are streamed.)\n *\n * * Synchronized seqscans can start in the middle of the table.\n * * Parallel scans interleave the rows of different workers.\n * * Index scans follow index order. (A publication row filter could\n * otherwise be satisfied with an index.)\n */\nconst PHYSICAL_ORDER_SETTINGS = [\n 'synchronize_seqscans = off',\n 'max_parallel_workers_per_gather = 0',\n 'enable_indexscan = off',\n 'enable_indexonlyscan = off',\n 'enable_bitmapscan = off',\n];\n\nfunction rowCountMark(rows: number): string {\n return String(rows).padStart(16, '0');\n}\n\ntype StreamOptions = {\n /**\n * The number of bytes at which to flush a batch of rows in a\n * backfill message. Defaults to Node's getDefaultHighWatermark().\n */\n flushThresholdBytes?: number | undefined;\n\n /**\n * Use text-format COPY instead of binary COPY.\n * Binary is faster and handles all types (unknown types are cast to\n * `::text` in the SELECT). This flag exists as an escape hatch to\n * revert to the old code path if needed.\n */\n textCopy?: boolean | undefined;\n\n /**\n * @visibleForTesting\n * Called after the snapshot transaction is opened, before the table is\n * locked, e.g. to run a concurrent heap rewrite in between.\n */\n afterSnapshotForTesting?: (() => Promise<void>) | undefined;\n};\n\n// The size of chunks that Postgres sends on COPY stream.\n// This happens to match NodeJS's getDefaultHighWatermark()\n// (for Node v20+).\nconst POSTGRES_COPY_CHUNK_SIZE = 64 * 1024;\n\n// Matches the exact clauses emitted by makeDownloadStatements; quoted\n// identifiers like \"limit\" won't match because they lack the surrounding\n// whitespace.\nconst SAMPLE_OR_LIMIT_RE = /\\sTABLESAMPLE\\s+BERNOULLI\\b|\\sLIMIT\\s+\\d/i;\n\n/**\n * Streams a series of `backfill` messages (ending with `backfill-complete`)\n * at a set watermark (i.e. LSN). The data is retrieved via a COPY stream\n * made at a transaction snapshot corresponding to specific LSN, obtained by\n * creating a short-lived replication slot.\n */\nexport async function* streamBackfill(\n lc: LogContext,\n upstreamURI: string,\n {slot, publications}: Pick<Replica, 'slot' | 'publications'>,\n bf: BackfillRequest,\n opts: StreamOptions = {},\n): AsyncGenerator<BackfillMessage> {\n lc = lc\n .withContext('component', 'backfill')\n .withContext('table', bf.table.name);\n\n const {\n flushThresholdBytes = POSTGRES_COPY_CHUNK_SIZE,\n textCopy = false,\n afterSnapshotForTesting,\n } = opts;\n const db = await connectPgClient(lc, upstreamURI, 'backfill-stream', {\n // The COPY is a single stream that must outlive the entire table download,\n // so allow a very long (24h) connection lifetime.\n ['max_lifetime']: 24 * 60 * 60,\n // A backfill COPY is a background process that can be preempted by\n // upstream replication changes, potentially requiring it to sit idle for\n // long stretches. Use TCP keepalive to detect dead connections instead\n // of the inactivity watchdog, which could kill connnections that are\n // taking a back seat for upstream replication changes.\n liveness: 'keepalive',\n });\n let tx: TransactionPool | undefined;\n let watermark: string;\n try {\n ({tx, watermark} = await createSnapshotTransaction(\n lc,\n upstreamURI,\n db,\n slot,\n ));\n await afterSnapshotForTesting?.();\n const {tableSpec, backfill, progress} = await validateSchema(\n lc,\n tx,\n publications,\n bf,\n watermark,\n );\n\n // Note: validateSchema ensures that the rowKey and columns are disjoint\n const {relation, columns} = backfill;\n const cols = [...relation.rowKey.columns, ...columns];\n // Resumable backfills are streamed in physical (i.e. ctid) order, which\n // is the natural order of a sequential or TID range scan (as enforced by\n // the settings in `stream()`). An ORDER BY is not used, as Postgres does\n // not consider either scan to be ordered, and would unnecessarily sort\n // the table.\n const order =\n progress.resumable && progress.start\n ? {\n after: /*sql*/ `ctid > '${progressMarkToCtid(progress.start.progressMark)}'::tid`,\n }\n : undefined;\n const stmts = makeDownloadStatements(\n tableSpec,\n cols,\n undefined,\n undefined,\n [...cols.map(col => id(col)), CTID_SELECT],\n order,\n );\n if (progress.start) {\n lc.info?.(\n `resuming backfill from ${progress.start.progressMark} ` +\n `(timeline ${progress.timeline})`,\n );\n }\n\n if (textCopy) {\n const types = await getTypeParsers(db, {returnJsonAsString: true});\n yield* stream(\n lc,\n tx,\n backfill,\n progress,\n stmts,\n `COPY (${stmts.select}) TO STDOUT`,\n new TsvParser(),\n cols.map(col => {\n const parser = types.getTypeParser(tableSpec.columns[col].typeOID);\n return (text: string) => parser(text) as JSONValue;\n }),\n (text: string) => text,\n flushThresholdBytes,\n );\n } else {\n const binaryStmts = makeDownloadStatements(\n tableSpec,\n cols,\n undefined,\n undefined,\n [...makeBinarySelectExprs(tableSpec, cols), CTID_SELECT],\n order,\n );\n\n yield* stream(\n lc,\n tx,\n backfill,\n progress,\n stmts,\n `COPY (${binaryStmts.select}) TO STDOUT WITH (FORMAT binary)`,\n new BinaryCopyParser(),\n cols.map(col => {\n const spec = tableSpec.columns[col];\n const decoder = hasBinaryDecoder(spec)\n ? makeBinaryDecoder(spec)\n : textCastDecoder;\n return (buf: Buffer) => decoder(buf) as unknown as JSONValue;\n }),\n (buf: Buffer) => buf.toString('utf8'), // ctid::text\n flushThresholdBytes,\n );\n }\n } catch (e) {\n // Although we make the best effort to validate the schema at the\n // transaction snapshot, certain forms of `ALTER TABLE` are not\n // MVCC safe and not \"frozen\" in the snapshot:\n //\n // https://www.postgresql.org/docs/current/mvcc-caveats.html\n //\n // Handle these errors as schema incompatibility errors rather than\n // unknown runtime errors.\n if (\n e instanceof postgres.PostgresError &&\n (e.code === PG_UNDEFINED_TABLE || e.code === PG_UNDEFINED_COLUMN)\n ) {\n throw new SchemaIncompatibilityError(bf, String(e), {cause: e});\n }\n throw e;\n } finally {\n tx?.setDone();\n // Workaround postgres.js hanging at the end of some COPY commands:\n // https://github.com/porsager/postgres/issues/499\n void db.end().catch(e => lc.warn?.(`error closing backfill connection`, e));\n }\n}\n\nasync function* stream<T>(\n lc: LogContext,\n tx: TransactionPool,\n backfill: BackfillParams,\n {timeline, resumable, start: startMark}: Progress,\n {\n getTotalRows,\n getTotalBytes,\n }: Pick<DownloadStatements, 'getTotalRows' | 'getTotalBytes'>,\n copyCommand: string,\n parser: {parse(chunk: Buffer): Iterable<T | null>},\n decoders: ((field: T) => JSONValue)[],\n ctidDecoder: (field: T) => string,\n flushThresholdBytes: number,\n): AsyncGenerator<BackfillMessage> {\n // Backfill must read every row: TABLESAMPLE / LIMIT are reserved for shadow\n // sync and must never appear in a backfill COPY.\n assert(\n !SAMPLE_OR_LIMIT_RE.test(copyCommand),\n `backfill COPY must not sample or limit: ${copyCommand}`,\n );\n const start = performance.now();\n const [rows, bytes] = await tx.processReadTask(sql =>\n Promise.all([\n sql.unsafe<{totalRows: bigint}[]>(getTotalRows),\n sql.unsafe<{totalBytes: bigint}[]>(getTotalBytes),\n ]),\n );\n const status: DownloadStatus = {\n rows: 0,\n totalRows: Number(rows[0].totalRows),\n totalBytes: Number(bytes[0].totalBytes),\n };\n\n let elapsed = (performance.now() - start).toFixed(3);\n lc.info?.(\n `Computed total rows and bytes for: ${copyCommand} (${elapsed} ms)`,\n {\n status,\n },\n );\n\n // Drain the COPY stream from *within* a single read task so that the\n // TransactionPool worker holds the transaction for the entire duration of\n // the COPY, rather than incorrectly considering the worker \"idle\" and\n // attempting to send keepalives on it.\n //\n // Chunks are bridged out to this generator via a Queue, with a corresponding\n // acks Queue providing strict one-chunk-at-a-time backpressure. This is\n // necessary because it is not possible to \"yield\" from within the read task.\n const chunks = new Queue<Buffer | 'done'>();\n const acks = new Queue<void>();\n\n const copyDone = tx.processReadTask(async sql => {\n // SET LOCAL applies to the (snapshot) transaction of this read task.\n for (const setting of PHYSICAL_ORDER_SETTINGS) {\n await sql.unsafe(`SET LOCAL ${setting}`);\n }\n const readable = await sql.unsafe(copyCommand).readable();\n for await (const chunk of readable) {\n chunks.enqueue(chunk as Buffer);\n await acks.dequeue(); // wait for this generator to consume the chunk\n }\n chunks.enqueue('done');\n });\n copyDone.catch(e => chunks.enqueueRejection(e));\n\n let totalBytes = 0;\n let totalMsgs = 0;\n let rowValues: JSONValue[][] = [];\n let bufferedBytes = 0;\n\n const logFlushed = () => {\n lc.debug?.(\n `Flushed ${rowValues.length} rows, ${bufferedBytes} bytes ` +\n `(total: rows=${status.rows}, msgs=${totalMsgs}, bytes=${totalBytes})`,\n );\n };\n\n // The mark of the last row that was streamed, and that of the last row of\n // the previous message.\n let current: BackfillProgressMark | undefined = startMark;\n let previous: BackfillProgressMark | undefined = startMark;\n const markOf = (progressMark: string) => ({progressMark, timeline});\n\n // The number of columns of each row, followed by the ctid.\n const numCols = decoders.length + 1;\n\n // Tracks the row being parsed.\n let row: JSONValue[] = Array.from({length: decoders.length});\n let col = 0;\n\n for (;;) {\n const chunk = await chunks.dequeue();\n if (chunk === 'done') {\n break;\n }\n for (const field of parser.parse(chunk)) {\n if (col < decoders.length) {\n row[col] = field === null ? null : decoders[col](field);\n } else {\n const mark = resumable\n ? ctidToProgressMark(ctidDecoder(field as T))\n : rowCountMark(status.rows + 1);\n if (current !== undefined && mark <= current.progressMark) {\n // This should not happen given the PHYSICAL_ORDER_SETTINGS, but\n // the correctness of the progress marks depends on it.\n throw new Error(\n `backfill rows are not in ctid order ` +\n `(${mark} after ${current.progressMark})`,\n );\n }\n current = markOf(mark);\n }\n\n if (++col === numCols) {\n rowValues.push(row);\n status.rows++;\n row = Array.from({length: decoders.length});\n col = 0;\n }\n }\n bufferedBytes += chunk.byteLength;\n totalBytes += chunk.byteLength;\n\n if (bufferedBytes >= flushThresholdBytes && rowValues.length > 0) {\n yield {\n message: {\n tag: 'backfill',\n ...backfill,\n rowValues,\n status,\n progressMarks: progressMarks(previous, must(current)),\n },\n byteSize: bufferedBytes,\n };\n previous = current;\n totalMsgs++;\n logFlushed();\n rowValues = [];\n bufferedBytes = 0;\n }\n\n // Signal the read task to pull the next chunk (one-chunk backpressure).\n acks.enqueue();\n }\n\n // Surface any error from the COPY read task (and confirm clean completion).\n await copyDone;\n\n // Flush the last batch of rows.\n if (rowValues.length > 0) {\n yield {\n message: {\n tag: 'backfill',\n ...backfill,\n rowValues,\n status,\n progressMarks: progressMarks(previous, must(current)),\n },\n byteSize: bufferedBytes,\n };\n previous = current;\n totalMsgs++;\n logFlushed();\n }\n\n yield {\n message: {\n tag: 'backfill-completed',\n ...backfill,\n status,\n // `previous` is only unset for a backfill from scratch with no rows.\n progressMarks: previous ? {previous} : {},\n },\n byteSize: 0,\n };\n elapsed = (performance.now() - start).toFixed(3);\n lc.info?.(\n `Finished streaming ${status.rows} rows, ${totalMsgs} msgs, ${totalBytes} bytes ` +\n `(${elapsed} ms)`,\n );\n}\n\n/**\n * Creates (and drops) a replication slot in order to obtain a snapshot\n * that corresponds with a specific LSN. Sets the snapshot on the\n * TransactionPool and returns the watermark corresponding to the LSN.\n *\n * (Note that PG's other LSN-related functions are not scoped to a\n * transaction; this is the only way to get set a transaction at a specific\n * LSN.)\n */\nasync function createSnapshotTransaction(\n lc: LogContext,\n upstreamURI: string,\n db: PostgresDB,\n slotNamePrefix: string,\n) {\n const replicationSession = pgClient(\n lc,\n upstreamURI,\n 'backfill-replication-session',\n {\n ['fetch_types']: false, // Necessary for the streaming protocol\n connection: {replication: 'database'}, // https://www.postgresql.org/docs/current/protocol-replication.html\n },\n );\n const slotName = `${slotNamePrefix}_bf_${Date.now()}`;\n try {\n const {snapshot_name: snapshot, consistent_point: lsn} =\n await createReplicationSlot(lc, replicationSession, {\n slotName,\n temporary: true, // deletes the slot when the replicationSession ends\n diagnosticsDB: db,\n });\n\n const {init, imported} = importSnapshot(snapshot);\n const tx = new TransactionPool(lc, {mode: READONLY, init}).run(db);\n await imported.dequeue();\n\n const watermark = toStateVersionString(lsn);\n lc.info?.(`Opened snapshot transaction at LSN ${lsn} (${watermark})`);\n return {tx, watermark};\n } finally {\n await replicationSession.end();\n }\n}\n\nfunction validateSchema(\n lc: LogContext,\n tx: TransactionPool,\n publications: string[],\n bf: BackfillRequest,\n watermark: string,\n): Promise<{\n tableSpec: PublishedTableSpec;\n backfill: BackfillParams;\n progress: Progress;\n}> {\n return tx.processReadTask(async sql => {\n const {tables} = await getPublicationInfo(sql, publications);\n const spec = tables.find(\n spec => spec.schema === bf.table.schema && spec.name === bf.table.name,\n );\n if (!spec) {\n throw new SchemaIncompatibilityError(\n bf,\n `Table has been renamed or dropped`,\n );\n }\n const tableMeta = v.parse(bf.table.metadata, tableMetadataSchema);\n if (spec.schemaOID !== tableMeta.schemaOID) {\n throw new SchemaIncompatibilityError(\n bf,\n `Schema no longer corresponds to the original schema`,\n );\n }\n if (spec.oid !== tableMeta.relationOID) {\n throw new SchemaIncompatibilityError(\n bf,\n `Table no longer corresponds to the original table`,\n );\n }\n if (\n !equals(\n new Set(Object.keys(tableMeta.rowKey)),\n new Set(spec.replicaIdentityColumns),\n )\n ) {\n throw new SchemaIncompatibilityError(\n bf,\n 'Row key (e.g. PRIMARY KEY or INDEX) has changed',\n );\n }\n const allCols = [\n ...Object.entries(tableMeta.rowKey),\n ...Object.entries(bf.columns).map(([col, {id}]) => [col, id] as const),\n ];\n for (const [col, val] of allCols) {\n const colSpec = spec.columns[col];\n if (!colSpec) {\n throw new SchemaIncompatibilityError(\n bf,\n `Column ${col} has been renamed or dropped`,\n );\n }\n const colMeta = v.parse(val, columnMetadataSchema);\n if (colMeta.attNum !== colSpec.pos) {\n throw new SchemaIncompatibilityError(\n bf,\n `Column ${col} no longer corresponds to the original column`,\n );\n }\n }\n const backfill: BackfillParams = {\n relation: {\n schema: bf.table.schema,\n name: bf.table.name,\n rowKey: {columns: Object.keys(tableMeta.rowKey)},\n },\n columns: Object.keys(bf.columns).filter(\n col => !(col in tableMeta.rowKey),\n ),\n watermark,\n };\n const progress = await getProgress(lc, sql, spec.oid, bf);\n return {tableSpec: spec, backfill, progress};\n });\n}\n\n/**\n * Determines the timeline of the table's progress marks (as of the snapshot),\n * and whether the backfill can be resumed from the requested progress.\n *\n * The table is locked (in ACCESS SHARE mode, as the COPY would, for the rest\n * of the transaction) so that its storage cannot be rewritten (by `VACUUM\n * FULL`, `CLUSTER`, `TRUNCATE`, or a rewriting `ALTER TABLE`, all of which\n * require ACCESS EXCLUSIVE) between reading the timeline and the COPY. Note\n * that `pg_relation_filenode()` reflects the table's current storage (only\n * refreshed upon acquiring a lock), whereas `pg_class.relfilenode` is read at\n * the transaction snapshot. If they differ, the storage was rewritten after the\n * snapshot, and the COPY would scan the new storage with the old snapshot,\n * which is not MVCC-safe for all rewrites (e.g. the table appears empty after\n * a rewriting `ALTER TABLE`). The backfill is then retried at a new snapshot.\n */\nasync function getProgress(\n lc: LogContext,\n sql: postgres.Sql,\n relationOID: number,\n bf: BackfillRequest,\n): Promise<Progress> {\n const table = `${id(bf.table.schema)}.${id(bf.table.name)}`;\n await sql.unsafe(`LOCK TABLE ${table} IN ACCESS SHARE MODE`);\n const [{lockedOID, relkind, snapshotFilenode, filenode}] = await sql<\n {\n lockedOID: number | null;\n relkind: string;\n snapshotFilenode: string;\n filenode: string | null;\n }[]\n >`\n SELECT to_regclass(${table})::oid::int8 AS \"lockedOID\",\n relkind,\n relfilenode::text AS \"snapshotFilenode\",\n pg_relation_filenode(oid)::text AS filenode\n FROM pg_class WHERE oid = ${relationOID}`;\n if (Number(lockedOID) !== relationOID) {\n // The (current) table with the name is not the one at the snapshot.\n throw new SchemaIncompatibilityError(\n bf,\n `Table has been renamed or replaced`,\n );\n }\n if (relkind !== 'r' || filenode === null) {\n // Not a plain table (e.g. a partitioned table), for which ctids are not\n // unique. Such backfills are not resumable, which is achieved by using a\n // timeline that is unique to the run.\n return {\n timeline: `${relkind}:${randomCharacters(12)}`,\n resumable: false,\n start: undefined,\n };\n }\n if (filenode !== snapshotFilenode) {\n throw new Error(\n `${bf.table.schema}.${bf.table.name} was rewritten after the backfill ` +\n `snapshot (relfilenode ${snapshotFilenode} => ${filenode})`,\n );\n }\n const timeline = filenode;\n const requested = resumePoint(bf);\n if (requested && requested.timeline !== timeline) {\n lc.info?.(\n `restarting backfill from scratch: the requested progress is on ` +\n `timeline ${requested.timeline} but the table is on ${timeline}`,\n );\n return {timeline, resumable: true, start: undefined};\n }\n if (requested && !(await canResume(sql, relationOID, bf))) {\n lc.info?.(\n `restarting backfill from scratch: the table has TOASTed values ` +\n `and backfilling columns [${Object.keys(bf.columns).join(',')}] ` +\n `are TOAST-able`,\n );\n return {timeline, resumable: true, start: undefined};\n }\n return {timeline, resumable: true, start: requested};\n}\n\n/**\n * Whether resuming a backfill (in a new snapshot) is guaranteed to deliver\n * the values of every row that the preceding snapshot(s) had not reached.\n *\n * Within a single snapshot, every row is scanned exactly once, since the\n * version visible to the snapshot stays in place while it is held. Across\n * snapshots, however, a row that had not yet been reached (i.e. beyond the\n * resume mark `M`) can be updated between the snapshots, with its new\n * version placed at or before `M` (a non-HOT update into free space in an\n * earlier page, or a HOT update into a lower line pointer of `M`'s page).\n * Neither snapshot then scans the row, and it is only delivered by the\n * replication stream. The stream's UPDATE contains the full row, _except_\n * for unchanged values stored out-of-line in TOAST, which pgoutput omits\n * (unless the table has REPLICA IDENTITY FULL). A backfilling column with\n * such a value would be published without it.\n *\n * Resuming is therefore only done if no backfilling column can be TOASTed\n * (i.e. all have `attstorage = 'p'`, as for fixed-width types), or if the\n * table has no TOAST data. The latter is safe because an omitted value must\n * still be in the TOAST relation at the new snapshot (unless its row has since\n * been deleted, which the replication stream does deliver), and conservative\n * because the TOAST relation does not shrink to empty without a heap rewrite,\n * which changes the timeline.\n *\n * Potential optimization for TOAST-able backfills: have the resumed run also\n * deliver the rows at or before `M` that were modified since the snapshot of\n * the run that started the backfill from scratch. That run's snapshot `xmin`\n * (`T_origin`, as an xid8) would be encoded in the timeline, i.e.\n * `timeline = \"<relfilenode>:<T_origin>\"`, and carried over (rather than\n * replaced) by resumed runs, so that:\n * * Within a chain of runs, every row version with `xmin < T_origin` stayed\n * in place across all of the runs' snapshots and was therefore scanned\n * exactly once, and all other rows are re-delivered by the resumed run\n * (`ctid > M OR xmin >= T_origin`).\n * * Continuations from a different chain (i.e. with a newer `T_origin`) are\n * not accepted by subscribers, and requests from different chains are\n * merged into a backfill from scratch, as the timelines differ. Note that\n * using the _latest_ `T` would be incorrect: rows that moved before `M`\n * between the earlier snapshots would never be re-delivered.\n * The resumed run would scan the whole table (but only deliver the modified\n * rows), comparing the 32-bit `xmin` with `T_origin` modulo 2^32, which\n * requires falling back to a backfill from scratch once the current xid is\n * 2^31 or more past `T_origin` (i.e. wraparound). This also relies on the\n * `xmin` system column returning the raw xmin of frozen tuples, which should\n * be verified.\n */\nasync function canResume(\n sql: postgres.Sql,\n relationOID: number,\n bf: BackfillRequest,\n): Promise<boolean> {\n const [{toastable, toasted}] = await sql<\n {toastable: boolean; toasted: boolean}[]\n >`\n SELECT\n EXISTS (\n SELECT 1 FROM pg_attribute\n WHERE attrelid = ${relationOID}\n AND attname IN ${sql(Object.keys(bf.columns))}\n AND attstorage <> 'p'\n ) AS toastable,\n reltoastrelid <> 0 AND pg_relation_size(reltoastrelid) > 0 AS toasted\n FROM pg_class WHERE oid = ${relationOID}`;\n return !toastable || !toasted;\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"backfill-stream.js","names":[],"sources":["../../../../../../../zero-cache/src/services/change-source/pg/backfill-stream.ts"],"sourcesContent":["import {\n PG_UNDEFINED_COLUMN,\n PG_UNDEFINED_TABLE,\n} from '@drdgvhbh/postgres-error-codes';\nimport type {LogContext} from '@rocicorp/logger';\nimport postgres from 'postgres';\nimport {assert} from '../../../../../shared/src/asserts.ts';\nimport {must} from '../../../../../shared/src/must.ts';\nimport {Queue} from '../../../../../shared/src/queue.ts';\nimport {randomCharacters} from '../../../../../shared/src/random-values.ts';\nimport {equals} from '../../../../../shared/src/set-utils.ts';\nimport * as v from '../../../../../shared/src/valita.ts';\nimport {READONLY} from '../../../db/mode-enum.ts';\nimport {\n BinaryCopyParser,\n hasBinaryDecoder,\n makeBinaryDecoder,\n textCastDecoder,\n} from '../../../db/pg-copy-binary.ts';\nimport {TsvParser} from '../../../db/pg-copy.ts';\nimport {getTypeParsers} from '../../../db/pg-type-parser.ts';\nimport type {PublishedTableSpec} from '../../../db/specs.ts';\nimport {importSnapshot, TransactionPool} from '../../../db/transaction-pool.ts';\nimport {connectPgClient, pgClient, type PostgresDB} from '../../../types/pg.ts';\nimport {id} from '../../../types/sql.ts';\nimport {\n SchemaIncompatibilityError,\n type BackfillMessage,\n} from '../common/backfill-manager.ts';\nimport {resumePoint} from '../protocol/backfill-progress.ts';\nimport type {\n BackfillCompleted,\n BackfillProgressMark,\n BackfillRequest,\n DownloadStatus,\n JSONValue,\n} from '../protocol/current.ts';\nimport {\n columnMetadataSchema,\n tableMetadataSchema,\n} from './backfill-metadata.ts';\nimport {\n makeBinarySelectExprs,\n makeDownloadStatements,\n type DownloadStatements,\n} from './initial-sync.ts';\nimport {toStateVersionString} from './lsn.ts';\nimport {createReplicationSlot} from './replication-slots.ts';\nimport {getPublicationInfo} from './schema/published.ts';\nimport type {Replica} from './schema/shard.ts';\n\ntype BackfillParams = Omit<BackfillCompleted, 'tag' | 'progressMarks'>;\n\n/**\n * Determines how the progress of a backfill is marked, and from where it is\n * resumed.\n */\ntype Progress = {\n /**\n * The timeline of the progress marks. For plain tables, this is the table's\n * `relfilenode`, which identifies the physical \"generation\" of the table's\n * data (changed by heap rewrites like `VACUUM FULL`, `CLUSTER`, or\n * `TRUNCATE`), within which `ctid`s are comparable.\n */\n timeline: string;\n\n /**\n * Whether the progress marks are resumable `ctid`s. Tables without\n * (their own) storage, such as partitioned tables, are not resumable and\n * are instead marked with the number of rows streamed, on a timeline that\n * is unique to the run.\n */\n resumable: boolean;\n\n /** The mark from which the backfill resumes, if any. */\n start: BackfillProgressMark | undefined;\n};\n\n/**\n * Encodes a `ctid` text representation, e.g. `(123,4)`, as a\n * fixed-width string that sorts lexicographically in `ctid` order.\n */\nconst CTID_RE = /^\\((\\d+),(\\d+)\\)$/;\nconst CTID_MARK_RE = /^(\\d{10})\\.(\\d{5})$/;\n\nexport function ctidToProgressMark(ctid: string): string {\n const match = CTID_RE.exec(ctid);\n assert(match, () => `invalid ctid: ${ctid}`);\n return `${match[1].padStart(10, '0')}.${match[2].padStart(5, '0')}`;\n}\n\n/** The inverse of {@link ctidToProgressMark}, as a `tid` literal. */\nexport function progressMarkToCtid(mark: string): string {\n const match = CTID_MARK_RE.exec(mark);\n assert(match, () => `invalid ctid progress mark: ${mark}`);\n return `(${Number(match[1])},${Number(match[2])})`;\n}\n\nfunction progressMarks(\n previous: BackfillProgressMark | undefined,\n current: BackfillProgressMark,\n) {\n return previous ? {previous, current} : {current};\n}\n\n/** The `ctid` of each row is appended as the last column of the download. */\nconst CTID_SELECT = 'ctid::text';\n\n/**\n * Settings that ensure that the COPY scans the table in physical (i.e.\n * `ctid`) order, which is necessary for the progress marks of a resumable\n * backfill to be meaningful. (Monotonicity is nonetheless verified as rows\n * are streamed.)\n *\n * * Synchronized seqscans can start in the middle of the table.\n * * Parallel scans interleave the rows of different workers.\n * * Index scans follow index order. (A publication row filter could\n * otherwise be satisfied with an index.)\n */\nconst PHYSICAL_ORDER_SETTINGS = [\n 'synchronize_seqscans = off',\n 'max_parallel_workers_per_gather = 0',\n 'enable_indexscan = off',\n 'enable_indexonlyscan = off',\n 'enable_bitmapscan = off',\n];\n\nfunction rowCountMark(rows: number): string {\n return String(rows).padStart(16, '0');\n}\n\ntype StreamOptions = {\n /**\n * The number of bytes at which to flush a batch of rows in a\n * backfill message. Defaults to Node's getDefaultHighWatermark().\n */\n flushThresholdBytes?: number | undefined;\n\n /**\n * Use text-format COPY instead of binary COPY.\n * Binary is faster and handles all types (unknown types are cast to\n * `::text` in the SELECT). This flag exists as an escape hatch to\n * revert to the old code path if needed.\n */\n textCopy?: boolean | undefined;\n\n /**\n * @visibleForTesting\n * Called after the snapshot transaction is opened, before the table is\n * locked, e.g. to run a concurrent heap rewrite in between.\n */\n afterSnapshotForTesting?: (() => Promise<void>) | undefined;\n};\n\n// The size of chunks that Postgres sends on COPY stream.\n// This happens to match NodeJS's getDefaultHighWatermark()\n// (for Node v20+).\nconst POSTGRES_COPY_CHUNK_SIZE = 64 * 1024;\n\n// Matches the exact clauses emitted by makeDownloadStatements; quoted\n// identifiers like \"limit\" won't match because they lack the surrounding\n// whitespace.\nconst SAMPLE_OR_LIMIT_RE = /\\sTABLESAMPLE\\s+BERNOULLI\\b|\\sLIMIT\\s+\\d/i;\n\n/**\n * Streams a series of `backfill` messages (ending with `backfill-complete`)\n * at a set watermark (i.e. LSN). The data is retrieved via a COPY stream\n * made at a transaction snapshot corresponding to specific LSN, obtained by\n * creating a short-lived replication slot.\n */\nexport async function* streamBackfill(\n lc: LogContext,\n upstreamURI: string,\n {slot, publications}: Pick<Replica, 'slot' | 'publications'>,\n bf: BackfillRequest,\n opts: StreamOptions = {},\n): AsyncGenerator<BackfillMessage> {\n lc = lc\n .withContext('component', 'backfill')\n .withContext('table', bf.table.name);\n\n const {\n flushThresholdBytes = POSTGRES_COPY_CHUNK_SIZE,\n textCopy = false,\n afterSnapshotForTesting,\n } = opts;\n const db = await connectPgClient(lc, upstreamURI, 'backfill-stream', {\n // The COPY is a single stream that must outlive the entire table download,\n // so allow a very long (24h) connection lifetime.\n ['max_lifetime']: 24 * 60 * 60,\n // A backfill COPY is a background process that can be preempted by\n // upstream replication changes, potentially requiring it to sit idle for\n // long stretches. Use TCP keepalive to detect dead connections instead\n // of the inactivity watchdog, which could kill connnections that are\n // taking a back seat for upstream replication changes.\n liveness: 'keepalive',\n });\n let tx: TransactionPool | undefined;\n let watermark: string;\n try {\n ({tx, watermark} = await createSnapshotTransaction(\n lc,\n upstreamURI,\n db,\n slot,\n ));\n await afterSnapshotForTesting?.();\n const {tableSpec, backfill, progress} = await validateSchema(\n lc,\n tx,\n publications,\n bf,\n watermark,\n );\n\n // Note: validateSchema ensures that the rowKey and columns are disjoint\n const {relation, columns} = backfill;\n const cols = [...relation.rowKey.columns, ...columns];\n // Resumable backfills are streamed in physical (i.e. ctid) order, which\n // is the natural order of a sequential or TID range scan (as enforced by\n // the settings in `stream()`). An ORDER BY is not used, as Postgres does\n // not consider either scan to be ordered, and would unnecessarily sort\n // the table.\n const order =\n progress.resumable && progress.start\n ? {\n after: /*sql*/ `ctid > '${progressMarkToCtid(progress.start.progressMark)}'::tid`,\n }\n : undefined;\n const stmts = makeDownloadStatements(\n tableSpec,\n cols,\n undefined,\n undefined,\n [...cols.map(col => id(col)), CTID_SELECT],\n order,\n );\n if (progress.start) {\n lc.info?.(\n `resuming backfill from ${progress.start.progressMark} ` +\n `(timeline ${progress.timeline})`,\n );\n }\n\n if (textCopy) {\n const types = await getTypeParsers(db, {returnJsonAsString: true});\n yield* stream(\n lc,\n tx,\n backfill,\n progress,\n stmts,\n `COPY (${stmts.select}) TO STDOUT`,\n new TsvParser(),\n cols.map(col => {\n const parser = types.getTypeParser(tableSpec.columns[col].typeOID);\n return (text: string) => parser(text) as JSONValue;\n }),\n (text: string) => text,\n flushThresholdBytes,\n );\n } else {\n const binaryStmts = makeDownloadStatements(\n tableSpec,\n cols,\n undefined,\n undefined,\n [...makeBinarySelectExprs(tableSpec, cols), CTID_SELECT],\n order,\n );\n\n yield* stream(\n lc,\n tx,\n backfill,\n progress,\n stmts,\n `COPY (${binaryStmts.select}) TO STDOUT WITH (FORMAT binary)`,\n new BinaryCopyParser(),\n cols.map(col => {\n const spec = tableSpec.columns[col];\n const decoder = hasBinaryDecoder(spec)\n ? makeBinaryDecoder(spec)\n : textCastDecoder;\n return (buf: Buffer) => decoder(buf) as unknown as JSONValue;\n }),\n (buf: Buffer) => buf.toString('utf8'), // ctid::text\n flushThresholdBytes,\n );\n }\n } catch (e) {\n // Although we make the best effort to validate the schema at the\n // transaction snapshot, certain forms of `ALTER TABLE` are not\n // MVCC safe and not \"frozen\" in the snapshot:\n //\n // https://www.postgresql.org/docs/current/mvcc-caveats.html\n //\n // Handle these errors as schema incompatibility errors rather than\n // unknown runtime errors.\n if (\n e instanceof postgres.PostgresError &&\n (e.code === PG_UNDEFINED_TABLE || e.code === PG_UNDEFINED_COLUMN)\n ) {\n throw new SchemaIncompatibilityError(bf, String(e), {cause: e});\n }\n throw e;\n } finally {\n tx?.setDone();\n // Workaround postgres.js hanging at the end of some COPY commands:\n // https://github.com/porsager/postgres/issues/499\n void db.end().catch(e => lc.warn?.(`error closing backfill connection`, e));\n }\n}\n\nasync function* stream<T>(\n lc: LogContext,\n tx: TransactionPool,\n backfill: BackfillParams,\n {timeline, resumable, start: startMark}: Progress,\n {\n getTotalRows,\n getTotalBytes,\n }: Pick<DownloadStatements, 'getTotalRows' | 'getTotalBytes'>,\n copyCommand: string,\n parser: {parse(chunk: Buffer): Iterable<T | null>},\n decoders: ((field: T) => JSONValue)[],\n ctidDecoder: (field: T) => string,\n flushThresholdBytes: number,\n): AsyncGenerator<BackfillMessage> {\n // Backfill must read every row: TABLESAMPLE / LIMIT are reserved for shadow\n // sync and must never appear in a backfill COPY.\n assert(\n !SAMPLE_OR_LIMIT_RE.test(copyCommand),\n `backfill COPY must not sample or limit: ${copyCommand}`,\n );\n const start = performance.now();\n const [rows, bytes] = await tx.processReadTask(sql =>\n Promise.all([\n sql.unsafe<{totalRows: bigint}[]>(getTotalRows),\n sql.unsafe<{totalBytes: bigint}[]>(getTotalBytes),\n ]),\n );\n const status: DownloadStatus = {\n rows: 0,\n totalRows: Number(rows[0].totalRows),\n totalBytes: Number(bytes[0].totalBytes),\n };\n\n let elapsed = (performance.now() - start).toFixed(3);\n lc.info?.(\n `Computed total rows and bytes for: ${copyCommand} (${elapsed} ms)`,\n {\n status,\n },\n );\n\n // Drain the COPY stream from *within* a single read task so that the\n // TransactionPool worker holds the transaction for the entire duration of\n // the COPY, rather than incorrectly considering the worker \"idle\" and\n // attempting to send keepalives on it.\n //\n // Chunks are bridged out to this generator via a Queue, with a corresponding\n // acks Queue providing strict one-chunk-at-a-time backpressure. This is\n // necessary because it is not possible to \"yield\" from within the read task.\n const chunks = new Queue<Buffer | 'done'>();\n const acks = new Queue<void>();\n\n const copyDone = tx.processReadTask(async sql => {\n // SET LOCAL applies to the (snapshot) transaction of this read task.\n for (const setting of PHYSICAL_ORDER_SETTINGS) {\n await sql.unsafe(`SET LOCAL ${setting}`);\n }\n const readable = await sql.unsafe(copyCommand).readable();\n for await (const chunk of readable) {\n chunks.enqueue(chunk as Buffer);\n await acks.dequeue(); // wait for this generator to consume the chunk\n }\n chunks.enqueue('done');\n });\n copyDone.catch(e => chunks.enqueueRejection(e));\n\n let totalBytes = 0;\n let totalMsgs = 0;\n let rowValues: JSONValue[][] = [];\n let bufferedBytes = 0;\n\n const logFlushed = () => {\n lc.debug?.(\n `Flushed ${rowValues.length} rows, ${bufferedBytes} bytes ` +\n `(total: rows=${status.rows}, msgs=${totalMsgs}, bytes=${totalBytes})`,\n );\n };\n\n // The mark of the last row that was streamed, and that of the last row of\n // the previous message.\n let current: BackfillProgressMark | undefined = startMark;\n let previous: BackfillProgressMark | undefined = startMark;\n const markOf = (progressMark: string) => ({progressMark, timeline});\n\n // The number of columns of each row, followed by the ctid.\n const numCols = decoders.length + 1;\n\n // Tracks the row being parsed.\n let row: JSONValue[] = Array.from({length: decoders.length});\n let col = 0;\n\n for (;;) {\n const chunk = await chunks.dequeue();\n if (chunk === 'done') {\n break;\n }\n for (const field of parser.parse(chunk)) {\n if (col < decoders.length) {\n row[col] = field === null ? null : decoders[col](field);\n } else {\n const mark = resumable\n ? ctidToProgressMark(ctidDecoder(field as T))\n : rowCountMark(status.rows + 1);\n if (current !== undefined && mark <= current.progressMark) {\n // This should not happen given the PHYSICAL_ORDER_SETTINGS, but\n // the correctness of the progress marks depends on it.\n throw new Error(\n `backfill rows are not in ctid order ` +\n `(${mark} after ${current.progressMark})`,\n );\n }\n current = markOf(mark);\n }\n\n if (++col === numCols) {\n rowValues.push(row);\n status.rows++;\n row = Array.from({length: decoders.length});\n col = 0;\n }\n }\n bufferedBytes += chunk.byteLength;\n totalBytes += chunk.byteLength;\n\n if (bufferedBytes >= flushThresholdBytes && rowValues.length > 0) {\n yield {\n message: {\n tag: 'backfill',\n ...backfill,\n rowValues,\n status,\n progressMarks: progressMarks(previous, must(current)),\n },\n byteSize: bufferedBytes,\n };\n previous = current;\n totalMsgs++;\n logFlushed();\n rowValues = [];\n bufferedBytes = 0;\n }\n\n // Signal the read task to pull the next chunk (one-chunk backpressure).\n acks.enqueue();\n }\n\n // Surface any error from the COPY read task (and confirm clean completion).\n await copyDone;\n\n // Flush the last batch of rows.\n if (rowValues.length > 0) {\n yield {\n message: {\n tag: 'backfill',\n ...backfill,\n rowValues,\n status,\n progressMarks: progressMarks(previous, must(current)),\n },\n byteSize: bufferedBytes,\n };\n previous = current;\n totalMsgs++;\n logFlushed();\n }\n\n yield {\n message: {\n tag: 'backfill-completed',\n ...backfill,\n status,\n // `previous` is only unset for a backfill from scratch with no rows.\n progressMarks: previous ? {previous} : {},\n },\n byteSize: 0,\n };\n elapsed = (performance.now() - start).toFixed(3);\n lc.info?.(\n `Finished streaming ${status.rows} rows, ${totalMsgs} msgs, ${totalBytes} bytes ` +\n `(${elapsed} ms)`,\n );\n}\n\n/**\n * Creates (and drops) a replication slot in order to obtain a snapshot\n * that corresponds with a specific LSN. Sets the snapshot on the\n * TransactionPool and returns the watermark corresponding to the LSN.\n *\n * (Note that PG's other LSN-related functions are not scoped to a\n * transaction; this is the only way to get set a transaction at a specific\n * LSN.)\n */\nasync function createSnapshotTransaction(\n lc: LogContext,\n upstreamURI: string,\n db: PostgresDB,\n slotNamePrefix: string,\n) {\n const replicationSession = pgClient(\n lc,\n upstreamURI,\n 'backfill-replication-session',\n {\n ['fetch_types']: false, // Necessary for the streaming protocol\n connection: {replication: 'database'}, // https://www.postgresql.org/docs/current/protocol-replication.html\n },\n );\n const slotName = `${slotNamePrefix}_bf_${Date.now()}`;\n try {\n const {snapshot_name: snapshot, consistent_point: lsn} =\n await createReplicationSlot(lc, replicationSession, {\n slotName,\n temporary: true, // deletes the slot when the replicationSession ends\n diagnosticsDB: db,\n });\n\n const {init, imported} = importSnapshot(snapshot);\n const tx = new TransactionPool(lc, {mode: READONLY, init}).run(db);\n await imported.dequeue();\n\n const watermark = toStateVersionString(lsn);\n lc.info?.(`Opened snapshot transaction at LSN ${lsn} (${watermark})`);\n return {tx, watermark};\n } finally {\n // With a timeout, in case a hung slot creation is still pending.\n await replicationSession.end({timeout: 5});\n }\n}\n\nfunction validateSchema(\n lc: LogContext,\n tx: TransactionPool,\n publications: string[],\n bf: BackfillRequest,\n watermark: string,\n): Promise<{\n tableSpec: PublishedTableSpec;\n backfill: BackfillParams;\n progress: Progress;\n}> {\n return tx.processReadTask(async sql => {\n const {tables} = await getPublicationInfo(sql, publications);\n const spec = tables.find(\n spec => spec.schema === bf.table.schema && spec.name === bf.table.name,\n );\n if (!spec) {\n throw new SchemaIncompatibilityError(\n bf,\n `Table has been renamed or dropped`,\n );\n }\n const tableMeta = v.parse(bf.table.metadata, tableMetadataSchema);\n if (spec.schemaOID !== tableMeta.schemaOID) {\n throw new SchemaIncompatibilityError(\n bf,\n `Schema no longer corresponds to the original schema`,\n );\n }\n if (spec.oid !== tableMeta.relationOID) {\n throw new SchemaIncompatibilityError(\n bf,\n `Table no longer corresponds to the original table`,\n );\n }\n if (\n !equals(\n new Set(Object.keys(tableMeta.rowKey)),\n new Set(spec.replicaIdentityColumns),\n )\n ) {\n throw new SchemaIncompatibilityError(\n bf,\n 'Row key (e.g. PRIMARY KEY or INDEX) has changed',\n );\n }\n const allCols = [\n ...Object.entries(tableMeta.rowKey),\n ...Object.entries(bf.columns).map(([col, {id}]) => [col, id] as const),\n ];\n for (const [col, val] of allCols) {\n const colSpec = spec.columns[col];\n if (!colSpec) {\n throw new SchemaIncompatibilityError(\n bf,\n `Column ${col} has been renamed or dropped`,\n );\n }\n const colMeta = v.parse(val, columnMetadataSchema);\n if (colMeta.attNum !== colSpec.pos) {\n throw new SchemaIncompatibilityError(\n bf,\n `Column ${col} no longer corresponds to the original column`,\n );\n }\n }\n const backfill: BackfillParams = {\n relation: {\n schema: bf.table.schema,\n name: bf.table.name,\n rowKey: {columns: Object.keys(tableMeta.rowKey)},\n },\n columns: Object.keys(bf.columns).filter(\n col => !(col in tableMeta.rowKey),\n ),\n watermark,\n };\n const progress = await getProgress(lc, sql, spec.oid, bf);\n return {tableSpec: spec, backfill, progress};\n });\n}\n\n/**\n * Determines the timeline of the table's progress marks (as of the snapshot),\n * and whether the backfill can be resumed from the requested progress.\n *\n * The table is locked (in ACCESS SHARE mode, as the COPY would, for the rest\n * of the transaction) so that its storage cannot be rewritten (by `VACUUM\n * FULL`, `CLUSTER`, `TRUNCATE`, or a rewriting `ALTER TABLE`, all of which\n * require ACCESS EXCLUSIVE) between reading the timeline and the COPY. Note\n * that `pg_relation_filenode()` reflects the table's current storage (only\n * refreshed upon acquiring a lock), whereas `pg_class.relfilenode` is read at\n * the transaction snapshot. If they differ, the storage was rewritten after the\n * snapshot, and the COPY would scan the new storage with the old snapshot,\n * which is not MVCC-safe for all rewrites (e.g. the table appears empty after\n * a rewriting `ALTER TABLE`). The backfill is then retried at a new snapshot.\n */\nasync function getProgress(\n lc: LogContext,\n sql: postgres.Sql,\n relationOID: number,\n bf: BackfillRequest,\n): Promise<Progress> {\n const table = `${id(bf.table.schema)}.${id(bf.table.name)}`;\n await sql.unsafe(`LOCK TABLE ${table} IN ACCESS SHARE MODE`);\n const [{lockedOID, relkind, snapshotFilenode, filenode}] = await sql<\n {\n lockedOID: number | null;\n relkind: string;\n snapshotFilenode: string;\n filenode: string | null;\n }[]\n >`\n SELECT to_regclass(${table})::oid::int8 AS \"lockedOID\",\n relkind,\n relfilenode::text AS \"snapshotFilenode\",\n pg_relation_filenode(oid)::text AS filenode\n FROM pg_class WHERE oid = ${relationOID}`;\n if (Number(lockedOID) !== relationOID) {\n // The (current) table with the name is not the one at the snapshot.\n throw new SchemaIncompatibilityError(\n bf,\n `Table has been renamed or replaced`,\n );\n }\n if (relkind !== 'r' || filenode === null) {\n // Not a plain table (e.g. a partitioned table), for which ctids are not\n // unique. Such backfills are not resumable, which is achieved by using a\n // timeline that is unique to the run.\n return {\n timeline: `${relkind}:${randomCharacters(12)}`,\n resumable: false,\n start: undefined,\n };\n }\n if (filenode !== snapshotFilenode) {\n throw new Error(\n `${bf.table.schema}.${bf.table.name} was rewritten after the backfill ` +\n `snapshot (relfilenode ${snapshotFilenode} => ${filenode})`,\n );\n }\n const timeline = filenode;\n const requested = resumePoint(bf);\n if (requested && requested.timeline !== timeline) {\n lc.info?.(\n `restarting backfill from scratch: the requested progress is on ` +\n `timeline ${requested.timeline} but the table is on ${timeline}`,\n );\n return {timeline, resumable: true, start: undefined};\n }\n if (requested && !(await canResume(sql, relationOID, bf))) {\n lc.info?.(\n `restarting backfill from scratch: the table has TOASTed values ` +\n `and backfilling columns [${Object.keys(bf.columns).join(',')}] ` +\n `are TOAST-able`,\n );\n return {timeline, resumable: true, start: undefined};\n }\n return {timeline, resumable: true, start: requested};\n}\n\n/**\n * Whether resuming a backfill (in a new snapshot) is guaranteed to deliver\n * the values of every row that the preceding snapshot(s) had not reached.\n *\n * Within a single snapshot, every row is scanned exactly once, since the\n * version visible to the snapshot stays in place while it is held. Across\n * snapshots, however, a row that had not yet been reached (i.e. beyond the\n * resume mark `M`) can be updated between the snapshots, with its new\n * version placed at or before `M` (a non-HOT update into free space in an\n * earlier page, or a HOT update into a lower line pointer of `M`'s page).\n * Neither snapshot then scans the row, and it is only delivered by the\n * replication stream. The stream's UPDATE contains the full row, _except_\n * for unchanged values stored out-of-line in TOAST, which pgoutput omits\n * (unless the table has REPLICA IDENTITY FULL). A backfilling column with\n * such a value would be published without it.\n *\n * Resuming is therefore only done if no backfilling column can be TOASTed\n * (i.e. all have `attstorage = 'p'`, as for fixed-width types), or if the\n * table has no TOAST data. The latter is safe because an omitted value must\n * still be in the TOAST relation at the new snapshot (unless its row has since\n * been deleted, which the replication stream does deliver), and conservative\n * because the TOAST relation does not shrink to empty without a heap rewrite,\n * which changes the timeline.\n *\n * Potential optimization for TOAST-able backfills: have the resumed run also\n * deliver the rows at or before `M` that were modified since the snapshot of\n * the run that started the backfill from scratch. That run's snapshot `xmin`\n * (`T_origin`, as an xid8) would be encoded in the timeline, i.e.\n * `timeline = \"<relfilenode>:<T_origin>\"`, and carried over (rather than\n * replaced) by resumed runs, so that:\n * * Within a chain of runs, every row version with `xmin < T_origin` stayed\n * in place across all of the runs' snapshots and was therefore scanned\n * exactly once, and all other rows are re-delivered by the resumed run\n * (`ctid > M OR xmin >= T_origin`).\n * * Continuations from a different chain (i.e. with a newer `T_origin`) are\n * not accepted by subscribers, and requests from different chains are\n * merged into a backfill from scratch, as the timelines differ. Note that\n * using the _latest_ `T` would be incorrect: rows that moved before `M`\n * between the earlier snapshots would never be re-delivered.\n * The resumed run would scan the whole table (but only deliver the modified\n * rows), comparing the 32-bit `xmin` with `T_origin` modulo 2^32, which\n * requires falling back to a backfill from scratch once the current xid is\n * 2^31 or more past `T_origin` (i.e. wraparound). This also relies on the\n * `xmin` system column returning the raw xmin of frozen tuples, which should\n * be verified.\n */\nasync function canResume(\n sql: postgres.Sql,\n relationOID: number,\n bf: BackfillRequest,\n): Promise<boolean> {\n const [{toastable, toasted}] = await sql<\n {toastable: boolean; toasted: boolean}[]\n >`\n SELECT\n EXISTS (\n SELECT 1 FROM pg_attribute\n WHERE attrelid = ${relationOID}\n AND attname IN ${sql(Object.keys(bf.columns))}\n AND attstorage <> 'p'\n ) AS toastable,\n reltoastrelid <> 0 AND pg_relation_size(reltoastrelid) > 0 AS toasted\n FROM pg_class WHERE oid = ${relationOID}`;\n return !toastable || !toasted;\n}\n"],"mappings":"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"}
@@ -1,6 +1,6 @@
1
1
  import type { Readable } from 'node:stream';
2
2
  import type { LogContext } from '@rocicorp/logger';
3
- import type postgres from 'postgres';
3
+ import postgres from 'postgres';
4
4
  import { type PostgresDB } from '../../../types/pg.ts';
5
5
  import { type ShardID } from '../../../types/shards.ts';
6
6
  import { type ReplicaStage } from './schema/replica-stage-enum.ts';
@@ -26,10 +26,14 @@ export type CreateSlotSpec = {
26
26
  slotName: string;
27
27
  failover?: boolean;
28
28
  temporary?: boolean;
29
- lockTimeout?: number;
29
+ lockTimeout?: number | undefined;
30
30
  diagnosticsDB?: PostgresDB | undefined;
31
31
  };
32
32
  export declare const CREATE_REPLICATION_SLOT_TIMEOUT_MS = 60000;
33
+ /** Thrown when the client-side timeout for creating a slot is reached. */
34
+ export declare class SlotCreationTimeoutError extends Error {
35
+ readonly name = "SlotCreationTimeoutError";
36
+ }
33
37
  export declare function createReplicationSlot(lc: LogContext, session: postgres.Sql, { slotName, failover, temporary, lockTimeout, diagnosticsDB, }: CreateSlotSpec): Promise<ReplicationSlot>;
34
38
  /**
35
39
  * Best-effort logging of what a timed out CREATE_REPLICATION_SLOT was waiting
@@ -44,13 +48,21 @@ export declare function createReplicationSlot(lc: LogContext, session: postgres.
44
48
  * Exported for testing.
45
49
  */
46
50
  export declare function logSlotCreationBlockers(lc: LogContext, sql: PostgresDB, walsenderPID: number, slotName: string): Promise<void>;
51
+ export type CreateReplicaAndSlotOptions = {
52
+ lockTimeout?: number | undefined;
53
+ maxBlockedMs?: number | undefined;
54
+ retryDelayMs?: number | undefined;
55
+ };
47
56
  /**
48
- * Replica and slot creation involves two sessions for proper coordination
49
- * with other replica management logic:
57
+ * Replica and slot creation involves several sessions for proper
58
+ * coordination with other replica management logic:
50
59
  *
51
- * * A normal transaction is started and acquires an advisory lock for
60
+ * * A dedicated session acquires a (session-level) advisory lock for
52
61
  * replica slot management. This is the same lock that cleanup logic
53
- * acquires before cleaning up replication slots.
62
+ * acquires before cleaning up replication slots. The lock is held
63
+ * outside of a transaction so that the session is not "idle in
64
+ * transaction" while slot creation waits on upstream transactions,
65
+ * which some upstreams terminate (e.g. idle_in_transaction_session_timeout).
54
66
  * * With the lock held, a new replication slot is created in a
55
67
  * replication session. The API of CREATE_REPLICATION_SLOT is such
56
68
  * that it cannot be done in a transaction, and cannot be followed by
@@ -59,6 +71,14 @@ export declare function logSlotCreationBlockers(lc: LogContext, sql: PostgresDB,
59
71
  * * Once the slot is created, the slot and replica information are recorded
60
72
  * in the `replicas` table before releasing the lock.
61
73
  *
74
+ * Slot creation waits for all transactions on the upstream instance that
75
+ * hold an xid, so it can be blocked by long-running transactions. A blocked
76
+ * attempt is abandoned (see {@link CREATE_REPLICATION_SLOT_TIMEOUT_MS}) and
77
+ * retried in-process, as it leaves no durable state: the `replicas` row is
78
+ * only inserted after the slot has been created. Retries continue until the
79
+ * slot is created or until `maxBlockedMs` has elapsed, after which the error
80
+ * is thrown.
81
+ *
62
82
  * This locking ensures that:
63
83
  * 1. multiple replication managers attempting to create a replication slot
64
84
  * will not use the same name for the replication slot (which is selected
@@ -91,7 +111,7 @@ export declare function logSlotCreationBlockers(lc: LogContext, sql: PostgresDB,
91
111
  * the source replica has been selected for restoring, which will carry
92
112
  * over the `generation` and other relevant values from the source.
93
113
  */
94
- export declare function createReplicaAndSlot<T>(lc: LogContext, sql: PostgresDB, sessionName: string, shard: ShardID, epoch: number, replicaID: string, failover: boolean, backupOptions: BackupOptions, captureSnapshot: (snapshot: string) => Promise<T>, stage: ReplicaStage): Promise<ReplicationSlotResult<T>>;
114
+ export declare function createReplicaAndSlot<T>(lc: LogContext, sql: PostgresDB, sessionName: string, shard: ShardID, epoch: number, replicaID: string, failover: boolean, backupOptions: BackupOptions, captureSnapshot: (snapshot: string) => Promise<T>, stage: ReplicaStage, { lockTimeout, maxBlockedMs, retryDelayMs, }?: CreateReplicaAndSlotOptions): Promise<ReplicationSlotResult<T>>;
95
115
  /**
96
116
  * Claims the inactive slot of the given replica for use by this task, opening
97
117
  * a replication session to mark it as active. The process should follow up by
@@ -1 +1 @@
1
- {"version":3,"file":"replication-slots.d.ts","sourceRoot":"","sources":["../../../../../../../zero-cache/src/services/change-source/pg/replication-slots.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"replication-slots.d.ts","sourceRoot":"","sources":["../../../../../../../zero-cache/src/services/change-source/pg/replication-slots.ts"],"names":[],"mappings":"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"}
@@ -1,3 +1,4 @@
1
+ import { sleep } from "../../../../../shared/src/sleep.js";
1
2
  import { must } from "../../../../../shared/src/must.js";
2
3
  import { upstreamSchema } from "../../../types/shards.js";
3
4
  import { isPostgresError } from "../../../types/pg.js";
@@ -9,10 +10,16 @@ import { AutoResetSignal } from "../../change-streamer/schema/tables.js";
9
10
  import "../../../types/pg-versions.js";
10
11
  import { createReplicationSessionFor, endSession, extractConnectionConfig, keepSlotActiveUntilTakenOver } from "./logical-replication/stream.js";
11
12
  import { orTimeout } from "../../../types/timeout.js";
12
- import { PG_CONFIGURATION_LIMIT_EXCEEDED, PG_INSUFFICIENT_PRIVILEGE } from "@drdgvhbh/postgres-error-codes";
13
+ import { defu } from "defu";
14
+ import postgres from "postgres";
15
+ import { PG_CONFIGURATION_LIMIT_EXCEEDED, PG_INSUFFICIENT_PRIVILEGE, PG_LOCK_NOT_AVAILABLE } from "@drdgvhbh/postgres-error-codes";
13
16
  //#region ../zero-cache/src/services/change-source/pg/replication-slots.ts
14
17
  var CREATE_REPLICATION_SLOT_TIMEOUT_MS = 6e4;
15
18
  var SERVER_LOCK_TIMEOUT_MS = CREATE_REPLICATION_SLOT_TIMEOUT_MS - 1e3;
19
+ /** Thrown when the client-side timeout for creating a slot is reached. */
20
+ var SlotCreationTimeoutError = class extends Error {
21
+ name = "SlotCreationTimeoutError";
22
+ };
16
23
  async function createReplicationSlot(lc, session, { slotName, failover, temporary, lockTimeout = SERVER_LOCK_TIMEOUT_MS, diagnosticsDB }) {
17
24
  await session.unsafe(`SET lock_timeout = ${lockTimeout}`);
18
25
  const { pgVersion, pid: walsenderPID } = (await session.unsafe(`
@@ -21,16 +28,18 @@ async function createReplicationSlot(lc, session, { slotName, failover, temporar
21
28
  `))[0];
22
29
  const maybeTemporary = temporary ? "TEMPORARY" : "";
23
30
  const options = failover && pgVersion >= 17e4 ? "(FAILOVER)" : "";
24
- const raced = await orTimeout(session.unsafe(`
25
- CREATE_REPLICATION_SLOT "${slotName}" ${maybeTemporary} LOGICAL pgoutput ${options}`), CREATE_REPLICATION_SLOT_TIMEOUT_MS);
26
- if (raced === "timed-out") {
27
- if (diagnosticsDB) await logSlotCreationBlockers(lc, diagnosticsDB, walsenderPID, slotName);
28
- session.end().catch((e) => lc.warn?.(`Error closing timed out replication slot session`, e));
29
- throw new Error(`Timed out after ${CREATE_REPLICATION_SLOT_TIMEOUT_MS} ms creating replication slot ${slotName}.`);
31
+ const createSlot = session.unsafe(`
32
+ CREATE_REPLICATION_SLOT "${slotName}" ${maybeTemporary} LOGICAL pgoutput ${options}`);
33
+ try {
34
+ const raced = await orTimeout(createSlot, CREATE_REPLICATION_SLOT_TIMEOUT_MS);
35
+ if (raced === "timed-out") throw new SlotCreationTimeoutError(`Timed out after ${CREATE_REPLICATION_SLOT_TIMEOUT_MS} ms creating replication slot ${slotName}.`);
36
+ const [slot] = raced;
37
+ lc.info?.(`Created replication slot ${slotName}`, slot);
38
+ return slot;
39
+ } catch (e) {
40
+ if (diagnosticsDB && (isPostgresError(e, PG_LOCK_NOT_AVAILABLE) || e instanceof SlotCreationTimeoutError)) await logSlotCreationBlockers(lc, diagnosticsDB, walsenderPID, slotName);
41
+ throw e;
30
42
  }
31
- const [slot] = raced;
32
- lc.info?.(`Created replication slot ${slotName}`, slot);
33
- return slot;
34
43
  }
35
44
  var SLOT_DIAGNOSTICS_TIMEOUT_MS = 5e3;
36
45
  var SLOT_DIAGNOSTICS_MAX_TRANSACTIONS = 20;
@@ -76,12 +85,40 @@ async function logSlotCreationBlockers(lc, sql, walsenderPID, slotName) {
76
85
  }
77
86
  }
78
87
  /**
79
- * Replica and slot creation involves two sessions for proper coordination
80
- * with other replica management logic:
88
+ * Whether slot creation failed because it was waiting on transactions on the
89
+ * upstream instance: the server's lock_timeout fired, the client-side timeout
90
+ * was reached, or the replication session was closed while it was waiting.
91
+ * Such failures are expected on busy upstreams, and leave no durable state,
92
+ * so the attempt can simply be retried.
93
+ */
94
+ function isSlotCreationBlocked(e) {
95
+ return isPostgresError(e, PG_LOCK_NOT_AVAILABLE) || e instanceof SlotCreationTimeoutError || e instanceof Error && e.code === "CONNECTION_CLOSED";
96
+ }
97
+ var DEFAULT_MAX_SLOT_CREATION_BLOCKED_MS = 1800 * 1e3;
98
+ var DEFAULT_SLOT_CREATION_RETRY_DELAY_MS = 1e3;
99
+ /**
100
+ * Creates a dedicated (non-pooled) session for holding the session-level
101
+ * replication slot management lock. Ending the session releases the lock,
102
+ * which guarantees that the lock never outlives the attempt.
103
+ */
104
+ function createLockSessionFor(db, applicationName) {
105
+ return postgres(defu({
106
+ max: 1,
107
+ ["idle_timeout"]: null,
108
+ ["max_lifetime"]: null,
109
+ connection: { ["application_name"]: applicationName }
110
+ }, db.options));
111
+ }
112
+ /**
113
+ * Replica and slot creation involves several sessions for proper
114
+ * coordination with other replica management logic:
81
115
  *
82
- * * A normal transaction is started and acquires an advisory lock for
116
+ * * A dedicated session acquires a (session-level) advisory lock for
83
117
  * replica slot management. This is the same lock that cleanup logic
84
- * acquires before cleaning up replication slots.
118
+ * acquires before cleaning up replication slots. The lock is held
119
+ * outside of a transaction so that the session is not "idle in
120
+ * transaction" while slot creation waits on upstream transactions,
121
+ * which some upstreams terminate (e.g. idle_in_transaction_session_timeout).
85
122
  * * With the lock held, a new replication slot is created in a
86
123
  * replication session. The API of CREATE_REPLICATION_SLOT is such
87
124
  * that it cannot be done in a transaction, and cannot be followed by
@@ -90,6 +127,14 @@ async function logSlotCreationBlockers(lc, sql, walsenderPID, slotName) {
90
127
  * * Once the slot is created, the slot and replica information are recorded
91
128
  * in the `replicas` table before releasing the lock.
92
129
  *
130
+ * Slot creation waits for all transactions on the upstream instance that
131
+ * hold an xid, so it can be blocked by long-running transactions. A blocked
132
+ * attempt is abandoned (see {@link CREATE_REPLICATION_SLOT_TIMEOUT_MS}) and
133
+ * retried in-process, as it leaves no durable state: the `replicas` row is
134
+ * only inserted after the slot has been created. Retries continue until the
135
+ * slot is created or until `maxBlockedMs` has elapsed, after which the error
136
+ * is thrown.
137
+ *
93
138
  * This locking ensures that:
94
139
  * 1. multiple replication managers attempting to create a replication slot
95
140
  * will not use the same name for the replication slot (which is selected
@@ -122,83 +167,100 @@ async function logSlotCreationBlockers(lc, sql, walsenderPID, slotName) {
122
167
  * the source replica has been selected for restoring, which will carry
123
168
  * over the `generation` and other relevant values from the source.
124
169
  */
125
- async function createReplicaAndSlot(lc, sql, sessionName, shard, epoch, replicaID, failover, backupOptions, captureSnapshot, stage) {
126
- const replicationSession = createReplicationSessionFor(sql, sessionName);
170
+ async function createReplicaAndSlot(lc, sql, sessionName, shard, epoch, replicaID, failover, backupOptions, captureSnapshot, stage, { lockTimeout, maxBlockedMs = DEFAULT_MAX_SLOT_CREATION_BLOCKED_MS, retryDelayMs = DEFAULT_SLOT_CREATION_RETRY_DELAY_MS } = {}) {
127
171
  const lockName = replicationSlotManagementLock(shard);
128
172
  const slotPoolPrefix = replicationSlotPrefix(shard);
129
- for (let first = true;; first = false) {
173
+ const start = Date.now();
174
+ let addedReplicationRole = false;
175
+ let cleanedUpForSlotLimit = false;
176
+ for (let attempt = 1;; attempt++) {
130
177
  await dropUnclaimedSlots(lc, sql, shard);
178
+ const replicationSession = createReplicationSessionFor(sql, sessionName);
179
+ const lockSession = createLockSessionFor(sql, `${sessionName}-lock`);
180
+ const endSessions = () => Promise.allSettled([replicationSession.end({ timeout: 5 }), lockSession.end({ timeout: 5 })]);
131
181
  let slotName;
132
182
  let initialSession;
183
+ let creatingSlot = false;
133
184
  try {
134
- return await runTx(sql, async (tx) => {
135
- await tx`SELECT pg_advisory_xact_lock(hashtext(${lockName}))`;
136
- if (stage === 0) {
137
- const others = await getRestoreCandidates(tx, shard, epoch);
138
- lc.info?.(`current replicas at epoch ${epoch}`, { replicas: others });
139
- if (others.length) {
140
- const [{ id, stage, active }] = others;
141
- if (stage === 0 && active) throw new AutoResetSignal(`another replica (${id}) is performing initial sync`);
142
- }
185
+ await lockSession`SELECT pg_advisory_lock(hashtext(${lockName}))`;
186
+ if (stage === 0) {
187
+ const others = await getRestoreCandidates(sql, shard, epoch);
188
+ lc.info?.(`current replicas at epoch ${epoch}`, { replicas: others });
189
+ if (others.length) {
190
+ const [{ id, stage, active }] = others;
191
+ if (stage === 0 && active) throw new AutoResetSignal(`another replica (${id}) is performing initial sync`);
143
192
  }
144
- const names = await tx`
145
- SELECT slot_name as name FROM pg_replication_slots
146
- WHERE slot_name LIKE ${slotPoolPrefix + "%"};
147
- `.values();
148
- const inUse = new Set(names.flat());
149
- for (let next = 0;; next++) {
150
- const candidateName = `${slotPoolPrefix}${slotPoolSuffix(next)}`;
151
- if (!inUse.has(candidateName)) {
152
- slotName = candidateName;
153
- break;
154
- }
193
+ }
194
+ const names = await sql`
195
+ SELECT slot_name as name FROM pg_replication_slots
196
+ WHERE slot_name LIKE ${slotPoolPrefix + "%"};
197
+ `.values();
198
+ const inUse = new Set(names.flat());
199
+ for (let next = 0;; next++) {
200
+ const candidateName = `${slotPoolPrefix}${slotPoolSuffix(next)}`;
201
+ if (!inUse.has(candidateName)) {
202
+ slotName = candidateName;
203
+ break;
155
204
  }
156
- const slot = await createReplicationSlot(lc, replicationSession, {
157
- slotName,
158
- failover,
159
- diagnosticsDB: sql
160
- });
161
- const capturedSnapshot = await captureSnapshot(slot.snapshot_name);
162
- await replicationSession.end();
163
- initialSession = await keepSlotActiveUntilTakenOver(lc, {
164
- db: extractConnectionConfig(sql),
165
- slot: slot.slot_name,
166
- dummyPublication: metadataPublicationName(shard.appID, shard.shardNum),
167
- lsn: String(toBigInt(slot.consistent_point))
168
- });
205
+ }
206
+ creatingSlot = true;
207
+ const slot = await createReplicationSlot(lc, replicationSession, {
208
+ slotName,
209
+ failover,
210
+ lockTimeout,
211
+ diagnosticsDB: sql
212
+ });
213
+ creatingSlot = false;
214
+ const capturedSnapshot = await captureSnapshot(slot.snapshot_name);
215
+ await replicationSession.end({ timeout: 5 });
216
+ initialSession = await keepSlotActiveUntilTakenOver(lc, {
217
+ db: extractConnectionConfig(sql),
218
+ slot: slot.slot_name,
219
+ dummyPublication: metadataPublicationName(shard.appID, shard.shardNum),
220
+ lsn: String(toBigInt(slot.consistent_point))
221
+ });
222
+ const replica = await runTx(sql, async (tx) => {
169
223
  await createReplica(tx, shard, replicaID, slot.slot_name, epoch, stage === 0 ? toStateVersionString(slot.consistent_point) : "", backupOptions, stage);
170
- const replica = must(await getReplicaState(tx, shard, replicaID), `replica ${replicaID} was not created`);
171
- return {
172
- slot,
173
- capturedSnapshot,
174
- initialSession,
175
- replica
176
- };
224
+ return must(await getReplicaState(tx, shard, replicaID), `replica ${replicaID} was not created`);
177
225
  });
226
+ return {
227
+ slot,
228
+ capturedSnapshot,
229
+ initialSession,
230
+ replica
231
+ };
178
232
  } catch (e) {
179
- if (first && isPostgresError(e, PG_INSUFFICIENT_PRIVILEGE)) {
233
+ await endSessions();
234
+ if (!addedReplicationRole && isPostgresError(e, PG_INSUFFICIENT_PRIVILEGE)) {
180
235
  await sql`ALTER ROLE current_user WITH REPLICATION`;
181
236
  lc.info?.(`Added the REPLICATION role to database user`);
237
+ addedReplicationRole = true;
182
238
  continue;
183
239
  }
184
- if (first && isPostgresError(e, PG_CONFIGURATION_LIMIT_EXCEEDED)) {
240
+ if (!cleanedUpForSlotLimit && isPostgresError(e, PG_CONFIGURATION_LIMIT_EXCEEDED)) {
185
241
  lc.warn?.(`Reached max replication slots. Attempting to clean up unused slots`, e);
186
242
  await sql`
187
243
  DELETE FROM ${sql(`${upstreamSchema(shard)}.replicas`)} USING pg_replication_slots slots
188
244
  WHERE replicas.slot = slots.slot_name AND NOT slots.active`;
245
+ cleanedUpForSlotLimit = true;
189
246
  continue;
190
247
  }
191
248
  if (initialSession) try {
192
249
  await endSession(initialSession);
193
250
  } catch {}
251
+ const blockedMs = Date.now() - start;
252
+ if (creatingSlot && isSlotCreationBlocked(e) && blockedMs < maxBlockedMs) {
253
+ lc.warn?.(`Replication slot ${slotName} creation blocked by upstream transactions (attempt ${attempt}, ${blockedMs} ms). Retrying.`, e);
254
+ await sleep(retryDelayMs * (1 + 4 * Math.random()));
255
+ continue;
256
+ }
194
257
  if (slotName) {
195
258
  lc.warn?.(`deleting slot ${slotName} due to error`, e);
196
- try {
197
- await replicationSession.end();
198
- } catch {}
199
259
  await dropUnclaimedSlots(lc, sql, shard);
200
260
  }
201
261
  throw e;
262
+ } finally {
263
+ await endSessions();
202
264
  }
203
265
  }
204
266
  }