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

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 (130) 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 +28 -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 +83 -0
  6. package/out/zero-cache/src/config/zero-config.js.map +1 -1
  7. package/out/zero-cache/src/server/change-streamer.d.ts.map +1 -1
  8. package/out/zero-cache/src/server/change-streamer.js +2 -1
  9. package/out/zero-cache/src/server/change-streamer.js.map +1 -1
  10. package/out/zero-cache/src/server/main.d.ts.map +1 -1
  11. package/out/zero-cache/src/server/main.js +5 -1
  12. package/out/zero-cache/src/server/main.js.map +1 -1
  13. package/out/zero-cache/src/server/replicator.d.ts +4 -0
  14. package/out/zero-cache/src/server/replicator.d.ts.map +1 -1
  15. package/out/zero-cache/src/server/replicator.js +18 -4
  16. package/out/zero-cache/src/server/replicator.js.map +1 -1
  17. package/out/zero-cache/src/server/runner/run-worker.d.ts.map +1 -1
  18. package/out/zero-cache/src/server/runner/run-worker.js +12 -6
  19. package/out/zero-cache/src/server/runner/run-worker.js.map +1 -1
  20. package/out/zero-cache/src/server/syncer.d.ts.map +1 -1
  21. package/out/zero-cache/src/server/syncer.js +17 -1
  22. package/out/zero-cache/src/server/syncer.js.map +1 -1
  23. package/out/zero-cache/src/services/change-source/pg/backfill-stream.js +1 -1
  24. package/out/zero-cache/src/services/change-source/pg/backfill-stream.js.map +1 -1
  25. package/out/zero-cache/src/services/change-source/pg/replication-slots.d.ts +27 -7
  26. package/out/zero-cache/src/services/change-source/pg/replication-slots.d.ts.map +1 -1
  27. package/out/zero-cache/src/services/change-source/pg/replication-slots.js +124 -62
  28. package/out/zero-cache/src/services/change-source/pg/replication-slots.js.map +1 -1
  29. package/out/zero-cache/src/services/change-streamer/change-streamer-http.d.ts +9 -3
  30. package/out/zero-cache/src/services/change-streamer/change-streamer-http.d.ts.map +1 -1
  31. package/out/zero-cache/src/services/change-streamer/change-streamer-http.js +19 -6
  32. package/out/zero-cache/src/services/change-streamer/change-streamer-http.js.map +1 -1
  33. package/out/zero-cache/src/services/change-streamer/change-streamer-service.d.ts +8 -0
  34. package/out/zero-cache/src/services/change-streamer/change-streamer-service.d.ts.map +1 -1
  35. package/out/zero-cache/src/services/change-streamer/change-streamer-service.js +131 -62
  36. package/out/zero-cache/src/services/change-streamer/change-streamer-service.js.map +1 -1
  37. package/out/zero-cache/src/services/change-streamer/change-streamer.d.ts +124 -2
  38. package/out/zero-cache/src/services/change-streamer/change-streamer.d.ts.map +1 -1
  39. package/out/zero-cache/src/services/change-streamer/change-streamer.js +33 -2
  40. package/out/zero-cache/src/services/change-streamer/change-streamer.js.map +1 -1
  41. package/out/zero-cache/src/services/change-streamer/drain-tracker.d.ts +24 -0
  42. package/out/zero-cache/src/services/change-streamer/drain-tracker.d.ts.map +1 -0
  43. package/out/zero-cache/src/services/change-streamer/drain-tracker.js +67 -0
  44. package/out/zero-cache/src/services/change-streamer/drain-tracker.js.map +1 -0
  45. package/out/zero-cache/src/services/change-streamer/restore-admission.d.ts +214 -0
  46. package/out/zero-cache/src/services/change-streamer/restore-admission.d.ts.map +1 -0
  47. package/out/zero-cache/src/services/change-streamer/restore-admission.js +294 -0
  48. package/out/zero-cache/src/services/change-streamer/restore-admission.js.map +1 -0
  49. package/out/zero-cache/src/services/change-streamer/snapshot-reservations.d.ts +13 -1
  50. package/out/zero-cache/src/services/change-streamer/snapshot-reservations.d.ts.map +1 -1
  51. package/out/zero-cache/src/services/change-streamer/snapshot-reservations.js +7 -6
  52. package/out/zero-cache/src/services/change-streamer/snapshot-reservations.js.map +1 -1
  53. package/out/zero-cache/src/services/change-streamer/snapshot.d.ts +2 -2
  54. package/out/zero-cache/src/services/change-streamer/snapshot.d.ts.map +1 -1
  55. package/out/zero-cache/src/services/change-streamer/snapshot.js +2 -2
  56. package/out/zero-cache/src/services/change-streamer/snapshot.js.map +1 -1
  57. package/out/zero-cache/src/services/change-streamer/subscribe.d.ts +39 -0
  58. package/out/zero-cache/src/services/change-streamer/subscribe.d.ts.map +1 -1
  59. package/out/zero-cache/src/services/change-streamer/subscribe.js +19 -2
  60. package/out/zero-cache/src/services/change-streamer/subscribe.js.map +1 -1
  61. package/out/zero-cache/src/services/change-streamer/subscriber.d.ts +22 -1
  62. package/out/zero-cache/src/services/change-streamer/subscriber.d.ts.map +1 -1
  63. package/out/zero-cache/src/services/change-streamer/subscriber.js +41 -15
  64. package/out/zero-cache/src/services/change-streamer/subscriber.js.map +1 -1
  65. package/out/zero-cache/src/services/http-service.d.ts +6 -0
  66. package/out/zero-cache/src/services/http-service.d.ts.map +1 -1
  67. package/out/zero-cache/src/services/http-service.js +14 -2
  68. package/out/zero-cache/src/services/http-service.js.map +1 -1
  69. package/out/zero-cache/src/services/life-cycle.d.ts +33 -0
  70. package/out/zero-cache/src/services/life-cycle.d.ts.map +1 -1
  71. package/out/zero-cache/src/services/life-cycle.js +56 -10
  72. package/out/zero-cache/src/services/life-cycle.js.map +1 -1
  73. package/out/zero-cache/src/services/litestream/commands.d.ts +8 -0
  74. package/out/zero-cache/src/services/litestream/commands.d.ts.map +1 -1
  75. package/out/zero-cache/src/services/litestream/commands.js +22 -1
  76. package/out/zero-cache/src/services/litestream/commands.js.map +1 -1
  77. package/out/zero-cache/src/services/replicator/drain-handler.d.ts +74 -0
  78. package/out/zero-cache/src/services/replicator/drain-handler.d.ts.map +1 -0
  79. package/out/zero-cache/src/services/replicator/drain-handler.js +185 -0
  80. package/out/zero-cache/src/services/replicator/drain-handler.js.map +1 -0
  81. package/out/zero-cache/src/services/replicator/incremental-sync.d.ts +2 -1
  82. package/out/zero-cache/src/services/replicator/incremental-sync.d.ts.map +1 -1
  83. package/out/zero-cache/src/services/replicator/incremental-sync.js +66 -13
  84. package/out/zero-cache/src/services/replicator/incremental-sync.js.map +1 -1
  85. package/out/zero-cache/src/services/replicator/replicator.d.ts +2 -1
  86. package/out/zero-cache/src/services/replicator/replicator.d.ts.map +1 -1
  87. package/out/zero-cache/src/services/replicator/replicator.js +2 -2
  88. package/out/zero-cache/src/services/replicator/replicator.js.map +1 -1
  89. package/out/zero-cache/src/services/view-syncer/batch-pacer.d.ts +79 -0
  90. package/out/zero-cache/src/services/view-syncer/batch-pacer.d.ts.map +1 -0
  91. package/out/zero-cache/src/services/view-syncer/batch-pacer.js +249 -0
  92. package/out/zero-cache/src/services/view-syncer/batch-pacer.js.map +1 -0
  93. package/out/zero-cache/src/services/view-syncer/cvr.d.ts +27 -1
  94. package/out/zero-cache/src/services/view-syncer/cvr.d.ts.map +1 -1
  95. package/out/zero-cache/src/services/view-syncer/cvr.js +107 -17
  96. package/out/zero-cache/src/services/view-syncer/cvr.js.map +1 -1
  97. package/out/zero-cache/src/services/view-syncer/pipeline-driver.d.ts +85 -3
  98. package/out/zero-cache/src/services/view-syncer/pipeline-driver.d.ts.map +1 -1
  99. package/out/zero-cache/src/services/view-syncer/pipeline-driver.js +301 -20
  100. package/out/zero-cache/src/services/view-syncer/pipeline-driver.js.map +1 -1
  101. package/out/zero-cache/src/services/view-syncer/push-cost.d.ts +132 -0
  102. package/out/zero-cache/src/services/view-syncer/push-cost.d.ts.map +1 -0
  103. package/out/zero-cache/src/services/view-syncer/push-cost.js +164 -0
  104. package/out/zero-cache/src/services/view-syncer/push-cost.js.map +1 -0
  105. package/out/zero-cache/src/services/view-syncer/push-cutoff.d.ts +55 -0
  106. package/out/zero-cache/src/services/view-syncer/push-cutoff.d.ts.map +1 -0
  107. package/out/zero-cache/src/services/view-syncer/push-cutoff.js +56 -0
  108. package/out/zero-cache/src/services/view-syncer/push-cutoff.js.map +1 -0
  109. package/out/zero-cache/src/services/view-syncer/query-stats.d.ts +7 -0
  110. package/out/zero-cache/src/services/view-syncer/query-stats.d.ts.map +1 -1
  111. package/out/zero-cache/src/services/view-syncer/query-stats.js +20 -1
  112. package/out/zero-cache/src/services/view-syncer/query-stats.js.map +1 -1
  113. package/out/zero-cache/src/services/view-syncer/snapshotter.d.ts +136 -5
  114. package/out/zero-cache/src/services/view-syncer/snapshotter.d.ts.map +1 -1
  115. package/out/zero-cache/src/services/view-syncer/snapshotter.js +246 -18
  116. package/out/zero-cache/src/services/view-syncer/snapshotter.js.map +1 -1
  117. package/out/zero-cache/src/services/view-syncer/view-syncer.d.ts +8 -2
  118. package/out/zero-cache/src/services/view-syncer/view-syncer.d.ts.map +1 -1
  119. package/out/zero-cache/src/services/view-syncer/view-syncer.js +381 -16
  120. package/out/zero-cache/src/services/view-syncer/view-syncer.js.map +1 -1
  121. package/out/zero-cache/src/types/processes.d.ts +8 -0
  122. package/out/zero-cache/src/types/processes.d.ts.map +1 -1
  123. package/out/zero-cache/src/types/processes.js.map +1 -1
  124. package/out/zero-client/src/client/version.js +1 -1
  125. package/out/zero-react/src/use-query.js +1 -1
  126. package/out/zero-react/src/use-query.js.map +1 -1
  127. package/out/zero-server/src/queries/process-queries.d.ts.map +1 -1
  128. package/out/zero-server/src/queries/process-queries.js +1 -1
  129. package/out/zero-server/src/queries/process-queries.js.map +1 -1
  130. package/package.json +1 -1
@@ -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
  }
@@ -1 +1 @@
1
- {"version":3,"file":"replication-slots.js","names":[],"sources":["../../../../../../../zero-cache/src/services/change-source/pg/replication-slots.ts"],"sourcesContent":["import type {Readable} from 'node:stream';\nimport {\n PG_CONFIGURATION_LIMIT_EXCEEDED,\n PG_INSUFFICIENT_PRIVILEGE,\n} from '@drdgvhbh/postgres-error-codes';\nimport type {LogContext} from '@rocicorp/logger';\nimport type postgres from 'postgres';\nimport type {JSONObject} from '../../../../../shared/src/bigint-json.ts';\nimport {must} from '../../../../../shared/src/must.ts';\nimport {runTx} from '../../../db/run-transaction.ts';\nimport {PG_17} from '../../../types/pg-versions.ts';\nimport {isPostgresError, type PostgresDB} from '../../../types/pg.ts';\nimport {upstreamSchema, type ShardID} from '../../../types/shards.ts';\nimport {orTimeout} from '../../../types/timeout.ts';\nimport {AutoResetSignal} from '../../change-streamer/schema/tables.ts';\nimport {\n createReplicationSessionFor,\n endSession,\n extractConnectionConfig,\n keepSlotActiveUntilTakenOver,\n} from './logical-replication/stream.ts';\nimport {toBigInt, toStateVersionString} from './lsn.ts';\nimport {\n InitialSync,\n Restore,\n type ReplicaStage,\n} from './schema/replica-stage-enum.ts';\nimport {\n createReplica,\n getReplicaState,\n getRestoreCandidates,\n metadataPublicationName,\n replicationSlotExpression,\n replicationSlotPrefix,\n type BackupOptions,\n type ReplicaState,\n} from './schema/shard.ts';\n\n// Record returned by `CREATE_REPLICATION_SLOT`\nexport type ReplicationSlot = {\n slot_name: string;\n consistent_point: string;\n snapshot_name: string;\n output_plugin: string;\n};\n\nexport type ReplicationSlotResult<T> = {\n slot: ReplicationSlot;\n capturedSnapshot: T;\n initialSession: Readable;\n replica: ReplicaState;\n};\n\nexport type ReservedSlot = {\n slot: string;\n reservation: Readable;\n replica: ReplicaState;\n};\n\nexport type CreateSlotSpec = {\n slotName: string;\n\n // Note: ignored if pgVersion < PG_17.\n failover?: boolean;\n\n // Create a temporary slot (i.e. not persisted, and automatically\n // cleaned up when the replication session ends).\n temporary?: boolean;\n\n // For overriding in tests.\n lockTimeout?: number;\n\n // A (non-replication) connection to the same upstream instance, used to\n // log what slot creation was waiting on if it times out.\n diagnosticsDB?: PostgresDB | undefined;\n};\n\n// When creating a replication slot, Postgres waits for open transactions\n// to complete before reserving a consistent_point (LSN) in the WAL and creating\n// a matching transaction snapshot. As such, it can technically take an arbitrary\n// amount of time (e.g. DDL operations, table-wide operations, etc.).\n//\n// However, to detect pathological situations, bound the amount of time that\n// the server waits for replication slot creation, so that a continual failure to\n// create a replication slot is surfaced by errors / alerts.\nexport const CREATE_REPLICATION_SLOT_TIMEOUT_MS = 60_000;\n\n// The lock_timeout is set 1s before the client-side orTimeout so that\n// Postgres reliably aborts first and tears down the walsender cleanly.\n// The client-side timeout remains as a fallback for network-level failures.\nconst SERVER_LOCK_TIMEOUT_MS = CREATE_REPLICATION_SLOT_TIMEOUT_MS - 1_000;\n\n// Note: The replication connection does not support the extended query protocol,\n// so all commands must be sent using sql.unsafe(). This is technically safe\n// because all placeholder values are under our control (i.e. \"slotName\").\nexport async function createReplicationSlot(\n lc: LogContext,\n session: postgres.Sql,\n {\n slotName,\n failover,\n temporary,\n lockTimeout = SERVER_LOCK_TIMEOUT_MS,\n diagnosticsDB,\n }: CreateSlotSpec,\n): Promise<ReplicationSlot> {\n // CREATE_REPLICATION_SLOT can hang indefinitely waiting for long-running\n // transactions to finish: internally it calls SnapBuildWaitSnapshot →\n // XactLockTableWait → LockAcquire on each running XID. statement_timeout\n // does NOT apply to replication commands, but lock_timeout does (it governs\n // the heavyweight lock wait inside LockAcquire). Setting it here causes\n // Postgres to raise ERRCODE_LOCK_NOT_AVAILABLE and cleanly tear down the\n // walsender, rather than relying solely on the client-side orTimeout\n // which can leave an orphaned backend.\n //\n // An orphaned walsender is actively harmful: by this point the replication\n // slot has already been created and is pinning WAL retention and catalog_xmin.\n // Worse, the slot is marked `active` (the walsender PID is still alive), so\n // the existing cleanup code (which drops inactive slots on retry) can't\n // reclaim it. Without lock_timeout the orphan persists until TCP keepalive\n // fires (~2h default) or the blocking transaction finishes.\n await session.unsafe(`SET lock_timeout = ${lockTimeout}`);\n\n const {pgVersion, pid: walsenderPID} = (\n await session.unsafe<{pgVersion: number; pid: number}[]>(`\n SELECT current_setting('server_version_num') as \"pgVersion\",\n pg_backend_pid() as \"pid\";\n `)\n )[0];\n\n const maybeTemporary = temporary ? 'TEMPORARY' : '';\n const options = failover && pgVersion >= PG_17 ? '(FAILOVER)' : '';\n const createSlot = session.unsafe<ReplicationSlot[]>(/*sql*/ `\n CREATE_REPLICATION_SLOT \"${slotName}\" ${maybeTemporary} LOGICAL pgoutput ${options}`);\n\n const raced = await orTimeout(createSlot, CREATE_REPLICATION_SLOT_TIMEOUT_MS);\n if (raced === 'timed-out') {\n if (diagnosticsDB) {\n await logSlotCreationBlockers(lc, diagnosticsDB, walsenderPID, slotName);\n }\n // Create slot can block indefinitely waiting for old transactions. End\n // this connection in the background and fail fast so the process restarts.\n void session\n .end()\n .catch(e =>\n lc.warn?.(`Error closing timed out replication slot session`, e),\n );\n throw new Error(\n `Timed out after ${CREATE_REPLICATION_SLOT_TIMEOUT_MS} ms creating replication slot ${slotName}.`,\n );\n }\n const [slot] = raced;\n lc.info?.(`Created replication slot ${slotName}`, slot);\n return slot;\n}\n\nconst SLOT_DIAGNOSTICS_TIMEOUT_MS = 5_000;\nconst SLOT_DIAGNOSTICS_MAX_TRANSACTIONS = 20;\n\n/**\n * Best-effort logging of what a timed out CREATE_REPLICATION_SLOT was waiting\n * on: the walsender's wait event and blocking pids, and the oldest\n * transactions holding an xid. Slot creation waits for every such transaction\n * on the instance (in all databases), each with its own lock_timeout, so a\n * chain of overlapping transactions can exceed the client-side timeout\n * without the lock_timeout ever firing.\n *\n * Query text is deliberately omitted since it can contain row values.\n *\n * Exported for testing.\n */\nexport async function logSlotCreationBlockers(\n lc: LogContext,\n sql: PostgresDB,\n walsenderPID: number,\n slotName: string,\n) {\n try {\n const result = await orTimeout(\n Promise.all([\n sql<JSONObject[]> /*sql*/ `\n SELECT pid, wait_event_type as \"waitEventType\", wait_event as \"waitEvent\",\n pg_blocking_pids(pid) as \"blockingPIDs\"\n FROM pg_stat_activity WHERE pid = ${walsenderPID}`,\n sql<JSONObject[]> /*sql*/ `\n SELECT pid, datname, usename, application_name as \"applicationName\",\n backend_type as \"backendType\", state,\n wait_event_type as \"waitEventType\", wait_event as \"waitEvent\",\n backend_xid::text as xid,\n xact_start::text as \"xactStart\",\n extract(epoch from now() - xact_start)::float8 as \"xactAgeSec\"\n FROM pg_stat_activity\n WHERE backend_xid IS NOT NULL AND pid <> ${walsenderPID}\n ORDER BY xact_start NULLS LAST\n LIMIT ${SLOT_DIAGNOSTICS_MAX_TRANSACTIONS}`,\n ]),\n SLOT_DIAGNOSTICS_TIMEOUT_MS,\n );\n if (result === 'timed-out') {\n lc.warn?.(`Timed out collecting diagnostics for slot ${slotName}`);\n return;\n }\n const [[walsender], transactions] = result;\n lc.warn?.(`Replication slot ${slotName} creation blocked`, {\n walsender: walsender ?? null,\n transactions,\n });\n } catch (e) {\n lc.warn?.(`Unable to collect diagnostics for slot ${slotName}`, e);\n }\n}\n\n/**\n * Replica and slot creation involves two sessions for proper coordination\n * with other replica management logic:\n *\n * * A normal transaction is started and acquires an advisory lock for\n * replica slot management. This is the same lock that cleanup logic\n * acquires before cleaning up replication slots.\n * * With the lock held, a new replication slot is created in a\n * replication session. The API of CREATE_REPLICATION_SLOT is such\n * that it cannot be done in a transaction, and cannot be followed by\n * any writes, or else its snapshot (which is needed for initial sync)\n * would be invalidated.\n * * Once the slot is created, the slot and replica information are recorded\n * in the `replicas` table before releasing the lock.\n *\n * This locking ensures that:\n * 1. multiple replication managers attempting to create a replication slot\n * will not use the same name for the replication slot (which is selected\n * from a pool of reused names).\n * 2. Running replication managers (which use an earlier replica of a lower\n * rank) will not delete the new slot during their cleanup logic, since\n * the slot will belong to a replica of a higher rank.\n *\n * When the replication slot is created, `captureSnapshot` callback is first\n * run while the snapshot at the consistent point is available. The callback\n * must capture the snapshot in postgres (e.g. `SET TRANSACTION SNAPSHOT`)\n * in order to use it. Once the callback completes, a placeholder replication\n * session (i.e. that does not consume any messages) is started, in order to\n * reserve the slot by mark it \"active\", effectively distinguishing the\n * initializing session from a failed or abandoned session.\n *\n * Note that starting the replication session releases the initial snapshot,\n * which is why the callback must capture it synchronously, before the\n * replication session is started.\n *\n * The placeholder replication session is closed when the\n * PostgresChangeSource takes over the slot to process the stream in earnest.\n * It is also returned for cleanup in the case of failures (and for control\n * in unit tests).\n *\n * For replica's created in the `Restore` stage, the `generation` is\n * initialized to an empty string instead of the slot's consistent point,\n * since the slot is not being used for initial sync. It is the\n * responsibiliy of the caller to call {@link initRestoreReplica} when\n * the source replica has been selected for restoring, which will carry\n * over the `generation` and other relevant values from the source.\n */\nexport async function createReplicaAndSlot<T>(\n lc: LogContext,\n sql: PostgresDB,\n sessionName: string,\n shard: ShardID,\n epoch: number,\n replicaID: string,\n failover: boolean,\n backupOptions: BackupOptions,\n captureSnapshot: (snapshot: string) => Promise<T>,\n stage: ReplicaStage,\n): Promise<ReplicationSlotResult<T>> {\n // Note: The replicationSession is used to create the replication slot\n // and closed immediately afterwards, or on error.\n const replicationSession = createReplicationSessionFor(sql, sessionName);\n const lockName = replicationSlotManagementLock(shard);\n const slotPoolPrefix = replicationSlotPrefix(shard);\n\n for (let first = true; ; first = false) {\n await dropUnclaimedSlots(lc, sql, shard);\n\n let slotName: string | undefined;\n let initialSession: Readable | undefined;\n try {\n return await runTx(sql, async tx => {\n await tx`SELECT pg_advisory_xact_lock(hashtext(${lockName}))`;\n\n if (stage === InitialSync) {\n // With the lock acquired, ensure that only one initial sync is\n // active at a time. getRestoreCandidates() orders its results\n // with stage = InitialSync (and active = true) first.\n const others = await getRestoreCandidates(tx, shard, epoch);\n lc.info?.(`current replicas at epoch ${epoch}`, {replicas: others});\n if (others.length) {\n const [{id, stage, active}] = others;\n if (stage === InitialSync && active) {\n throw new AutoResetSignal(\n `another replica (${id}) is performing initial sync`,\n );\n }\n }\n }\n\n // Pick an available slotName from the slotPoolPrefix pool.\n const names = await tx<{name: string}[]> /*sql*/ `\n SELECT slot_name as name FROM pg_replication_slots\n WHERE slot_name LIKE ${slotPoolPrefix + '%'};\n `.values();\n const inUse = new Set(names.flat());\n for (let next = 0; ; next++) {\n const candidateName = `${slotPoolPrefix}${slotPoolSuffix(next)}`;\n if (!inUse.has(candidateName)) {\n slotName = candidateName;\n break;\n }\n }\n\n const slot = await createReplicationSlot(lc, replicationSession, {\n slotName,\n failover,\n diagnosticsDB: sql,\n });\n const capturedSnapshot = await captureSnapshot(slot.snapshot_name);\n await replicationSession.end();\n initialSession = await keepSlotActiveUntilTakenOver(lc, {\n db: extractConnectionConfig(sql),\n slot: slot.slot_name,\n dummyPublication: metadataPublicationName(\n shard.appID,\n shard.shardNum,\n ),\n lsn: String(toBigInt(slot.consistent_point)),\n });\n\n await createReplica(\n tx,\n shard,\n replicaID,\n slot.slot_name,\n epoch,\n stage === InitialSync\n ? toStateVersionString(slot.consistent_point)\n : '', // initialized with initRestoreReplica\n backupOptions,\n stage,\n );\n\n const replica = must(\n await getReplicaState(tx, shard, replicaID),\n `replica ${replicaID} was not created`,\n );\n\n return {slot, capturedSnapshot, initialSession, replica};\n });\n } catch (e) {\n if (first && isPostgresError(e, PG_INSUFFICIENT_PRIVILEGE)) {\n // Some Postgres variants (e.g. Google Cloud SQL) require that\n // the user have the REPLICATION role in order to create a slot.\n // Note that this must be done by the upstreamDB connection, and\n // does not work in the replicationSession itself.\n await sql`ALTER ROLE current_user WITH REPLICATION`;\n lc.info?.(`Added the REPLICATION role to database user`);\n continue;\n }\n // Note: This is currently manually tested since max_replication_slots\n // is a PG startup parameter that other tests depend on.\n // TODO: Figure out a way to unit test this (with the full PG setup).\n if (first && isPostgresError(e, PG_CONFIGURATION_LIMIT_EXCEEDED)) {\n lc.warn?.(\n `Reached max replication slots. Attempting to clean up unused slots`,\n e,\n );\n // Drop any inactive replicas from failed initial syncs (e.g. inactive slots).\n const replicasTable = `${upstreamSchema(shard)}.replicas`;\n await sql`\n DELETE FROM ${sql(replicasTable)} USING pg_replication_slots slots\n WHERE replicas.slot = slots.slot_name AND NOT slots.active`;\n continue; // then let dropUnclaimedSlots() perform its cleanup\n }\n if (initialSession) {\n try {\n await endSession(initialSession);\n } catch {}\n }\n // Otherwise, clean up any created slot if something went wrong. The\n // slot may not exist (e.g. if its creation failed), and its name may\n // have been reused since the lock was released, so leave it to\n // dropUnclaimedSlots(), which only drops slots without a replica.\n if (slotName) {\n lc.warn?.(`deleting slot ${slotName} due to error`, e);\n try {\n await replicationSession.end();\n } catch {}\n await dropUnclaimedSlots(lc, sql, shard);\n }\n throw e;\n }\n }\n}\n\n/**\n * Claims the inactive slot of the given replica for use by this task, opening\n * a replication session to mark it as active. The process should follow up by\n * taking over the slot via the PostgresChangeSource.\n *\n * The replica is identified by its ID (and not just its slot) because rows of\n * replicas whose slots were dropped outside of zero-cache's control may share\n * the slot name.\n */\nexport async function claimSlotForResumption(\n lc: LogContext,\n sql: PostgresDB,\n shard: ShardID,\n _sessionName: string,\n {id: replicaID, slot}: Pick<ReplicaState, 'id' | 'slot'>,\n): Promise<ReservedSlot | null> {\n const lockName = replicationSlotManagementLock(shard);\n const replicasTable = `${upstreamSchema(shard)}.replicas`;\n\n let reservation: Readable | undefined;\n try {\n const reserved = await runTx(sql, async tx => {\n await tx`SELECT pg_advisory_xact_lock(hashtext(${lockName}))`;\n\n const replicas = await tx<{active: boolean; lsn: string}[]> /*sql*/ `\n SELECT active, confirmed_flush_lsn as lsn\n FROM pg_replication_slots\n JOIN ${tx(replicasTable)} replica on slot_name = slot\n WHERE slot_name = ${slot} AND replica.id = ${replicaID};\n `;\n if (replicas.length === 0) {\n lc.warn?.(`no replica ${replicaID} found for slot ${slot}`);\n return null;\n }\n const [{active, lsn}] = replicas;\n if (active) {\n lc.warn?.(\n `replica ${replicaID} for slot ${slot} is active and cannot be claimed`,\n );\n return null;\n }\n // Mark the replica as Restoring.\n await tx`\n UPDATE ${tx(replicasTable)} SET \"stage\" = ${Restore} WHERE id = ${replicaID};\n `;\n const replica = must(\n await getReplicaState(tx, shard, replicaID),\n `replica ${replicaID} disappeared`,\n );\n\n reservation = await keepSlotActiveUntilTakenOver(lc, {\n db: extractConnectionConfig(sql),\n slot,\n dummyPublication: metadataPublicationName(shard.appID, shard.shardNum),\n lsn: String(toBigInt(lsn)),\n });\n return {\n slot,\n replica,\n reservation,\n };\n });\n lc.info?.(`successfully claimed slot ${slot}`);\n return reserved;\n } catch (e) {\n if (reservation) {\n try {\n await endSession(reservation);\n } catch {}\n }\n lc.error?.(`unable to claim replication slot ${slot}`, e);\n return null;\n }\n}\n\nexport function dropInactiveSlotsAndReplicas(\n lc: LogContext,\n sql: PostgresDB,\n shard: ShardID,\n slots: string[],\n) {\n const lockName = replicationSlotManagementLock(shard);\n const replicasTable = `${upstreamSchema(shard)}.replicas`;\n\n return runTx(sql, async tx => {\n await tx`SELECT pg_advisory_xact_lock(hashtext(${lockName}))`;\n\n const dropped = await tx<{slot: string; replicaID: string | null}[]>\n /*sql*/ `\n SELECT slot_name as slot, replica.id as \"replicaID\", pg_drop_replication_slot(slot_name) \n FROM pg_replication_slots\n LEFT JOIN ${tx(replicasTable)} replica on slot_name = slot\n WHERE slot_name IN ${tx(slots)} AND NOT active;\n `;\n if (dropped.length) {\n lc.info?.(`dropped ${dropped.length} inactive replication slot(s)`, {\n dropped,\n });\n }\n\n const replicas = dropped\n .map(({replicaID}) => replicaID)\n .filter(id => id !== null);\n if (replicas.length) {\n // Delete replicas associated with the inactive (and now dropped) slots.\n await tx\n /*sql*/ `DELETE FROM ${tx(replicasTable)} WHERE id IN ${tx(replicas)}`;\n lc.info?.(`deleted ${replicas.length} old replica(s)`, {replicas});\n }\n });\n}\n\nfunction dropUnclaimedSlots(\n lc: LogContext,\n sql: PostgresDB,\n shard: ShardID,\n): Promise<{dropped: number; active: number; draining: number}> {\n // The slot / replica cleanup happens within a transaction while holding\n // the replication slot management lock for this shard, to ensure that no\n // slot that belongs to a newer replica is dropped.\n const lockName = replicationSlotManagementLock(shard);\n const slotExpression = replicationSlotExpression(shard);\n const replicasTable = `${upstreamSchema(shard)}.replicas`;\n\n return runTx(sql, async tx => {\n await tx`SELECT pg_advisory_xact_lock(hashtext(${lockName}))`;\n\n const dropped = await tx /*sql*/ `\n SELECT slot_name as slot, pg_drop_replication_slot(slot_name) \n FROM pg_replication_slots\n LEFT JOIN ${tx(replicasTable)} replica on slot_name = slot\n WHERE slot_name LIKE ${slotExpression} \n AND NOT active\n AND replica.id IS NULL;\n `;\n if (dropped.length) {\n lc.info?.(`dropped inactive replication slots`, {dropped});\n }\n\n // Conversely, delete replicas whose slots no longer exist, e.g. because\n // they were dropped outside of zero-cache's control (such as in an\n // upstream failover). Slot names are reused, so such rows would otherwise\n // be associated with a new slot of the same name. This is done (and\n // committed) before the slot is created, both so that the rows are never\n // associated with it, and because CREATE_REPLICATION_SLOT waits for\n // transactions that have written (i.e. been assigned an xid).\n const orphaned = await tx<{id: string}[]> /*sql*/ `\n DELETE FROM ${tx(replicasTable)} replica\n WHERE NOT EXISTS (\n SELECT 1 FROM pg_replication_slots WHERE slot_name = replica.slot\n )\n RETURNING id;\n `;\n if (orphaned.length) {\n lc.warn?.(`deleted replicas whose slots no longer exist`, {\n replicas: orphaned.map(({id}) => id),\n });\n }\n\n const remaining = await tx<\n {slot: string; pid: number | null; id: string | null}[]\n > /*sql*/ `\n SELECT slot_name as slot, active_pid as pid, replica.id as id\n FROM pg_replication_slots\n LEFT JOIN ${tx(replicasTable)} replica on slot_name = slot\n WHERE slot_name LIKE ${slotExpression};\n `;\n if (remaining.length) {\n lc.info?.(`remaining replication slots`, {remaining});\n }\n\n let active = 0;\n let draining = 0;\n for (const {id} of remaining) {\n if (id === null) {\n draining++;\n } else {\n active++;\n }\n }\n\n return {\n dropped: dropped.length,\n active,\n draining,\n };\n });\n}\n\nconst ALPHABET = 'abcdefghijklmnopqrstuvwxyz';\n\n// Alphabetic notation is used as the slot pool suffix to distinguish\n// it from the (numeric) shard num that's also encoded in the slot name.\nexport function slotPoolSuffix(n: number) {\n n++; // Adjust for 0-based indexing\n\n let suffix = '';\n while (n > 0) {\n n--;\n suffix = ALPHABET[n % 26] + suffix;\n n = Math.floor(n / 26);\n }\n return suffix;\n}\n\nfunction replicationSlotManagementLock(shard: ShardID) {\n return `replication-slot-management:${shard.appID}_${shard.shardNum}`;\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"replication-slots.js","names":[],"sources":["../../../../../../../zero-cache/src/services/change-source/pg/replication-slots.ts"],"sourcesContent":["import type {Readable} from 'node:stream';\nimport {\n PG_CONFIGURATION_LIMIT_EXCEEDED,\n PG_INSUFFICIENT_PRIVILEGE,\n PG_LOCK_NOT_AVAILABLE,\n} from '@drdgvhbh/postgres-error-codes';\nimport type {LogContext} from '@rocicorp/logger';\nimport {defu} from 'defu';\nimport postgres, {type Options, type PostgresType} from 'postgres';\nimport type {JSONObject} from '../../../../../shared/src/bigint-json.ts';\nimport {must} from '../../../../../shared/src/must.ts';\nimport {sleep} from '../../../../../shared/src/sleep.ts';\nimport {runTx} from '../../../db/run-transaction.ts';\nimport {PG_17} from '../../../types/pg-versions.ts';\nimport {isPostgresError, type PostgresDB} from '../../../types/pg.ts';\nimport {upstreamSchema, type ShardID} from '../../../types/shards.ts';\nimport {orTimeout} from '../../../types/timeout.ts';\nimport {AutoResetSignal} from '../../change-streamer/schema/tables.ts';\nimport {\n createReplicationSessionFor,\n endSession,\n extractConnectionConfig,\n keepSlotActiveUntilTakenOver,\n} from './logical-replication/stream.ts';\nimport {toBigInt, toStateVersionString} from './lsn.ts';\nimport {\n InitialSync,\n Restore,\n type ReplicaStage,\n} from './schema/replica-stage-enum.ts';\nimport {\n createReplica,\n getReplicaState,\n getRestoreCandidates,\n metadataPublicationName,\n replicationSlotExpression,\n replicationSlotPrefix,\n type BackupOptions,\n type ReplicaState,\n} from './schema/shard.ts';\n\n// Record returned by `CREATE_REPLICATION_SLOT`\nexport type ReplicationSlot = {\n slot_name: string;\n consistent_point: string;\n snapshot_name: string;\n output_plugin: string;\n};\n\nexport type ReplicationSlotResult<T> = {\n slot: ReplicationSlot;\n capturedSnapshot: T;\n initialSession: Readable;\n replica: ReplicaState;\n};\n\nexport type ReservedSlot = {\n slot: string;\n reservation: Readable;\n replica: ReplicaState;\n};\n\nexport type CreateSlotSpec = {\n slotName: string;\n\n // Note: ignored if pgVersion < PG_17.\n failover?: boolean;\n\n // Create a temporary slot (i.e. not persisted, and automatically\n // cleaned up when the replication session ends).\n temporary?: boolean;\n\n // For overriding in tests.\n lockTimeout?: number | undefined;\n\n // A (non-replication) connection to the same upstream instance, used to\n // log what slot creation was waiting on if it times out.\n diagnosticsDB?: PostgresDB | undefined;\n};\n\n// When creating a replication slot, Postgres waits for open transactions\n// to complete before reserving a consistent_point (LSN) in the WAL and creating\n// a matching transaction snapshot. As such, it can technically take an arbitrary\n// amount of time (e.g. DDL operations, table-wide operations, etc.).\n//\n// However, to detect pathological situations, bound the amount of time that\n// the server waits for replication slot creation, so that a continual failure to\n// create a replication slot is surfaced by errors / alerts.\nexport const CREATE_REPLICATION_SLOT_TIMEOUT_MS = 60_000;\n\n// The lock_timeout is set 1s before the client-side orTimeout so that\n// Postgres reliably aborts first and tears down the walsender cleanly.\n// The client-side timeout remains as a fallback for network-level failures.\nconst SERVER_LOCK_TIMEOUT_MS = CREATE_REPLICATION_SLOT_TIMEOUT_MS - 1_000;\n\n/** Thrown when the client-side timeout for creating a slot is reached. */\nexport class SlotCreationTimeoutError extends Error {\n readonly name = 'SlotCreationTimeoutError';\n}\n\n// Note: The replication connection does not support the extended query protocol,\n// so all commands must be sent using sql.unsafe(). This is technically safe\n// because all placeholder values are under our control (i.e. \"slotName\").\nexport async function createReplicationSlot(\n lc: LogContext,\n session: postgres.Sql,\n {\n slotName,\n failover,\n temporary,\n lockTimeout = SERVER_LOCK_TIMEOUT_MS,\n diagnosticsDB,\n }: CreateSlotSpec,\n): Promise<ReplicationSlot> {\n // CREATE_REPLICATION_SLOT can hang indefinitely waiting for long-running\n // transactions to finish: internally it calls SnapBuildWaitSnapshot →\n // XactLockTableWait → LockAcquire on each running XID. statement_timeout\n // does NOT apply to replication commands, but lock_timeout does (it governs\n // the heavyweight lock wait inside LockAcquire). Setting it here causes\n // Postgres to raise ERRCODE_LOCK_NOT_AVAILABLE and cleanly tear down the\n // walsender, rather than relying solely on the client-side orTimeout\n // which can leave an orphaned backend.\n //\n // An orphaned walsender is actively harmful: by this point the replication\n // slot has already been created and is pinning WAL retention and catalog_xmin.\n // Worse, the slot is marked `active` (the walsender PID is still alive), so\n // the existing cleanup code (which drops inactive slots on retry) can't\n // reclaim it. Without lock_timeout the orphan persists until TCP keepalive\n // fires (~2h default) or the blocking transaction finishes.\n await session.unsafe(`SET lock_timeout = ${lockTimeout}`);\n\n const {pgVersion, pid: walsenderPID} = (\n await session.unsafe<{pgVersion: number; pid: number}[]>(`\n SELECT current_setting('server_version_num') as \"pgVersion\",\n pg_backend_pid() as \"pid\";\n `)\n )[0];\n\n const maybeTemporary = temporary ? 'TEMPORARY' : '';\n const options = failover && pgVersion >= PG_17 ? '(FAILOVER)' : '';\n const createSlot = session.unsafe<ReplicationSlot[]>(/*sql*/ `\n CREATE_REPLICATION_SLOT \"${slotName}\" ${maybeTemporary} LOGICAL pgoutput ${options}`);\n\n try {\n const raced = await orTimeout(\n createSlot,\n CREATE_REPLICATION_SLOT_TIMEOUT_MS,\n );\n if (raced === 'timed-out') {\n throw new SlotCreationTimeoutError(\n `Timed out after ${CREATE_REPLICATION_SLOT_TIMEOUT_MS} ms creating replication slot ${slotName}.`,\n );\n }\n const [slot] = raced;\n lc.info?.(`Created replication slot ${slotName}`, slot);\n return slot;\n } catch (e) {\n if (\n diagnosticsDB &&\n (isPostgresError(e, PG_LOCK_NOT_AVAILABLE) ||\n e instanceof SlotCreationTimeoutError)\n ) {\n // After a lock_timeout, the walsender is no longer waiting, but the\n // transactions it was waiting on are likely still running. After the\n // client-side timeout, it may still be waiting on them.\n await logSlotCreationBlockers(lc, diagnosticsDB, walsenderPID, slotName);\n }\n throw e;\n }\n}\n\nconst SLOT_DIAGNOSTICS_TIMEOUT_MS = 5_000;\nconst SLOT_DIAGNOSTICS_MAX_TRANSACTIONS = 20;\n\n/**\n * Best-effort logging of what a timed out CREATE_REPLICATION_SLOT was waiting\n * on: the walsender's wait event and blocking pids, and the oldest\n * transactions holding an xid. Slot creation waits for every such transaction\n * on the instance (in all databases), each with its own lock_timeout, so a\n * chain of overlapping transactions can exceed the client-side timeout\n * without the lock_timeout ever firing.\n *\n * Query text is deliberately omitted since it can contain row values.\n *\n * Exported for testing.\n */\nexport async function logSlotCreationBlockers(\n lc: LogContext,\n sql: PostgresDB,\n walsenderPID: number,\n slotName: string,\n) {\n try {\n const result = await orTimeout(\n Promise.all([\n sql<JSONObject[]> /*sql*/ `\n SELECT pid, wait_event_type as \"waitEventType\", wait_event as \"waitEvent\",\n pg_blocking_pids(pid) as \"blockingPIDs\"\n FROM pg_stat_activity WHERE pid = ${walsenderPID}`,\n sql<JSONObject[]> /*sql*/ `\n SELECT pid, datname, usename, application_name as \"applicationName\",\n backend_type as \"backendType\", state,\n wait_event_type as \"waitEventType\", wait_event as \"waitEvent\",\n backend_xid::text as xid,\n xact_start::text as \"xactStart\",\n extract(epoch from now() - xact_start)::float8 as \"xactAgeSec\"\n FROM pg_stat_activity\n WHERE backend_xid IS NOT NULL AND pid <> ${walsenderPID}\n ORDER BY xact_start NULLS LAST\n LIMIT ${SLOT_DIAGNOSTICS_MAX_TRANSACTIONS}`,\n ]),\n SLOT_DIAGNOSTICS_TIMEOUT_MS,\n );\n if (result === 'timed-out') {\n lc.warn?.(`Timed out collecting diagnostics for slot ${slotName}`);\n return;\n }\n const [[walsender], transactions] = result;\n lc.warn?.(`Replication slot ${slotName} creation blocked`, {\n walsender: walsender ?? null,\n transactions,\n });\n } catch (e) {\n lc.warn?.(`Unable to collect diagnostics for slot ${slotName}`, e);\n }\n}\n\n/**\n * Whether slot creation failed because it was waiting on transactions on the\n * upstream instance: the server's lock_timeout fired, the client-side timeout\n * was reached, or the replication session was closed while it was waiting.\n * Such failures are expected on busy upstreams, and leave no durable state,\n * so the attempt can simply be retried.\n */\nfunction isSlotCreationBlocked(e: unknown): boolean {\n return (\n isPostgresError(e, PG_LOCK_NOT_AVAILABLE) ||\n e instanceof SlotCreationTimeoutError ||\n (e instanceof Error && (e as {code?: unknown}).code === 'CONNECTION_CLOSED')\n );\n}\n\n// Slot creation is retried while it is blocked by upstream transactions, but\n// a slot that cannot be created for this long is surfaced as an error (i.e.\n// by exiting the process).\nconst DEFAULT_MAX_SLOT_CREATION_BLOCKED_MS = 30 * 60 * 1000;\n\n// Base delay between retries of blocked slot creation. The actual delay is\n// jittered to between 1x and 5x of this value.\nconst DEFAULT_SLOT_CREATION_RETRY_DELAY_MS = 1_000;\n\nexport type CreateReplicaAndSlotOptions = {\n // For overriding in tests.\n lockTimeout?: number | undefined;\n maxBlockedMs?: number | undefined;\n retryDelayMs?: number | undefined;\n};\n\n/**\n * Creates a dedicated (non-pooled) session for holding the session-level\n * replication slot management lock. Ending the session releases the lock,\n * which guarantees that the lock never outlives the attempt.\n */\nfunction createLockSessionFor(db: PostgresDB, applicationName: string) {\n return postgres(\n defu(\n {\n max: 1,\n ['idle_timeout']: null,\n ['max_lifetime']: null as unknown as number,\n connection: {['application_name']: applicationName},\n },\n // See createReplicationSessionFor() for why this cast is necessary.\n db.options as unknown as Options<Record<string, PostgresType>>,\n ),\n );\n}\n\n/**\n * Replica and slot creation involves several sessions for proper\n * coordination with other replica management logic:\n *\n * * A dedicated session acquires a (session-level) advisory lock for\n * replica slot management. This is the same lock that cleanup logic\n * acquires before cleaning up replication slots. The lock is held\n * outside of a transaction so that the session is not \"idle in\n * transaction\" while slot creation waits on upstream transactions,\n * which some upstreams terminate (e.g. idle_in_transaction_session_timeout).\n * * With the lock held, a new replication slot is created in a\n * replication session. The API of CREATE_REPLICATION_SLOT is such\n * that it cannot be done in a transaction, and cannot be followed by\n * any writes, or else its snapshot (which is needed for initial sync)\n * would be invalidated.\n * * Once the slot is created, the slot and replica information are recorded\n * in the `replicas` table before releasing the lock.\n *\n * Slot creation waits for all transactions on the upstream instance that\n * hold an xid, so it can be blocked by long-running transactions. A blocked\n * attempt is abandoned (see {@link CREATE_REPLICATION_SLOT_TIMEOUT_MS}) and\n * retried in-process, as it leaves no durable state: the `replicas` row is\n * only inserted after the slot has been created. Retries continue until the\n * slot is created or until `maxBlockedMs` has elapsed, after which the error\n * is thrown.\n *\n * This locking ensures that:\n * 1. multiple replication managers attempting to create a replication slot\n * will not use the same name for the replication slot (which is selected\n * from a pool of reused names).\n * 2. Running replication managers (which use an earlier replica of a lower\n * rank) will not delete the new slot during their cleanup logic, since\n * the slot will belong to a replica of a higher rank.\n *\n * When the replication slot is created, `captureSnapshot` callback is first\n * run while the snapshot at the consistent point is available. The callback\n * must capture the snapshot in postgres (e.g. `SET TRANSACTION SNAPSHOT`)\n * in order to use it. Once the callback completes, a placeholder replication\n * session (i.e. that does not consume any messages) is started, in order to\n * reserve the slot by mark it \"active\", effectively distinguishing the\n * initializing session from a failed or abandoned session.\n *\n * Note that starting the replication session releases the initial snapshot,\n * which is why the callback must capture it synchronously, before the\n * replication session is started.\n *\n * The placeholder replication session is closed when the\n * PostgresChangeSource takes over the slot to process the stream in earnest.\n * It is also returned for cleanup in the case of failures (and for control\n * in unit tests).\n *\n * For replica's created in the `Restore` stage, the `generation` is\n * initialized to an empty string instead of the slot's consistent point,\n * since the slot is not being used for initial sync. It is the\n * responsibiliy of the caller to call {@link initRestoreReplica} when\n * the source replica has been selected for restoring, which will carry\n * over the `generation` and other relevant values from the source.\n */\nexport async function createReplicaAndSlot<T>(\n lc: LogContext,\n sql: PostgresDB,\n sessionName: string,\n shard: ShardID,\n epoch: number,\n replicaID: string,\n failover: boolean,\n backupOptions: BackupOptions,\n captureSnapshot: (snapshot: string) => Promise<T>,\n stage: ReplicaStage,\n {\n lockTimeout,\n maxBlockedMs = DEFAULT_MAX_SLOT_CREATION_BLOCKED_MS,\n retryDelayMs = DEFAULT_SLOT_CREATION_RETRY_DELAY_MS,\n }: CreateReplicaAndSlotOptions = {},\n): Promise<ReplicationSlotResult<T>> {\n const lockName = replicationSlotManagementLock(shard);\n const slotPoolPrefix = replicationSlotPrefix(shard);\n const start = Date.now();\n let addedReplicationRole = false;\n let cleanedUpForSlotLimit = false;\n\n for (let attempt = 1; ; attempt++) {\n await dropUnclaimedSlots(lc, sql, shard);\n\n // Note: The replicationSession is used to create the replication slot\n // and closed immediately afterwards, or on error. A new session is used\n // for each attempt, since a failed attempt closes it.\n const replicationSession = createReplicationSessionFor(sql, sessionName);\n const lockSession = createLockSessionFor(sql, `${sessionName}-lock`);\n // Ending the lock session releases the replication slot management lock.\n // The sessions are ended with a timeout in case a hung slot creation\n // (e.g. after a network partition) is still pending.\n const endSessions = () =>\n Promise.allSettled([\n replicationSession.end({timeout: 5}),\n lockSession.end({timeout: 5}),\n ]);\n\n let slotName: string | undefined;\n let initialSession: Readable | undefined;\n let creatingSlot = false;\n try {\n await lockSession`SELECT pg_advisory_lock(hashtext(${lockName}))`;\n\n if (stage === InitialSync) {\n // With the lock acquired, ensure that only one initial sync is\n // active at a time. getRestoreCandidates() orders its results\n // with stage = InitialSync (and active = true) first.\n const others = await getRestoreCandidates(sql, shard, epoch);\n lc.info?.(`current replicas at epoch ${epoch}`, {replicas: others});\n if (others.length) {\n const [{id, stage, active}] = others;\n if (stage === InitialSync && active) {\n throw new AutoResetSignal(\n `another replica (${id}) is performing initial sync`,\n );\n }\n }\n }\n\n // Pick an available slotName from the slotPoolPrefix pool.\n const names = await sql<{name: string}[]> /*sql*/ `\n SELECT slot_name as name FROM pg_replication_slots\n WHERE slot_name LIKE ${slotPoolPrefix + '%'};\n `.values();\n const inUse = new Set(names.flat());\n for (let next = 0; ; next++) {\n const candidateName = `${slotPoolPrefix}${slotPoolSuffix(next)}`;\n if (!inUse.has(candidateName)) {\n slotName = candidateName;\n break;\n }\n }\n\n creatingSlot = true;\n const slot = await createReplicationSlot(lc, replicationSession, {\n slotName,\n failover,\n lockTimeout,\n diagnosticsDB: sql,\n });\n creatingSlot = false;\n const capturedSnapshot = await captureSnapshot(slot.snapshot_name);\n await replicationSession.end({timeout: 5});\n initialSession = await keepSlotActiveUntilTakenOver(lc, {\n db: extractConnectionConfig(sql),\n slot: slot.slot_name,\n dummyPublication: metadataPublicationName(shard.appID, shard.shardNum),\n lsn: String(toBigInt(slot.consistent_point)),\n });\n\n const replica = await runTx(sql, async tx => {\n await createReplica(\n tx,\n shard,\n replicaID,\n slot.slot_name,\n epoch,\n stage === InitialSync\n ? toStateVersionString(slot.consistent_point)\n : '', // initialized with initRestoreReplica\n backupOptions,\n stage,\n );\n return must(\n await getReplicaState(tx, shard, replicaID),\n `replica ${replicaID} was not created`,\n );\n });\n\n return {slot, capturedSnapshot, initialSession, replica};\n } catch (e) {\n // Release the lock (and end the attempt's sessions) before handling the\n // error, e.g. as dropUnclaimedSlots() acquires the lock from a different\n // session.\n await endSessions();\n if (\n !addedReplicationRole &&\n isPostgresError(e, PG_INSUFFICIENT_PRIVILEGE)\n ) {\n // Some Postgres variants (e.g. Google Cloud SQL) require that\n // the user have the REPLICATION role in order to create a slot.\n // Note that this must be done by the upstreamDB connection, and\n // does not work in the replicationSession itself.\n await sql`ALTER ROLE current_user WITH REPLICATION`;\n lc.info?.(`Added the REPLICATION role to database user`);\n addedReplicationRole = true;\n continue;\n }\n // Note: This is currently manually tested since max_replication_slots\n // is a PG startup parameter that other tests depend on.\n // TODO: Figure out a way to unit test this (with the full PG setup).\n if (\n !cleanedUpForSlotLimit &&\n isPostgresError(e, PG_CONFIGURATION_LIMIT_EXCEEDED)\n ) {\n lc.warn?.(\n `Reached max replication slots. Attempting to clean up unused slots`,\n e,\n );\n // Drop any inactive replicas from failed initial syncs (e.g. inactive slots).\n const replicasTable = `${upstreamSchema(shard)}.replicas`;\n await sql`\n DELETE FROM ${sql(replicasTable)} USING pg_replication_slots slots\n WHERE replicas.slot = slots.slot_name AND NOT slots.active`;\n cleanedUpForSlotLimit = true;\n continue; // then let dropUnclaimedSlots() perform its cleanup\n }\n if (initialSession) {\n try {\n await endSession(initialSession);\n } catch {}\n }\n // The slot may not exist (e.g. if its creation failed), and its name\n // may be reused once the lock is released, so any created slot is\n // left to dropUnclaimedSlots(), which only drops slots without a\n // replica. This runs at the start of the next attempt.\n const blockedMs = Date.now() - start;\n if (\n creatingSlot &&\n isSlotCreationBlocked(e) &&\n blockedMs < maxBlockedMs\n ) {\n lc.warn?.(\n `Replication slot ${slotName} creation blocked by upstream transactions ` +\n `(attempt ${attempt}, ${blockedMs} ms). Retrying.`,\n e,\n );\n await sleep(retryDelayMs * (1 + 4 * Math.random()));\n continue;\n }\n if (slotName) {\n lc.warn?.(`deleting slot ${slotName} due to error`, e);\n await dropUnclaimedSlots(lc, sql, shard);\n }\n throw e;\n } finally {\n await endSessions();\n }\n }\n}\n\n/**\n * Claims the inactive slot of the given replica for use by this task, opening\n * a replication session to mark it as active. The process should follow up by\n * taking over the slot via the PostgresChangeSource.\n *\n * The replica is identified by its ID (and not just its slot) because rows of\n * replicas whose slots were dropped outside of zero-cache's control may share\n * the slot name.\n */\nexport async function claimSlotForResumption(\n lc: LogContext,\n sql: PostgresDB,\n shard: ShardID,\n _sessionName: string,\n {id: replicaID, slot}: Pick<ReplicaState, 'id' | 'slot'>,\n): Promise<ReservedSlot | null> {\n const lockName = replicationSlotManagementLock(shard);\n const replicasTable = `${upstreamSchema(shard)}.replicas`;\n\n let reservation: Readable | undefined;\n try {\n const reserved = await runTx(sql, async tx => {\n await tx`SELECT pg_advisory_xact_lock(hashtext(${lockName}))`;\n\n const replicas = await tx<{active: boolean; lsn: string}[]> /*sql*/ `\n SELECT active, confirmed_flush_lsn as lsn\n FROM pg_replication_slots\n JOIN ${tx(replicasTable)} replica on slot_name = slot\n WHERE slot_name = ${slot} AND replica.id = ${replicaID};\n `;\n if (replicas.length === 0) {\n lc.warn?.(`no replica ${replicaID} found for slot ${slot}`);\n return null;\n }\n const [{active, lsn}] = replicas;\n if (active) {\n lc.warn?.(\n `replica ${replicaID} for slot ${slot} is active and cannot be claimed`,\n );\n return null;\n }\n // Mark the replica as Restoring.\n await tx`\n UPDATE ${tx(replicasTable)} SET \"stage\" = ${Restore} WHERE id = ${replicaID};\n `;\n const replica = must(\n await getReplicaState(tx, shard, replicaID),\n `replica ${replicaID} disappeared`,\n );\n\n reservation = await keepSlotActiveUntilTakenOver(lc, {\n db: extractConnectionConfig(sql),\n slot,\n dummyPublication: metadataPublicationName(shard.appID, shard.shardNum),\n lsn: String(toBigInt(lsn)),\n });\n return {\n slot,\n replica,\n reservation,\n };\n });\n lc.info?.(`successfully claimed slot ${slot}`);\n return reserved;\n } catch (e) {\n if (reservation) {\n try {\n await endSession(reservation);\n } catch {}\n }\n lc.error?.(`unable to claim replication slot ${slot}`, e);\n return null;\n }\n}\n\nexport function dropInactiveSlotsAndReplicas(\n lc: LogContext,\n sql: PostgresDB,\n shard: ShardID,\n slots: string[],\n) {\n const lockName = replicationSlotManagementLock(shard);\n const replicasTable = `${upstreamSchema(shard)}.replicas`;\n\n return runTx(sql, async tx => {\n await tx`SELECT pg_advisory_xact_lock(hashtext(${lockName}))`;\n\n const dropped = await tx<{slot: string; replicaID: string | null}[]>\n /*sql*/ `\n SELECT slot_name as slot, replica.id as \"replicaID\", pg_drop_replication_slot(slot_name) \n FROM pg_replication_slots\n LEFT JOIN ${tx(replicasTable)} replica on slot_name = slot\n WHERE slot_name IN ${tx(slots)} AND NOT active;\n `;\n if (dropped.length) {\n lc.info?.(`dropped ${dropped.length} inactive replication slot(s)`, {\n dropped,\n });\n }\n\n const replicas = dropped\n .map(({replicaID}) => replicaID)\n .filter(id => id !== null);\n if (replicas.length) {\n // Delete replicas associated with the inactive (and now dropped) slots.\n await tx\n /*sql*/ `DELETE FROM ${tx(replicasTable)} WHERE id IN ${tx(replicas)}`;\n lc.info?.(`deleted ${replicas.length} old replica(s)`, {replicas});\n }\n });\n}\n\nfunction dropUnclaimedSlots(\n lc: LogContext,\n sql: PostgresDB,\n shard: ShardID,\n): Promise<{dropped: number; active: number; draining: number}> {\n // The slot / replica cleanup happens within a transaction while holding\n // the replication slot management lock for this shard, to ensure that no\n // slot that belongs to a newer replica is dropped.\n const lockName = replicationSlotManagementLock(shard);\n const slotExpression = replicationSlotExpression(shard);\n const replicasTable = `${upstreamSchema(shard)}.replicas`;\n\n return runTx(sql, async tx => {\n await tx`SELECT pg_advisory_xact_lock(hashtext(${lockName}))`;\n\n const dropped = await tx /*sql*/ `\n SELECT slot_name as slot, pg_drop_replication_slot(slot_name) \n FROM pg_replication_slots\n LEFT JOIN ${tx(replicasTable)} replica on slot_name = slot\n WHERE slot_name LIKE ${slotExpression} \n AND NOT active\n AND replica.id IS NULL;\n `;\n if (dropped.length) {\n lc.info?.(`dropped inactive replication slots`, {dropped});\n }\n\n // Conversely, delete replicas whose slots no longer exist, e.g. because\n // they were dropped outside of zero-cache's control (such as in an\n // upstream failover). Slot names are reused, so such rows would otherwise\n // be associated with a new slot of the same name. This is done (and\n // committed) before the slot is created, both so that the rows are never\n // associated with it, and because CREATE_REPLICATION_SLOT waits for\n // transactions that have written (i.e. been assigned an xid).\n const orphaned = await tx<{id: string}[]> /*sql*/ `\n DELETE FROM ${tx(replicasTable)} replica\n WHERE NOT EXISTS (\n SELECT 1 FROM pg_replication_slots WHERE slot_name = replica.slot\n )\n RETURNING id;\n `;\n if (orphaned.length) {\n lc.warn?.(`deleted replicas whose slots no longer exist`, {\n replicas: orphaned.map(({id}) => id),\n });\n }\n\n const remaining = await tx<\n {slot: string; pid: number | null; id: string | null}[]\n > /*sql*/ `\n SELECT slot_name as slot, active_pid as pid, replica.id as id\n FROM pg_replication_slots\n LEFT JOIN ${tx(replicasTable)} replica on slot_name = slot\n WHERE slot_name LIKE ${slotExpression};\n `;\n if (remaining.length) {\n lc.info?.(`remaining replication slots`, {remaining});\n }\n\n let active = 0;\n let draining = 0;\n for (const {id} of remaining) {\n if (id === null) {\n draining++;\n } else {\n active++;\n }\n }\n\n return {\n dropped: dropped.length,\n active,\n draining,\n };\n });\n}\n\nconst ALPHABET = 'abcdefghijklmnopqrstuvwxyz';\n\n// Alphabetic notation is used as the slot pool suffix to distinguish\n// it from the (numeric) shard num that's also encoded in the slot name.\nexport function slotPoolSuffix(n: number) {\n n++; // Adjust for 0-based indexing\n\n let suffix = '';\n while (n > 0) {\n n--;\n suffix = ALPHABET[n % 26] + suffix;\n n = Math.floor(n / 26);\n }\n return suffix;\n}\n\nfunction replicationSlotManagementLock(shard: ShardID) {\n return `replication-slot-management:${shard.appID}_${shard.shardNum}`;\n}\n"],"mappings":"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"}
@@ -7,7 +7,7 @@ import { type Sized, type Source } from '../../types/streams.ts';
7
7
  import { Subscription } from '../../types/subscription.ts';
8
8
  import { HttpService, type Options as HttpOptions } from '../http-service.ts';
9
9
  import { type ChangeStreamer, type ChangeStreamerService, type SubscriberContext } from './change-streamer.ts';
10
- import { type SnapshotStatus, type SubscribeDownstream, type SubscribeUpstream } from './subscribe.ts';
10
+ import { type LocalReplica, type SnapshotStatus, type SubscribeDownstream, type SubscribeUpstream } from './subscribe.ts';
11
11
  type Options = HttpOptions & {
12
12
  startupDelayMs: number;
13
13
  config?: Pick<NormalizedZeroConfig, 'adminPassword' | 'operatorPassword'> | undefined;
@@ -18,10 +18,16 @@ export declare class ChangeStreamerHttpServer extends HttpService {
18
18
  readonly id = "change-streamer-http-server";
19
19
  constructor(lc: LogContext, opts: Options, parent: Worker, changeStreamer: ChangeStreamerService);
20
20
  protected _onStart(): void;
21
+ /**
22
+ * Signals (drainable) subscribers to switch over to another
23
+ * replication-manager, resolving when they have disconnected. Subscribers
24
+ * continue to be served in the meantime.
25
+ */
26
+ drain(): Promise<void>;
21
27
  protected _onStop(): Promise<void>;
22
28
  }
23
29
  export interface SnapshotReserver {
24
- reserveSnapshot(taskID: string): Promise<{
30
+ reserveSnapshot(taskID: string, localReplica?: LocalReplica | undefined): Promise<{
25
31
  reserved: SnapshotStatus;
26
32
  followup: ReservationFollowup;
27
33
  }>;
@@ -46,7 +52,7 @@ export interface ReservationFollowup extends SubscriptionStarter {
46
52
  export declare class ChangeStreamerHttpClient implements ChangeStreamer, SnapshotReserver, SubscriptionStarter {
47
53
  #private;
48
54
  constructor(lc: LogContext, shardID: ShardID, changeDB: string, changeStreamerURI: string | undefined);
49
- reserveSnapshot(taskID: string): Promise<{
55
+ reserveSnapshot(taskID: string, localReplica?: LocalReplica | undefined): Promise<{
50
56
  reserved: SnapshotStatus;
51
57
  followup: ReservationFollowup;
52
58
  }>;
@@ -1 +1 @@
1
- {"version":3,"file":"change-streamer-http.d.ts","sourceRoot":"","sources":["../../../../../../zero-cache/src/services/change-streamer/change-streamer-http.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"change-streamer-http.d.ts","sourceRoot":"","sources":["../../../../../../zero-cache/src/services/change-streamer/change-streamer-http.ts"],"names":[],"mappings":"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"}
@@ -93,9 +93,9 @@ var ChangeStreamerHttpServer = class extends HttpService {
93
93
  if (protocolVersion < MIN_SUBSCRIBE_PROTOCOL_VERSION) throw new Error(`the /subscribe endpoint requires protocol v${MIN_SUBSCRIBE_PROTOCOL_VERSION} (client is at v${protocolVersion})`);
94
94
  const instream = await streamInternalStringified(this._lc, ws, subscribeUpstreamSchema, outbound, { batched: true });
95
95
  for await (const msg of instream) if (msg[0] === "reserve-snapshot") {
96
- const { taskID } = msg[1];
96
+ const { taskID, localReplica } = msg[1];
97
97
  this.#ensureChangeStreamerStarted("incoming snapshot reservation");
98
- reservation = await this.#changeStreamer.startSnapshotReservation(taskID);
98
+ reservation = await this.#changeStreamer.startSnapshotReservation(taskID, { localReplica });
99
99
  this.#forwardReservation(reservation, outbound);
100
100
  } else if (msg[0] === "start-subscription") {
101
101
  const ctx = {
@@ -147,6 +147,14 @@ var ChangeStreamerHttpServer = class extends HttpService {
147
147
  this._state.setTimeout(() => this.#ensureChangeStreamerStarted(`startup delay elapsed (${startupDelayMs} ms)`), startupDelayMs);
148
148
  });
149
149
  }
150
+ /**
151
+ * Signals (drainable) subscribers to switch over to another
152
+ * replication-manager, resolving when they have disconnected. Subscribers
153
+ * continue to be served in the meantime.
154
+ */
155
+ async drain() {
156
+ if (this.#changeStreamerStarted && this.#changeStreamer.drain) await this.#changeStreamer.drain();
157
+ }
150
158
  async _onStop() {
151
159
  if (this.#changeStreamerStarted) await this.#changeStreamer.stop();
152
160
  }
@@ -176,9 +184,12 @@ var ChangeStreamerHttpClient = class {
176
184
  this.#lc.info?.(`connecting to change-streamer@${uri}`);
177
185
  return uri;
178
186
  }
179
- async reserveSnapshot(taskID) {
187
+ async reserveSnapshot(taskID, localReplica) {
180
188
  const { instream, outbound } = await this.connect(taskID);
181
- outbound.push(["reserve-snapshot", { taskID }]);
189
+ outbound.push(["reserve-snapshot", {
190
+ taskID,
191
+ ...localReplica ? { localReplica } : {}
192
+ }]);
182
193
  const { head, rest } = readHead(instream);
183
194
  const item = await head.next();
184
195
  if (!item.value) throw new Error("stream closed without snapshot reservation");
@@ -240,14 +251,16 @@ function checkProtocolVersion(pathname) {
240
251
  return v;
241
252
  }
242
253
  function toStartSubscriptionContext(ctx) {
243
- const { taskID, id, mode, replicaVersion, watermark, backfills } = ctx;
254
+ const { taskID, id, mode, replicaVersion, watermark, backfills, drainable, switchover } = ctx;
244
255
  return {
245
256
  taskID,
246
257
  id,
247
258
  mode,
248
259
  replicaVersion,
249
260
  watermark,
250
- backfills
261
+ backfills,
262
+ ...drainable !== void 0 ? { drainable } : {},
263
+ ...switchover !== void 0 ? { switchover } : {}
251
264
  };
252
265
  }
253
266
  //#endregion