@relaybus/postgres 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -2,32 +2,131 @@
2
2
 
3
3
  var crypto = require('crypto');
4
4
  var core = require('@relaybus/core');
5
+ var internal = require('@relaybus/core/internal');
5
6
  var pg = require('pg');
6
7
 
8
+ // src/pg-executor.ts
9
+
10
+ // src/transaction-timeouts.ts
11
+ function budgetSettingsSql({ value, sessionTimeouts, scope }) {
12
+ const local = scope === "transaction";
13
+ const statementTimeout = `SELECT set_config('statement_timeout', ${value}, ${local})`;
14
+ if (!sessionTimeouts) {
15
+ return statementTimeout;
16
+ }
17
+ return `${statementTimeout}, set_config('idle_in_transaction_session_timeout', ${value}, ${local}), CASE WHEN current_setting('server_version_num')::int >= 170000 THEN set_config('transaction_timeout', ${value}, ${local}) END`;
18
+ }
19
+ var SESSION_DEFAULTS_SQL = "SELECT set_config(name, reset_val, false) FROM pg_settings WHERE name IN ('statement_timeout', 'idle_in_transaction_session_timeout', 'transaction_timeout')";
20
+ function budgetValue(timeoutMs) {
21
+ return `${Math.ceil(timeoutMs)}ms`;
22
+ }
23
+ function inBudgetedTransaction(state, {
24
+ work,
25
+ timeoutMs
26
+ }) {
27
+ const { executor } = state;
28
+ if (executor.transactionWithBudget !== void 0) {
29
+ return executor.transactionWithBudget(work, timeoutMs);
30
+ }
31
+ const sql = budgetSettingsSql({
32
+ value: "$1",
33
+ sessionTimeouts: executor.sessionTimeouts === true,
34
+ scope: "transaction"
35
+ });
36
+ return executor.transaction(async (transaction) => {
37
+ await state.query({ sql, parameters: [budgetValue(timeoutMs)], transaction });
38
+ return work(transaction);
39
+ });
40
+ }
41
+
7
42
  // src/pg-executor.ts
8
43
  var FOREIGN_SESSION_ERRORS = /* @__PURE__ */ new Set(["26000", "42P05"]);
44
+ var STATEMENT_NAME_DIGITS = 24;
9
45
  var PgExecutor = class {
10
46
  #pool;
11
47
  #logger;
12
48
  #names = /* @__PURE__ */ new Map();
13
49
  #preparing = true;
50
+ #sessionBudget;
51
+ /** The budget each connection keeps at session level (`sessionBudget`); absent for the defaults. */
52
+ #sessionValues = /* @__PURE__ */ new WeakMap();
14
53
  constructor(pool, options = {}) {
15
54
  this.#pool = pool;
16
55
  this.#logger = options.logger ?? console;
56
+ this.#sessionBudget = options.sessionBudget === true;
57
+ }
58
+ /** The connection listens for `error` during the transaction, so a server-ended session fails the transaction instead of the process. */
59
+ sessionTimeouts = true;
60
+ transaction(work) {
61
+ return this.#transaction(
62
+ work,
63
+ (client) => this.#sessionBudget && (!this.#preparing || this.#sessionValues.has(client)) ? { begin: `BEGIN; ${SESSION_DEFAULTS_SQL}`, sessionValue: null } : { begin: "BEGIN" }
64
+ );
65
+ }
66
+ /**
67
+ * Sends `BEGIN` and the budget settings as one simple query (the value as a literal: the simple
68
+ * query protocol has no parameters), so the budget costs no round trip. With `sessionBudget` a
69
+ * connection that already keeps this budget only gets `BEGIN`.
70
+ */
71
+ transactionWithBudget(work, timeoutMs) {
72
+ const value = budgetValue(timeoutMs);
73
+ const settings = (scope) => budgetSettingsSql({ value: `'${value}'`, sessionTimeouts: true, scope });
74
+ return this.#transaction(work, (client) => {
75
+ if (!this.#keepsSessionBudget()) {
76
+ return { begin: `BEGIN; ${settings("transaction")}` };
77
+ }
78
+ return this.#sessionValues.get(client) === value ? { begin: "BEGIN" } : { begin: `BEGIN; ${settings("session")}`, sessionValue: value };
79
+ });
17
80
  }
18
- async transaction(work) {
81
+ #recordSession(client, sessionValue) {
82
+ if (sessionValue === null) {
83
+ this.#sessionValues.delete(client);
84
+ } else if (sessionValue !== void 0) {
85
+ this.#sessionValues.set(client, sessionValue);
86
+ }
87
+ }
88
+ /** A pooler that hands out other server sessions (noticed by prepared statements) shares session settings. */
89
+ #keepsSessionBudget() {
90
+ return this.#sessionBudget && this.#preparing;
91
+ }
92
+ async #transaction(work, start) {
19
93
  const client = await this.#pool.connect();
94
+ let released = false;
95
+ const release = (destroy) => {
96
+ if (released) {
97
+ return;
98
+ }
99
+ released = true;
100
+ client.removeListener("error", onSessionEnded);
101
+ if (destroy) {
102
+ client.release(true);
103
+ } else {
104
+ client.release();
105
+ }
106
+ };
107
+ const onSessionEnded = (error) => {
108
+ this.#logger.warn(
109
+ `[PgExecutor](transaction) The server ended the session of an open transaction, the transaction fails | error=${error.message}`
110
+ );
111
+ release(true);
112
+ };
113
+ client.on("error", onSessionEnded);
114
+ const { begin, sessionValue } = start(client);
20
115
  try {
21
- await client.query("BEGIN");
116
+ await client.query(begin);
22
117
  const result = await work(client);
23
- await client.query("COMMIT");
24
- client.release();
118
+ const { command } = await client.query("COMMIT");
119
+ if (command === "COMMIT") {
120
+ this.#recordSession(client, sessionValue);
121
+ }
122
+ release(false);
25
123
  return result;
26
124
  } catch (error) {
27
- await this.#rollbackAndRelease(client);
125
+ await this.#rollbackAndRelease(client, release);
28
126
  throw error;
29
127
  }
30
128
  }
129
+ // eslint-disable-next-line relaybus/max-two-params -- PostgresExecutor port signature
31
130
  async query(sql, parameters, transaction, options) {
32
131
  const queryable = transaction ?? this.#pool;
33
132
  const values = [...parameters];
@@ -56,18 +155,18 @@ var PgExecutor = class {
56
155
  #nameOf(sql) {
57
156
  let name = this.#names.get(sql);
58
157
  if (name === void 0) {
59
- name = `relaybus_${crypto.createHash("sha256").update(sql).digest("hex").slice(0, 24)}`;
158
+ name = `relaybus_${crypto.createHash("sha256").update(sql).digest("hex").slice(0, STATEMENT_NAME_DIGITS)}`;
60
159
  this.#names.set(sql, name);
61
160
  }
62
161
  return name;
63
162
  }
64
163
  /** A client whose rollback fails is in an unknown state, so it is destroyed instead of reused. */
65
- async #rollbackAndRelease(client) {
164
+ async #rollbackAndRelease(client, release) {
66
165
  try {
67
166
  await client.query("ROLLBACK");
68
- client.release();
167
+ release(false);
69
168
  } catch {
70
- client.release(true);
169
+ release(true);
71
170
  }
72
171
  }
73
172
  };
@@ -90,6 +189,26 @@ var DEFAULT_SCHEMA = "public";
90
189
  var MAXIMUM_CHANNEL_LENGTH = 63;
91
190
  var DEFAULT_TABLE_PREFIX = "relaybus_";
92
191
  var TABLE_PREFIX_PATTERN = /^[a-z0-9_]*$/;
192
+ function indexNames(index) {
193
+ return {
194
+ deliveryEventHandler: index("deliveries_event_handler_unique"),
195
+ deliveryLaneDue: index("deliveries_lane_due_idx"),
196
+ deliveryKeyPending: index("deliveries_key_pending_idx"),
197
+ eventCreated: index("events_created_idx"),
198
+ deliveryCompleted: index("deliveries_completed_idx"),
199
+ deliveryDead: index("deliveries_dead_idx"),
200
+ completionCompleted: index("completed_deliveries_completed_idx"),
201
+ eventCompletionsCompleted: index("event_completions_completed_idx"),
202
+ obsolete: [
203
+ index("deliveries_due_idx"),
204
+ index("deliveries_due_unkeyed_idx"),
205
+ index("deliveries_handler_created_idx"),
206
+ index("deliveries_key_unfinished_idx"),
207
+ index("deliveries_key_processing_idx"),
208
+ index("deliveries_status_created_idx")
209
+ ]
210
+ };
211
+ }
93
212
  function resolvePostgresNames(options = {}) {
94
213
  const schema = options.schema ?? DEFAULT_SCHEMA;
95
214
  const tablePrefix = options.tablePrefix ?? DEFAULT_TABLE_PREFIX;
@@ -119,28 +238,12 @@ function resolvePostgresNames(options = {}) {
119
238
  completions: table("completed_deliveries"),
120
239
  completionHistory: options.completionHistory ?? false,
121
240
  notificationChannel: channelName(`${schema}.${tablePrefix}deliveries`),
122
- indexes: {
123
- deliveryEventHandler: index("deliveries_event_handler_unique"),
124
- deliveryLaneDue: index("deliveries_lane_due_idx"),
125
- deliveryKeyPending: index("deliveries_key_pending_idx"),
126
- eventCreated: index("events_created_idx"),
127
- deliveryCompleted: index("deliveries_completed_idx"),
128
- deliveryDead: index("deliveries_dead_idx"),
129
- completionCompleted: index("completed_deliveries_completed_idx"),
130
- eventCompletionsCompleted: index("event_completions_completed_idx"),
131
- obsolete: [
132
- index("deliveries_due_idx"),
133
- index("deliveries_due_unkeyed_idx"),
134
- index("deliveries_handler_created_idx"),
135
- index("deliveries_key_unfinished_idx"),
136
- index("deliveries_key_processing_idx"),
137
- index("deliveries_status_created_idx")
138
- ]
139
- }
241
+ indexes: indexNames(index)
140
242
  };
141
243
  }
244
+ var CHANNEL_HASH_DIGITS = 32;
142
245
  function channelName(readable) {
143
- return readable.length <= MAXIMUM_CHANNEL_LENGTH ? readable : `relaybus_${crypto.createHash("sha256").update(readable).digest("hex").slice(0, 32)}`;
246
+ return readable.length <= MAXIMUM_CHANNEL_LENGTH ? readable : `relaybus_${crypto.createHash("sha256").update(readable).digest("hex").slice(0, CHANNEL_HASH_DIGITS)}`;
144
247
  }
145
248
 
146
249
  // src/sql-fragments.ts
@@ -152,9 +255,24 @@ function laneExpression(keyExpression, eventIdExpression) {
152
255
  }
153
256
 
154
257
  // src/schema.ts
258
+ var SCHEMA_MARKER_DIGITS = 32;
155
259
  function createPostgresSchemaStatements(options = {}) {
156
260
  const names = resolvePostgresNames(options);
157
- const statements = [
261
+ const statements = [...eventAndDeliveryStatements(names), ...laneAndCompletionStatements(names)];
262
+ if (names.completionHistory) {
263
+ statements.push(...completionHistoryStatements(names));
264
+ }
265
+ if (names.schema !== "public") {
266
+ statements.unshift(`CREATE SCHEMA IF NOT EXISTS ${names.quotedSchema}`);
267
+ }
268
+ statements.push(`COMMENT ON TABLE ${names.lanes} IS '${postgresSchemaMarker(statements)}'`);
269
+ return statements;
270
+ }
271
+ function eventAndDeliveryStatements(names) {
272
+ return [...eventAndKeySequenceStatements(names), ...deliveryStatements(names)];
273
+ }
274
+ function eventAndKeySequenceStatements(names) {
275
+ return [
158
276
  `CREATE TABLE IF NOT EXISTS ${names.events} (
159
277
  id text PRIMARY KEY,
160
278
  type text NOT NULL,
@@ -173,7 +291,11 @@ function createPostgresSchemaStatements(options = {}) {
173
291
  `CREATE TABLE IF NOT EXISTS ${names.keySequences} (
174
292
  key text PRIMARY KEY,
175
293
  sequence bigint NOT NULL
176
- )`,
294
+ )`
295
+ ];
296
+ }
297
+ function deliveryStatements(names) {
298
+ return [
177
299
  `CREATE TABLE IF NOT EXISTS ${names.deliveries} (
178
300
  id text COLLATE "C" PRIMARY KEY,
179
301
  event_id text NOT NULL REFERENCES ${names.events} (id) ON DELETE CASCADE,
@@ -198,7 +320,11 @@ function createPostgresSchemaStatements(options = {}) {
198
320
  `CREATE INDEX IF NOT EXISTS ${names.indexes.deliveryLaneDue} ON ${names.deliveries} (lane, available_at, id) WHERE status = 'pending'`,
199
321
  `CREATE INDEX IF NOT EXISTS ${names.indexes.deliveryKeyPending} ON ${names.deliveries} (lane, handler, key, sequence) WHERE key IS NOT NULL AND status = 'pending'`,
200
322
  `CREATE INDEX IF NOT EXISTS ${names.indexes.deliveryCompleted} ON ${names.deliveries} (completed_at, id) WHERE status = 'completed'`,
201
- `CREATE INDEX IF NOT EXISTS ${names.indexes.deliveryDead} ON ${names.deliveries} (updated_at, id) WHERE status = 'dead'`,
323
+ `CREATE INDEX IF NOT EXISTS ${names.indexes.deliveryDead} ON ${names.deliveries} (updated_at, id) WHERE status = 'dead'`
324
+ ];
325
+ }
326
+ function laneAndCompletionStatements(names) {
327
+ return [
202
328
  `CREATE TABLE IF NOT EXISTS ${names.lanes} (
203
329
  lane smallint PRIMARY KEY CHECK (lane >= 0 AND lane < ${LANE_COUNT}),
204
330
  epoch bigint NOT NULL DEFAULT 0,
@@ -215,9 +341,10 @@ function createPostgresSchemaStatements(options = {}) {
215
341
  `CREATE INDEX IF NOT EXISTS ${names.indexes.eventCompletionsCompleted} ON ${names.eventCompletions} (completed_at, event_id)`,
216
342
  `INSERT INTO ${names.lanes} (lane) SELECT generate_series(0, ${LANE_COUNT - 1})::smallint ON CONFLICT (lane) DO NOTHING`
217
343
  ];
218
- if (names.completionHistory) {
219
- statements.push(
220
- `CREATE TABLE IF NOT EXISTS ${names.completions} (
344
+ }
345
+ function completionHistoryStatements(names) {
346
+ return [
347
+ `CREATE TABLE IF NOT EXISTS ${names.completions} (
221
348
  id text COLLATE "C" PRIMARY KEY,
222
349
  event_id text NOT NULL REFERENCES ${names.events} (id) ON DELETE CASCADE,
223
350
  handler text NOT NULL,
@@ -226,17 +353,11 @@ function createPostgresSchemaStatements(options = {}) {
226
353
  created_at timestamptz NOT NULL,
227
354
  completed_at timestamptz NOT NULL
228
355
  )`,
229
- `CREATE INDEX IF NOT EXISTS ${names.indexes.completionCompleted} ON ${names.completions} (completed_at, id)`
230
- );
231
- }
232
- if (names.schema !== "public") {
233
- statements.unshift(`CREATE SCHEMA IF NOT EXISTS ${names.quotedSchema}`);
234
- }
235
- statements.push(`COMMENT ON TABLE ${names.lanes} IS '${postgresSchemaMarker(statements)}'`);
236
- return statements;
356
+ `CREATE INDEX IF NOT EXISTS ${names.indexes.completionCompleted} ON ${names.completions} (completed_at, id)`
357
+ ];
237
358
  }
238
359
  function postgresSchemaMarker(statements) {
239
- return `relaybus schema ${crypto.createHash("sha256").update(statements.join(";")).digest("hex").slice(0, 32)}`;
360
+ return `relaybus schema ${crypto.createHash("sha256").update(statements.join(";")).digest("hex").slice(0, SCHEMA_MARKER_DIGITS)}`;
240
361
  }
241
362
  function upgradeFromVersion010(names) {
242
363
  return `DO $relaybus_upgrade$
@@ -265,9 +386,17 @@ function createPostgresSchemaSql(options = {}) {
265
386
  return `${createPostgresSchemaStatements(options).join(";\n\n")};
266
387
  `;
267
388
  }
268
-
269
- // src/notification-listener.ts
270
389
  var RECONNECT_DELAY_MS = 1e3;
390
+ function endOnce(connection, ended) {
391
+ let ending = Promise.resolve();
392
+ return () => {
393
+ if (!ended.value) {
394
+ ended.value = true;
395
+ ending = connection.end().catch(() => void 0);
396
+ }
397
+ return ending;
398
+ };
399
+ }
271
400
  var NotificationListener = class {
272
401
  #connect;
273
402
  #channel;
@@ -277,8 +406,8 @@ var NotificationListener = class {
277
406
  #drop;
278
407
  #reconnectTimer;
279
408
  #closed = false;
280
- constructor(connect, channel, listener, logger) {
281
- this.#connect = connect;
409
+ constructor(connect2, channel, listener, logger) {
410
+ this.#connect = connect2;
282
411
  this.#channel = channel;
283
412
  this.#listener = listener;
284
413
  this.#logger = logger;
@@ -286,15 +415,8 @@ var NotificationListener = class {
286
415
  /** Connects and listens. Rejects when that fails; a connection lost later is replaced. */
287
416
  async start() {
288
417
  const connection = await this.#connect();
289
- let ended = false;
290
- let ending = Promise.resolve();
291
- const end = () => {
292
- if (!ended) {
293
- ended = true;
294
- ending = connection.end().catch(() => void 0);
295
- }
296
- return ending;
297
- };
418
+ const ended = { value: false };
419
+ const end = endOnce(connection, ended);
298
420
  if (this.#closed) {
299
421
  await end();
300
422
  return;
@@ -302,7 +424,7 @@ var NotificationListener = class {
302
424
  this.#drop = end;
303
425
  let listening = false;
304
426
  const onLost = (error) => {
305
- if (!listening || ended) {
427
+ if (!listening || ended.value) {
306
428
  return;
307
429
  }
308
430
  void end();
@@ -318,14 +440,8 @@ var NotificationListener = class {
318
440
  try {
319
441
  await connection.query(`LISTEN "${this.#channel}"`);
320
442
  } catch (error) {
321
- if (this.#drop === end) {
322
- this.#drop = void 0;
323
- }
324
- await end();
325
- if (this.#closed) {
326
- return;
327
- }
328
- throw error;
443
+ await this.#abandonFailedListen(end, error);
444
+ return;
329
445
  }
330
446
  if (this.#closed) {
331
447
  await end();
@@ -333,38 +449,50 @@ var NotificationListener = class {
333
449
  }
334
450
  listening = true;
335
451
  }
452
+ /** Forgets and ends a connection whose LISTEN failed; rethrows the failure unless closing caused it. */
453
+ async #abandonFailedListen(end, error) {
454
+ if (this.#drop === end) {
455
+ this.#drop = void 0;
456
+ }
457
+ await end();
458
+ if (this.#closed) {
459
+ return;
460
+ }
461
+ throw error;
462
+ }
336
463
  #scheduleReconnect() {
337
- this.#reconnectTimer = setTimeout(() => {
338
- this.#reconnectTimer = void 0;
339
- this.start().then(
340
- () => {
341
- if (!this.#closed) {
342
- this.#listener();
343
- }
344
- },
345
- (error) => {
346
- this.#logger.warn(
347
- `[NotificationListener](scheduleReconnect) Listening again failed, retrying | channel=${this.#channel} | error=${error instanceof Error ? error.message : String(error)}`
348
- );
349
- if (!this.#closed) {
350
- this.#scheduleReconnect();
464
+ this.#reconnectTimer = internal.schedule(core.systemClock, {
465
+ run: () => {
466
+ this.#reconnectTimer = void 0;
467
+ this.start().then(
468
+ () => {
469
+ if (!this.#closed) {
470
+ this.#listener();
471
+ }
472
+ },
473
+ (error) => {
474
+ this.#logger.warn(
475
+ `[NotificationListener](scheduleReconnect) Listening again failed, retrying | channel=${this.#channel} | error=${error instanceof Error ? error.message : String(error)}`
476
+ );
477
+ if (!this.#closed) {
478
+ this.#scheduleReconnect();
479
+ }
351
480
  }
352
- }
353
- );
354
- }, RECONNECT_DELAY_MS);
355
- this.#reconnectTimer.unref();
481
+ );
482
+ },
483
+ delayMs: RECONNECT_DELAY_MS,
484
+ unref: true
485
+ });
356
486
  }
357
487
  /** Stops listening for good and ends the connection. */
358
488
  async close() {
359
489
  this.#closed = true;
360
- clearTimeout(this.#reconnectTimer);
490
+ this.#reconnectTimer?.();
361
491
  const drop = this.#drop;
362
492
  this.#drop = void 0;
363
493
  await drop?.();
364
494
  }
365
495
  };
366
-
367
- // src/row-mapping.ts
368
496
  function text(row, column) {
369
497
  return row[column];
370
498
  }
@@ -391,25 +519,24 @@ function mapEventEnvelope(row) {
391
519
  occurredAt: new Date(text(row, "event_occurred_at"))
392
520
  };
393
521
  }
394
- function formatLeaseToken(lane, epoch, claims) {
395
- return `${lane}:${epoch}:${claims}`;
522
+ var LEASE_TOKENS = new internal.LaneLeaseTokenFormat({
523
+ laneCount: LANE_COUNT,
524
+ epochDigits: 18,
525
+ claimsDigits: 9
526
+ });
527
+ function formatLeaseToken(lane, { epoch, claims }) {
528
+ return LEASE_TOKENS.format(lane, { epoch, claims });
396
529
  }
397
- var LEASE_TOKEN_PATTERN = /^(\d{1,3}):(\d{1,18}):(\d{1,9})$/;
398
530
  function parseLeaseToken(token) {
399
- const match = LEASE_TOKEN_PATTERN.exec(token);
400
- if (match === null || Number(match[1]) >= LANE_COUNT) {
401
- return void 0;
402
- }
403
- return { lane: Number(match[1]), epoch: match[2], claims: Number(match[3]) };
531
+ return LEASE_TOKENS.parse(token);
404
532
  }
405
533
  function mapClaimedDelivery(row, leaseExpiresAt) {
406
534
  return {
407
535
  deliveryId: text(row, "delivery_id"),
408
- leaseToken: formatLeaseToken(
409
- readCount(row, "lane"),
410
- text(row, "epoch"),
411
- readCount(row, "claims")
412
- ),
536
+ leaseToken: formatLeaseToken(readCount(row, "lane"), {
537
+ epoch: text(row, "epoch"),
538
+ claims: readCount(row, "claims")
539
+ }),
413
540
  handler: text(row, "handler"),
414
541
  attempt: readCount(row, "attempt"),
415
542
  leaseExpiresAt,
@@ -437,614 +564,1144 @@ function mapOldestDueAt(rows) {
437
564
  return nullableDate(rows[0], "oldest_due_at");
438
565
  }
439
566
 
440
- // src/statements.ts
441
- var DEFAULT_LIST_LIMIT = 100;
442
- function eventColumns(alias) {
443
- return `${alias}.id AS event_id,
444
- ${alias}.type AS event_type,
445
- ${alias}.source AS event_source,
446
- ${alias}.key AS event_key,
447
- ${alias}.payload::text AS event_payload,
448
- ${alias}.headers::text AS event_headers,
449
- ${alias}.correlation_id AS event_correlation_id,
450
- ${alias}.causation_id AS event_causation_id,
451
- ${isoText(`${alias}.occurred_at`)} AS event_occurred_at`;
452
- }
453
- function countChanged(dml) {
454
- return `WITH changed AS (${dml} RETURNING 1) SELECT count(*)::int AS count FROM changed`;
455
- }
456
- var CLAIM_SCAN_WINDOW = 256;
457
- var LANE_SCAN_WINDOW = 64;
458
- var LANE_DEPTH_CAP = 16;
459
- var INITIAL_FREE_LANES = 4;
460
- var FREE_LANE_SURPLUS = 16;
461
- var HANDLER_PREDICATE = `delivery.handler = ANY (ARRAY(SELECT jsonb_array_elements_text($2::jsonb)))`;
462
- var LANE_LIST = `ARRAY(SELECT jsonb_array_elements_text($4::jsonb)::smallint)`;
463
- var PostgresStatements = class {
464
- #names;
465
- constructor(names) {
466
- this.#names = names;
467
- }
468
- /**
469
- * Lock advisory key text for {@link setupLock}. It is derived from the schema
470
- * name only, so stores with different table prefixes in one schema also
471
- * serialize their `CREATE SCHEMA IF NOT EXISTS`, which is not race-free.
472
- */
473
- get setupLockKey() {
474
- return `relaybus:${this.#names.schema}`;
567
+ // src/postgres-append.ts
568
+ var NOTIFICATION_INTERVAL_MS = 10;
569
+ var PostgresAppend = class {
570
+ #state;
571
+ constructor(state) {
572
+ this.#state = state;
475
573
  }
476
- /**
477
- * Whether the lane table carries `marker` as its comment, that is whether the schema statements
478
- * that produce that marker already ran. Takes no table lock. Parameters: `[marker]`.
479
- */
480
- get schemaCurrent() {
481
- return `SELECT EXISTS (
482
- SELECT 1 FROM pg_catalog.pg_class AS c
483
- JOIN pg_catalog.pg_namespace AS n ON n.oid = c.relnamespace
484
- JOIN pg_catalog.pg_description AS d
485
- ON d.objoid = c.oid AND d.classoid = 'pg_catalog.pg_class'::regclass AND d.objsubid = 0
486
- WHERE n.nspname = '${this.#names.schema}' AND c.relname = '${this.#names.lanesTableName}' AND d.description = $1::text
487
- ) AS current`;
574
+ /** Implements {@link PostgresEventStore.append}. */
575
+ async append(transaction, { entries, now }) {
576
+ const plan = planAppend(entries);
577
+ if (plan.events.length === 0) {
578
+ return { appendedEventIds: [], duplicateEventIds: [], appendedDeliveryCount: 0 };
579
+ }
580
+ const createdAt = now.toISOString();
581
+ const wakeAt = this.#wakeAt(plan.deliveries, { now, transaction });
582
+ const handlersByEvent = handlersByEventOf(plan.deliveries);
583
+ const fresh = await this.#insertFreshEvents(transaction, {
584
+ plan,
585
+ handlersByEvent,
586
+ createdAt,
587
+ wakeAt
588
+ });
589
+ const insertedEventIds = new Set(
590
+ fresh.filter((row) => row["inserted"] === true).map((row) => row["id"])
591
+ );
592
+ let appendedDeliveryCount = readCount(fresh[0], "deliveries");
593
+ const remaining = plan.deliveries.filter(
594
+ (delivery) => !insertedEventIds.has(delivery.event_id)
595
+ );
596
+ if (remaining.length > 0) {
597
+ appendedDeliveryCount += await this.#appendToExistingEvents(transaction, {
598
+ plan,
599
+ fresh,
600
+ deliveries: remaining,
601
+ handlersByEvent,
602
+ insertedEventIds,
603
+ createdAt,
604
+ wakeAt
605
+ });
606
+ }
607
+ return { ...splitOutcomes(plan, insertedEventIds), appendedDeliveryCount };
608
+ }
609
+ /** Runs the publishing statement that inserts the events not stored yet, with their deliveries. */
610
+ async #insertFreshEvents(transaction, {
611
+ plan,
612
+ handlersByEvent,
613
+ createdAt,
614
+ wakeAt
615
+ }) {
616
+ const keyCounts = keyCountsOf(plan.events);
617
+ return this.#state.query({
618
+ sql: this.#state.statements.append.insertFreshEvents,
619
+ parameters: [
620
+ JSON.stringify(
621
+ plan.events.map((event, ordinal) => ({
622
+ ordinal,
623
+ ...toEventRow(event, { sequence: null, handlers: handlersByEvent.get(event.id) ?? [] })
624
+ }))
625
+ ),
626
+ createdAt,
627
+ JSON.stringify(plan.deliveries),
628
+ JSON.stringify([...keyCounts].map(([key, count]) => ({ key, count }))),
629
+ wakeAt
630
+ ],
631
+ transaction
632
+ });
488
633
  }
489
- get setupLock() {
490
- return "SELECT pg_advisory_xact_lock(hashtextextended($1::text, 0))";
634
+ /** Locks the given events and returns the ids of those that still exist. */
635
+ async #lockExistingEvents(transaction, existing) {
636
+ const locked = await this.#state.query({
637
+ sql: this.#state.statements.append.lockExistingEvents,
638
+ parameters: [JSON.stringify(existing.map((row) => row.id).sort())],
639
+ transaction
640
+ });
641
+ return new Set(locked.map((row) => row["id"]));
491
642
  }
492
643
  /**
493
- * Publishes in one statement: reserves the sequences of the keys (`[{ key, count }]` in `$4`, locked in
494
- * key order: each row lock is held until the publishing transaction ends, which makes sequence order equal commit order for a key), numbers the keyed events in entry order (`ordinal`), inserts
495
- * the events of `$1` that do not exist yet and the deliveries of `$3` that belong to them, and sends one
496
- * wake-up notification when a delivery it inserted is due at `$5`. Parameters:
497
- * `[eventsJson, createdAt, deliveriesJson, keyCountsJson, now]`. Returns one row per event of `$1`: its
498
- * id, its sequence, whether this statement inserted it, and (on every row) how many deliveries it
499
- * inserted. Deliveries of events that already existed are left to {@link insertDeliveries}.
644
+ * The time the publishing statements notify the dispatchers for (deliveries due by then wake them),
645
+ * or `null` for no notification. An append in a transaction of the caller's always notifies, at its
646
+ * commit. In a transaction of this store, only the first append within {@link NOTIFICATION_INTERVAL_MS}
647
+ * does; the others leave the wake-up to {@link #wakeAfterCommit}, which runs once their transaction
648
+ * committed, so no committed delivery waits for the poll.
500
649
  */
501
- get insertFreshEvents() {
502
- const events = this.#names.events;
503
- return `WITH reserved AS (
504
- INSERT INTO ${this.#names.keySequences} AS reserved (key, sequence)
505
- SELECT requested.key, requested.count
506
- FROM jsonb_to_recordset($4::jsonb) AS requested (key text, count bigint)
507
- ORDER BY requested.key
508
- ON CONFLICT (key) DO UPDATE SET sequence = reserved.sequence + EXCLUDED.sequence
509
- RETURNING reserved.key AS key, reserved.sequence AS sequence
510
- ), incoming AS (
511
- SELECT incoming.*,
512
- CASE WHEN incoming.key IS NULL THEN NULL ELSE
513
- reserved.sequence - count(*) OVER (PARTITION BY incoming.key)
514
- + row_number() OVER (PARTITION BY incoming.key ORDER BY incoming.ordinal)
515
- END AS sequence
516
- FROM jsonb_to_recordset($1::jsonb) AS incoming (
517
- ordinal int, id text, type text, source text, key text, payload jsonb, headers jsonb,
518
- correlation_id text, causation_id text, occurred_at timestamptz, handlers text[])
519
- LEFT JOIN reserved ON reserved.key = incoming.key
520
- ), inserted_events AS (
521
- INSERT INTO ${events}
522
- (id, type, source, key, sequence, payload, headers, correlation_id, causation_id, occurred_at, created_at, handlers)
523
- SELECT incoming.id, incoming.type, incoming.source, incoming.key, incoming.sequence,
524
- COALESCE(incoming.payload, 'null'::jsonb), COALESCE(incoming.headers, '{}'::jsonb),
525
- incoming.correlation_id, incoming.causation_id, incoming.occurred_at, $2::timestamptz,
526
- COALESCE(incoming.handlers, '{}')
527
- FROM incoming
528
- ORDER BY incoming.id
529
- ON CONFLICT (id) DO NOTHING
530
- RETURNING id, key, sequence
531
- ), inserted_deliveries AS (
532
- INSERT INTO ${this.#names.deliveries}
533
- (id, event_id, handler, lane, status, attempts, available_at, key, sequence, created_at, updated_at)
534
- SELECT delivery.id, delivery.event_id, delivery.handler, ${laneExpression("event.key", "event.id")}, 'pending', 0,
535
- delivery.available_at, event.key, event.sequence, $2::timestamptz, $2::timestamptz
536
- FROM jsonb_to_recordset($3::jsonb) AS delivery (id text, event_id text, handler text, available_at timestamptz)
537
- JOIN inserted_events AS event ON event.id = delivery.event_id
538
- ORDER BY delivery.event_id, delivery.handler
539
- ON CONFLICT (event_id, handler) DO NOTHING
540
- RETURNING available_at
541
- ), ${this.#wakeUp("inserted_deliveries", "$5")}
542
- SELECT incoming.id, incoming.sequence::text AS sequence,
543
- incoming.id IN (SELECT inserted_events.id FROM inserted_events) AS inserted,
544
- (SELECT count(*) FROM inserted_deliveries)::int AS deliveries
545
- FROM incoming
546
- WHERE (SELECT count(*) FROM woken) >= 0`;
547
- }
548
- /** One wake-up notification on {@link PostgresNames.notificationChannel}, outside any publishing transaction. */
549
- get notify() {
550
- return `SELECT pg_notify('${this.#names.notificationChannel}', '')`;
650
+ #wakeAt(deliveries, { now, transaction }) {
651
+ if (!this.#state.notifies || !deliveries.some((delivery) => Date.parse(delivery.available_at) <= now.getTime())) {
652
+ return null;
653
+ }
654
+ const own = this.#state.ownTransactions.get(transaction);
655
+ if (own !== void 0 && Date.now() - this.#state.lastNotificationAt < NOTIFICATION_INTERVAL_MS) {
656
+ own.wake = true;
657
+ return null;
658
+ }
659
+ this.#state.lastNotificationAt = Date.now();
660
+ return now.toISOString();
551
661
  }
552
662
  /**
553
- * A CTE `woken` that sends one notification on {@link PostgresNames.notificationChannel} when a row of
554
- * `inserted` is due at `now`. PostgreSQL delivers it when the transaction commits, merges the same
555
- * notification sent twice in one transaction, and drops it on rollback. The statement must reference
556
- * `woken` for it to run.
663
+ * Wakes the dispatchers for a committed transaction whose appends sent no notification: at once when
664
+ * none was sent within {@link NOTIFICATION_INTERVAL_MS}, otherwise once when the interval ends. A
665
+ * wake-up already scheduled covers this transaction too, since it is sent after this commit. Nothing is
666
+ * sent once the store is closed or the `pg` pool behind it is ending: no dispatcher can be woken through
667
+ * it then, and the deliveries are found by polling.
557
668
  */
558
- #wakeUp(inserted, now) {
559
- return `woken AS (
560
- SELECT pg_notify('${this.#names.notificationChannel}', '') FROM (
561
- SELECT 1 FROM ${inserted} WHERE ${inserted}.available_at <= ${now}::timestamptz LIMIT 1
562
- ) AS due
563
- )`;
669
+ wakeAfterCommit() {
670
+ if (this.#state.trailingNotification !== void 0 || this.#state.closed) {
671
+ return;
672
+ }
673
+ this.#state.trailingNotification = internal.schedule(core.systemClock, {
674
+ run: () => {
675
+ this.#state.trailingNotification = void 0;
676
+ if (this.#state.pool?.ending === true) {
677
+ return;
678
+ }
679
+ this.#state.lastNotificationAt = Date.now();
680
+ this.#state.query({ sql: this.#state.statements.append.notify, parameters: [] }).catch((error) => {
681
+ this.#state.logger.error(
682
+ `[PostgresAppend](wakeAfterCommit) Waking the dispatchers failed, they find the deliveries when they poll | error=${error instanceof Error ? error.message : String(error)}`
683
+ );
684
+ });
685
+ },
686
+ delayMs: Math.max(0, this.#state.lastNotificationAt + NOTIFICATION_INTERVAL_MS - Date.now())
687
+ });
564
688
  }
565
689
  /**
566
- * Parameters: `[eventsJson, createdAt]`. Returns the ids that were inserted.
567
- * `handlers` is the list of handlers the event gets deliveries for: it
568
- * outlives the deliveries, which are deleted when they complete.
569
- */
570
- get insertEvents() {
571
- return `INSERT INTO ${this.#names.events}
572
- (id, type, source, key, sequence, payload, headers, correlation_id, causation_id, occurred_at, created_at, handlers)
573
- SELECT incoming.id, incoming.type, incoming.source, incoming.key, incoming.sequence,
574
- COALESCE(incoming.payload, 'null'::jsonb), COALESCE(incoming.headers, '{}'::jsonb),
575
- incoming.correlation_id, incoming.causation_id, incoming.occurred_at, $2::timestamptz,
576
- COALESCE(incoming.handlers, '{}')
577
- FROM jsonb_to_recordset($1::jsonb) AS incoming (
578
- id text, type text, source text, key text, sequence bigint, payload jsonb, headers jsonb,
579
- correlation_id text, causation_id text, occurred_at timestamptz, handlers text[])
580
- ORDER BY incoming.id
581
- ON CONFLICT (id) DO NOTHING
582
- RETURNING id`;
583
- }
584
- /**
585
- * Locks events that were not inserted by the current append, in id order, so
586
- * that appends adding handlers to the same event run one after the other.
587
- * Parameters: `[eventIdsJson]`. `FOR NO KEY UPDATE` conflicts with itself and
588
- * not with the foreign key checks of other inserts.
690
+ * Adds the deliveries of events the publishing statement found already stored. An event that already
691
+ * exists may be getting the same new handler from another transaction at this very moment. Whoever
692
+ * holds the event decides, so the freshness check of the insert sees what the other transaction
693
+ * committed, even if its delivery has completed since. An existing event that a purge removed
694
+ * meanwhile is inserted again (with the sequence reserved for it) instead of being skipped; its id is
695
+ * added to `insertedEventIds`. Returns how many deliveries were inserted.
589
696
  */
590
- get lockExistingEvents() {
591
- return `SELECT id FROM ${this.#names.events}
592
- WHERE id = ANY (ARRAY(SELECT jsonb_array_elements_text($1::jsonb)))
593
- ORDER BY id
594
- FOR NO KEY UPDATE`;
697
+ async #appendToExistingEvents(transaction, input) {
698
+ const { plan, fresh, deliveries, handlersByEvent, insertedEventIds, createdAt, wakeAt } = input;
699
+ const sequences = new Map(
700
+ fresh.map((row) => [row["id"], row["sequence"]])
701
+ );
702
+ const reinserted = [];
703
+ let existing = plan.events.filter((event) => !insertedEventIds.has(event.id)).map(
704
+ (event) => toEventRow(event, {
705
+ sequence: sequences.get(event.id),
706
+ handlers: handlersByEvent.get(event.id) ?? []
707
+ })
708
+ );
709
+ while (existing.length > 0) {
710
+ const locked = await this.#lockExistingEvents(transaction, existing);
711
+ const gone = existing.filter((row) => !locked.has(row.id));
712
+ if (gone.length > 0) {
713
+ const inserted2 = await this.#state.query({
714
+ sql: this.#state.statements.append.insertEvents,
715
+ parameters: [JSON.stringify(gone), createdAt],
716
+ transaction
717
+ });
718
+ for (const row of inserted2) {
719
+ insertedEventIds.add(row["id"]);
720
+ reinserted.push(row["id"]);
721
+ }
722
+ }
723
+ existing = gone.filter((row) => !insertedEventIds.has(row.id));
724
+ }
725
+ const inserted = await this.#state.query({
726
+ sql: this.#state.statements.append.insertDeliveries,
727
+ parameters: [JSON.stringify(deliveries), createdAt, JSON.stringify(reinserted), wakeAt],
728
+ transaction
729
+ });
730
+ return inserted.length;
731
+ }
732
+ };
733
+ function planAppend(entries) {
734
+ const events = [];
735
+ const deliveries = [];
736
+ const outcomes = [];
737
+ const seenEventIds = /* @__PURE__ */ new Set();
738
+ const seenDeliveries = /* @__PURE__ */ new Set();
739
+ for (const { event, deliveries: newDeliveries } of entries) {
740
+ const repeated = seenEventIds.has(event.id);
741
+ seenEventIds.add(event.id);
742
+ outcomes.push({ eventId: event.id, repeated });
743
+ if (!repeated) {
744
+ events.push(event);
745
+ }
746
+ for (const delivery of newDeliveries) {
747
+ const identity = internal.CompositeKey.of(event.id, delivery.handler);
748
+ if (!seenDeliveries.has(identity)) {
749
+ seenDeliveries.add(identity);
750
+ deliveries.push({
751
+ id: delivery.id,
752
+ event_id: event.id,
753
+ handler: delivery.handler,
754
+ available_at: delivery.availableAt.toISOString()
755
+ });
756
+ }
757
+ }
758
+ }
759
+ return { events, deliveries, outcomes };
760
+ }
761
+ function toEventRow(event, {
762
+ sequence,
763
+ handlers
764
+ }) {
765
+ return {
766
+ id: event.id,
767
+ type: event.type,
768
+ source: event.source,
769
+ key: event.key,
770
+ sequence,
771
+ payload: event.payload,
772
+ headers: event.headers,
773
+ correlation_id: event.correlationId,
774
+ causation_id: event.causationId,
775
+ occurred_at: event.occurredAt.toISOString(),
776
+ handlers
777
+ };
778
+ }
779
+ function handlersByEventOf(deliveries) {
780
+ const handlersByEvent = /* @__PURE__ */ new Map();
781
+ for (const delivery of deliveries) {
782
+ handlersByEvent.set(delivery.event_id, [
783
+ ...handlersByEvent.get(delivery.event_id) ?? [],
784
+ delivery.handler
785
+ ]);
786
+ }
787
+ return handlersByEvent;
788
+ }
789
+ function keyCountsOf(events) {
790
+ const keyCounts = /* @__PURE__ */ new Map();
791
+ for (const event of events) {
792
+ if (event.key !== null) {
793
+ keyCounts.set(event.key, (keyCounts.get(event.key) ?? 0) + 1);
794
+ }
795
+ }
796
+ return keyCounts;
797
+ }
798
+ function splitOutcomes(plan, insertedEventIds) {
799
+ const appendedEventIds = [];
800
+ const duplicateEventIds = [];
801
+ for (const { eventId, repeated } of plan.outcomes) {
802
+ (!repeated && insertedEventIds.has(eventId) ? appendedEventIds : duplicateEventIds).push(
803
+ eventId
804
+ );
805
+ }
806
+ return { appendedEventIds, duplicateEventIds };
807
+ }
808
+ var ReconcileFailedError = class extends core.RelayBusError {
809
+ name = "ReconcileFailedError";
810
+ code = "RELAYBUS_POSTGRES_RECONCILE_FAILED";
811
+ };
812
+ function leftWorkBehind(row) {
813
+ return row["takeover_waiting"] === true || row["unexamined_free_lanes"] === true || row["stale"] === true;
814
+ }
815
+ function keepKeyHeads(picked, limit) {
816
+ const seenKeys = /* @__PURE__ */ new Set();
817
+ const kept = [...picked].sort((left, right) => compareSequence(left, right)).filter((row) => {
818
+ if (row["delivery_key"] === null) {
819
+ return true;
820
+ }
821
+ const group = internal.CompositeKey.of(row["handler"], row["delivery_key"]);
822
+ return !seenKeys.has(group) && Boolean(seenKeys.add(group));
823
+ });
824
+ kept.sort(compareDue);
825
+ return kept.slice(0, limit);
826
+ }
827
+ function compareSequence(left, right) {
828
+ const first = sequenceOf(left);
829
+ const second = sequenceOf(right);
830
+ return Number(first > second) - Number(first < second);
831
+ }
832
+ function sequenceOf(row) {
833
+ return BigInt(row["sequence"] ?? 0);
834
+ }
835
+ function compareDue(left, right) {
836
+ return compareText(left["due_at"], right["due_at"]) || compareText(left["delivery_id"], right["delivery_id"]);
837
+ }
838
+ function compareText(left, right) {
839
+ return Number(left > right) - Number(left < right);
840
+ }
841
+
842
+ // src/postgres-claim.ts
843
+ var INITIAL_LANES_PER_ROUND = 4;
844
+ var MAXIMUM_STALE_CLAIM_ATTEMPTS = 3;
845
+ var PostgresClaims = class extends internal.OwnerSerializedClaims {
846
+ #state;
847
+ constructor(state) {
848
+ super();
849
+ this.#state = state;
850
+ }
851
+ async claimInTurn(request) {
852
+ await this.#assertReconciled(request.leaseOwner);
853
+ const heldFirst = this.#state.drainingOwners.has(request.leaseOwner);
854
+ let rows = await this.#claimFirst(request, heldFirst);
855
+ const heldLanes = new Set(JSON.parse(rows[0]["examined_lanes"]));
856
+ const claimed = this.#takeClaimed(rows, request.leaseOwner);
857
+ if (heldFirst && claimed.length < request.limit && rows[0]["stale"] !== true) {
858
+ const freeRows = await this.#claimFreeLanesAfterHeld(request, claimed);
859
+ if (freeRows === void 0) {
860
+ return this.#handOutInDueOrder(claimed, { request, heldLanes });
861
+ }
862
+ rows = freeRows;
863
+ }
864
+ if (claimed.length < request.limit && leftWorkBehind(rows[0])) {
865
+ await this.#claimMoreLanes(request, claimed);
866
+ }
867
+ return this.#handOutInDueOrder(claimed, { request, heldLanes });
868
+ }
869
+ /** Reconciles first when an earlier reconciliation of `owner` failed; refuses to claim while it still fails. */
870
+ async #assertReconciled(owner) {
871
+ if (this.#state.unreconciled.has(owner) && !await this.#reconcile(owner)) {
872
+ throw new ReconcileFailedError(
873
+ `[PostgresEventStore](claim) Giving back unreturned deliveries still fails, so no claim is made | owner=${owner}`
874
+ );
875
+ }
876
+ }
877
+ /** The first claim statement: from the held lanes only when `heldFirst`, else from held and free lanes. */
878
+ async #claimFirst(request, heldFirst) {
879
+ try {
880
+ return await this.#claimInOneStatement(
881
+ request,
882
+ heldFirst ? this.#state.statements.lanes.claimHeldLanes : this.#state.statements.lanes.claimLanes
883
+ );
884
+ } catch (error) {
885
+ await this.#reconcile(request.leaseOwner);
886
+ throw error;
887
+ }
595
888
  }
596
889
  /**
597
- * Parameters: `[deliveriesJson, createdAt, insertedEventIdsJson, now]`. Key and
598
- * sequence are copied from the stored event, so a delivery added later to an
599
- * existing event keeps the event's position in its key and lands in the same
600
- * lane. Returns the ids that were inserted.
601
- *
602
- * The deliveries of events this call just inserted are all new. For an event
603
- * that already existed, a handler gets a delivery only if the event does not
604
- * list it yet: a completed delivery is deleted, but its handler stays listed,
605
- * so a redelivered or republished event never runs a handler twice. The
606
- * handlers added to an existing event are appended to its list.
890
+ * After a held-lanes claim came up short: claims the rest from held and free lanes and adds it to
891
+ * `claimed`. Returns the statement's rows, or undefined when it failed after something was claimed
892
+ * already, which is then handed out as it is.
607
893
  */
608
- get insertDeliveries() {
609
- const events = this.#names.events;
610
- const insertedEvents = `ARRAY(SELECT jsonb_array_elements_text($3::jsonb))`;
611
- return `WITH incoming AS (
612
- SELECT incoming.id, incoming.event_id, incoming.handler, incoming.available_at
613
- FROM jsonb_to_recordset($1::jsonb) AS incoming (id text, event_id text, handler text, available_at timestamptz)
614
- ), fresh AS (
615
- SELECT incoming.*, event.key, event.sequence, ${laneExpression("event.key", "event.id")} AS lane
616
- FROM incoming
617
- JOIN ${events} AS event ON event.id = incoming.event_id
618
- WHERE event.id = ANY (${insertedEvents}) OR NOT incoming.handler = ANY (event.handlers)
619
- ), inserted AS (
620
- INSERT INTO ${this.#names.deliveries}
621
- (id, event_id, handler, lane, status, attempts, available_at, key, sequence, created_at, updated_at)
622
- SELECT fresh.id, fresh.event_id, fresh.handler, fresh.lane, 'pending', 0, fresh.available_at,
623
- fresh.key, fresh.sequence, $2::timestamptz, $2::timestamptz
624
- FROM fresh
625
- ORDER BY fresh.event_id, fresh.handler
626
- ON CONFLICT (event_id, handler) DO NOTHING
627
- RETURNING id, event_id, handler, available_at
628
- ), extended AS (
629
- UPDATE ${events} AS event SET handlers = event.handlers || added.handlers
630
- FROM (
631
- SELECT inserted.event_id, array_agg(inserted.handler) AS handlers FROM inserted
632
- WHERE inserted.event_id <> ALL (${insertedEvents}) GROUP BY inserted.event_id
633
- ) AS added
634
- WHERE event.id = added.event_id
635
- RETURNING event.id
636
- ), ${this.#wakeUp("inserted", "$4")}
637
- SELECT id FROM inserted WHERE (SELECT count(*) FROM woken) >= 0`;
894
+ async #claimFreeLanesAfterHeld(request, claimed) {
895
+ let rows;
896
+ try {
897
+ rows = await this.#claimInOneStatement(
898
+ { ...request, limit: request.limit - claimed.length },
899
+ this.#state.statements.lanes.claimLanes
900
+ );
901
+ } catch (error) {
902
+ await this.#reconcile(request.leaseOwner);
903
+ if (claimed.length === 0) {
904
+ this.#state.drainingOwners.delete(request.leaseOwner);
905
+ throw error;
906
+ }
907
+ this.#state.logger.error(
908
+ `[PostgresClaims](claimFreeLanesAfterHeld) Claiming from free lanes failed, returning the deliveries already claimed | claimed=${claimed.length} | error=${error instanceof Error ? error.message : String(error)}`
909
+ );
910
+ return void 0;
911
+ }
912
+ claimed.push(...this.#takeClaimed(rows, request.leaseOwner));
913
+ return rows;
914
+ }
915
+ /** Takes more lanes round by round and adds what they give to `claimed`; keeps what was claimed when that fails. */
916
+ async #claimMoreLanes(request, claimed) {
917
+ try {
918
+ const more = await this.#claimMore(request, claimed.length);
919
+ this.#handOut(more, request.leaseOwner);
920
+ claimed.push(...more);
921
+ } catch (error) {
922
+ await this.#reconcile(request.leaseOwner);
923
+ if (claimed.length === 0) {
924
+ throw error;
925
+ }
926
+ this.#state.logger.error(
927
+ `[PostgresClaims](claimMoreLanes) Taking more lanes failed, returning the deliveries already claimed | claimed=${claimed.length} | error=${error instanceof Error ? error.message : String(error)}`
928
+ );
929
+ }
930
+ }
931
+ /** The deliveries a claim statement picked, registered as handed out to `leaseOwner`. */
932
+ #takeClaimed(rows, leaseOwner) {
933
+ const claimed = rows.filter((row) => row["delivery_id"] !== null);
934
+ this.#forgetFinished(rows[0], { leaseOwner, claimed });
935
+ this.#handOut(claimed, leaseOwner);
936
+ return claimed;
937
+ }
938
+ #handOutInDueOrder(claimed, {
939
+ request,
940
+ heldLanes
941
+ }) {
942
+ if (claimed.length >= request.limit && claimed.every((row) => heldLanes.has(readCount(row, "lane")))) {
943
+ this.#state.drainingOwners.add(request.leaseOwner);
944
+ } else {
945
+ this.#state.drainingOwners.delete(request.leaseOwner);
946
+ }
947
+ claimed.sort(compareDue);
948
+ return claimed.map((row) => mapClaimedDelivery(row, request.leaseExpiresAt));
638
949
  }
639
950
  /**
640
- * A claim in one statement, the only one in the common case. Parameters:
641
- * `[now, handlersJson, limit, leaseOwner, leaseExpiresAt, generation]`.
642
- *
643
- * 1. **Held lanes.** The lanes this owner holds under a live lease whose `claim_floor` is exactly the
644
- * generation are locked `FOR NO KEY UPDATE` in lane order. A lane above it was raised by a
645
- * reconciliation, so a statement of a claim whose caller gave up (a client timeout does not cancel
646
- * the statement on the server) and that reaches the server late records nothing; a lane below it was
647
- * taken by such a late statement after the reconciliation, and its listed deliveries were never
648
- * handed out. A lane that another transaction of this owner is changing makes the statement wait;
649
- * the statement then reports `stale` and does nothing (see the `stale` CTE).
650
- * 2. **Free lanes**, only when the held lanes yield fewer than `limit`: the free lanes with a due
651
- * delivery (one index probe per free lane) are ranked by their oldest due delivery; the first
652
- * {@link INITIAL_FREE_LANES} that can give something now, and further ones until what they can give
653
- * (estimated from their first {@link LANE_DEPTH_CAP} due deliveries) covers what is still wanted, are locked
654
- * `FOR UPDATE SKIP LOCKED`, so a lane another claimer has is skipped, never waited
655
- * for. A lane that still runs deliveries of an expired lease is left to the transaction of
656
- * {@link takeFreeLanes}, which charges their lost attempts (`takeover_waiting`); so is any lane whose
657
- * locked row differs from what the statement's snapshot saw. A lane that runs nothing and did not
658
- * change cannot have had a delivery written since the snapshot, so its deliveries can be read here.
659
- * 3. **Picks** follow the rules of {@link claim}: the fast window of each lane, and for lanes whose
660
- * window filled up while the batch is not full, the heads of all keys (a loose index scan per lane).
661
- * 4. **Writes**: one update per lane. A held lane records its picks; a taken lane gets a new epoch, the
662
- * owner, the lease and its picks; a taken lane that yielded nothing is not written. Held lanes that
663
- * picked nothing, run nothing and have no full scan window are freed.
664
- *
665
- * Always returns one row per picked delivery, or one row whose `delivery_id` is null. `running_ids`
666
- * (JSON text) lists the deliveries the held lanes still run, `examined_lanes` the held lanes.
951
+ * Runs `statement` (`LaneStatements.claimLanes` or its held-lane form), again when it reports that it waited for a lane lock and
952
+ * read deliveries with an older snapshot than the lane it got. A held lane is only locked by an
953
+ * operation of its own owner (another claim, a reschedule), so a few attempts are enough.
667
954
  */
668
- get claimLanes() {
669
- return this.#claimLanes(true);
955
+ async #claimInOneStatement(request, statement) {
956
+ const parameters = [
957
+ request.now.toISOString(),
958
+ JSON.stringify(request.handlers),
959
+ request.limit,
960
+ request.leaseOwner,
961
+ request.leaseExpiresAt.toISOString(),
962
+ this.#generation(request.leaseOwner).toString()
963
+ ];
964
+ let rows = await this.#state.query({ sql: statement, parameters });
965
+ for (let attempt = 1; attempt < MAXIMUM_STALE_CLAIM_ATTEMPTS && rows[0]["stale"] === true; attempt += 1) {
966
+ rows = await this.#state.query({ sql: statement, parameters });
967
+ }
968
+ return rows;
670
969
  }
671
970
  /**
672
- * {@link claimLanes} limited to the lanes the owner holds: the same parameters, rows and writes, without
673
- * looking at free lanes (`takeover_waiting` and `unexamined_free_lanes` are false). A worker draining a
674
- * backlog finds its batch in the lanes it holds; this statement is about half as costly to plan, and
675
- * the caller runs {@link claimLanes} after it only when it came up short.
676
- */
677
- get claimHeldLanes() {
678
- return this.#claimLanes(false);
971
+ * Second step of {@link PostgresEventStore.claim}, in one transaction: takes free lanes a few at a
972
+ * time, lanes to take over included (their lost attempts are charged first), and records the batch.
973
+ * `alreadyClaimed` deliveries of the first step are in flight, so they are not picked again.
974
+ */
975
+ async #claimMore(request, alreadyClaimed) {
976
+ const now = request.now.toISOString();
977
+ const leaseExpiresAt = request.leaseExpiresAt.toISOString();
978
+ const handlers = JSON.stringify(request.handlers);
979
+ const generation = this.#generation(request.leaseOwner).toString();
980
+ return this.#state.executor.transaction(async (transaction) => {
981
+ const claimed = [];
982
+ const remaining = () => request.limit - alreadyClaimed - claimed.length;
983
+ const takenLanes = /* @__PURE__ */ new Set();
984
+ for (let laneCount = INITIAL_LANES_PER_ROUND; remaining() > 0; ) {
985
+ const taken = await this.#state.query({
986
+ sql: this.#state.statements.lanes.takeFreeLanes,
987
+ parameters: [now, handlers, laneCount, request.leaseOwner, leaseExpiresAt, generation],
988
+ transaction
989
+ });
990
+ if (taken.length === 0) {
991
+ break;
992
+ }
993
+ const lanes = taken.map((row) => readCount(row, "lane"));
994
+ for (const lane of lanes) {
995
+ takenLanes.add(lane);
996
+ }
997
+ claimed.push(
998
+ ...await this.#pickDeliveries(
999
+ [now, handlers, remaining(), JSON.stringify(lanes)],
1000
+ transaction
1001
+ )
1002
+ );
1003
+ laneCount *= 2;
1004
+ }
1005
+ await this.#recordClaim(claimed, { lanes: takenLanes, request, transaction });
1006
+ return claimed;
1007
+ });
679
1008
  }
680
- #claimLanes(includeFreeLanes) {
681
- const deliveries = this.#names.deliveries;
682
- const lanes = this.#names.lanes;
683
- const freeLane = (alias) => `(${alias}.lease_expires_at IS NULL OR ${alias}.lease_expires_at <= $1::timestamptz
684
- OR (${alias}.lease_owner = $4::text AND ${alias}.claim_floor < $6::bigint))`;
685
- const free = includeFreeLanes ? `), free_lanes AS MATERIALIZED (
686
- -- The oldest due delivery of each free lane: one index probe per free lane, bounded by the due time,
687
- -- so deliveries scheduled for later are never read.
688
- SELECT lane.lane, lane.epoch, lane.in_flight, lane.lease_owner, lane.lease_expires_at,
689
- oldest.available_at, oldest.id, NOT ${this.#noRunningDelivery("lane")} AS running
690
- FROM ${lanes} AS lane
691
- CROSS JOIN LATERAL (
692
- SELECT delivery.available_at, delivery.id
693
- FROM ${deliveries} AS delivery
694
- WHERE delivery.lane = lane.lane AND delivery.status = 'pending'
695
- AND delivery.available_at <= $1::timestamptz AND ${HANDLER_PREDICATE}
696
- ORDER BY delivery.available_at, delivery.id
697
- LIMIT 1
698
- ) AS oldest
699
- WHERE (SELECT count FROM wanted) > 0 AND ${freeLane("lane")}
700
- ORDER BY oldest.available_at, oldest.id
701
- LIMIT (SELECT count FROM wanted) + ${FREE_LANE_SURPLUS}
702
- ), free_depth AS MATERIALIZED (
703
- -- What each candidate lane can give now, from its first due deliveries: unkeyed ones, and of a key its
704
- -- lowest one when nothing earlier of the key is pending. Lanes that can give nothing are passed over;
705
- -- of the others, the first ${INITIAL_FREE_LANES} are taken, and further ones until they cover what is
706
- -- still wanted, so the earliest due work of the free lanes comes first.
707
- SELECT runnable.lane,
708
- row_number() OVER (ORDER BY runnable.available_at, runnable.id) AS rank,
709
- sum(runnable.count) OVER (ORDER BY runnable.available_at, runnable.id ROWS UNBOUNDED PRECEDING) - runnable.count AS due_before
710
- FROM (
711
- SELECT lane_candidate.lane, lane_candidate.available_at, lane_candidate.id, depth.count
712
- FROM free_lanes AS lane_candidate
713
- CROSS JOIN LATERAL (
714
- -- A full window that shows nothing runnable may hide runnable work behind it: such a lane counts
715
- -- once, and the claim looks behind its blocked deliveries.
716
- SELECT GREATEST(count(*) FILTER (
717
- WHERE due.key IS NULL OR (due.position = 1 AND NOT EXISTS (
718
- SELECT 1 FROM ${deliveries} AS earlier
719
- WHERE earlier.lane = due.lane AND earlier.handler = due.handler AND earlier.key = due.key
720
- AND earlier.status = 'pending' AND earlier.key IS NOT NULL AND earlier.sequence < due.sequence
721
- ))
722
- ), CASE WHEN count(*) >= ${LANE_DEPTH_CAP} THEN 1 ELSE 0 END) AS count
723
- FROM (
724
- SELECT window_rows.*,
725
- row_number() OVER (PARTITION BY window_rows.handler, window_rows.key ORDER BY window_rows.sequence) AS position
726
- FROM (
727
- SELECT delivery.lane, delivery.handler, delivery.key, delivery.sequence FROM ${deliveries} AS delivery
728
- WHERE delivery.lane = lane_candidate.lane AND delivery.status = 'pending'
729
- AND delivery.available_at <= $1::timestamptz AND ${HANDLER_PREDICATE}
730
- ORDER BY delivery.available_at, delivery.id
731
- LIMIT ${LANE_DEPTH_CAP}
732
- ) AS window_rows
733
- ) AS due
734
- ) AS depth
735
- WHERE NOT lane_candidate.running AND depth.count > 0
736
- ) AS runnable
737
- ), free_locked AS MATERIALIZED (
738
- SELECT lane.lane, lane.epoch, lane.in_flight, lane.lease_owner, lane.lease_expires_at
739
- FROM ${lanes} AS lane
740
- WHERE lane.lane IN (
741
- SELECT chosen.lane FROM free_depth AS chosen
742
- WHERE chosen.rank <= ${INITIAL_FREE_LANES} OR chosen.due_before < (SELECT count FROM wanted)
743
- )
744
- AND ${freeLane("lane")}
745
- ORDER BY lane.lane
746
- FOR UPDATE SKIP LOCKED
747
- ), free_scope AS MATERIALIZED (
748
- -- A lane whose locked row is not the one the snapshot saw changed hands meanwhile: left for a later claim.
749
- SELECT locked.lane, locked.epoch + 1 AS epoch, '{}'::text[] AS in_flight
750
- FROM free_locked AS locked
751
- JOIN free_lanes AS seen ON seen.lane = locked.lane
752
- WHERE locked.epoch = seen.epoch AND locked.in_flight = seen.in_flight
753
- AND locked.lease_owner IS NOT DISTINCT FROM seen.lease_owner
754
- AND locked.lease_expires_at IS NOT DISTINCT FROM seen.lease_expires_at
755
- ), ${this.#candidates("free", "free_scope", null, "(SELECT count FROM wanted)")}` : `), free_lanes AS (
756
- SELECT NULL::smallint AS lane, NULL::bigint AS epoch, NULL::text[] AS in_flight, false AS running WHERE false
757
- ), free_scope AS (
758
- SELECT NULL::smallint AS lane, NULL::bigint AS epoch WHERE false
759
- ), free_candidates AS (
760
- SELECT NULL::text AS id, NULL::smallint AS lane, NULL::timestamptz AS available_at WHERE false
1009
+ /**
1010
+ * The generation of the owner's claim statements. It only grows, also across restarts (it starts at the
1011
+ * clock), and a reconciliation moves it up, which fences off every earlier statement still in flight.
1012
+ */
1013
+ #generation(owner, advance = false) {
1014
+ const current = this.#state.generations.get(owner) ?? BigInt(Date.now());
1015
+ const next = advance ? current + GENERATION_STEP > BigInt(Date.now()) ? current + GENERATION_STEP : BigInt(Date.now()) : current;
1016
+ this.#state.generations.set(owner, next);
1017
+ return next;
1018
+ }
1019
+ /** Remembers the claims handed to the caller of `claim` until they are settled. */
1020
+ #handOut(rows, leaseOwner) {
1021
+ for (const row of rows) {
1022
+ const claim = mapClaimedDelivery(row, /* @__PURE__ */ new Date(0));
1023
+ this.#state.handedOut.register(claim, {
1024
+ owner: leaseOwner,
1025
+ deliveryId: claim.deliveryId,
1026
+ ...parseLeaseToken(claim.leaseToken)
1027
+ });
1028
+ }
1029
+ }
1030
+ /**
1031
+ * Drops the registrations of the owner's held lanes whose delivery no longer runs there
1032
+ * (completed inside the caller's transaction, deleted, or listed no more). Registrations of
1033
+ * lanes this claim did not see stay: their tokens may still be valid.
1034
+ */
1035
+ #forgetFinished(row, {
1036
+ leaseOwner,
1037
+ claimed
1038
+ }) {
1039
+ const heldLanes = new Set(JSON.parse(row["examined_lanes"]));
1040
+ const running = /* @__PURE__ */ new Set([
1041
+ ...JSON.parse(row["running_ids"]),
1042
+ ...claimed.map((picked) => picked["delivery_id"])
1043
+ ]);
1044
+ this.#state.handedOut.forgetWhere(
1045
+ (entry) => entry.owner === leaseOwner && heldLanes.has(entry.lane) && !running.has(entry.deliveryId)
1046
+ );
1047
+ }
1048
+ /**
1049
+ * After a claim failed, possibly after the database recorded its batch: gives back the recorded
1050
+ * deliveries that were never handed out, keeping exactly the claims handed out and not settled.
1051
+ * A failure is remembered and retried before the owner's next claim: other deliveries keep
1052
+ * renewing the lane, so an unreturned one would otherwise never be recovered.
1053
+ */
1054
+ async #reconcile(leaseOwner) {
1055
+ const generation = this.#generation(leaseOwner, true);
1056
+ const handedOut = this.#state.handedOut.entriesOf(leaseOwner).map(({ deliveryId, lane, epoch, claims }) => ({ id: deliveryId, lane, epoch, claims }));
1057
+ try {
1058
+ await this.#state.query({
1059
+ sql: this.#state.statements.claim.reconcileClaims,
1060
+ parameters: [leaseOwner, JSON.stringify(handedOut), generation.toString()]
1061
+ });
1062
+ this.#state.unreconciled.delete(leaseOwner);
1063
+ return true;
1064
+ } catch (error) {
1065
+ this.#state.unreconciled.add(leaseOwner);
1066
+ this.#state.logger.error(
1067
+ `[PostgresClaims](reconcile) Giving back unreturned deliveries failed, it is retried before the next claim | owner=${leaseOwner} | error=${error instanceof Error ? error.message : String(error)}`
1068
+ );
1069
+ return false;
1070
+ }
1071
+ }
1072
+ /** Fast claim, then the slow one when the fast window was full of blocked followers. */
1073
+ async #pickDeliveries(parameters, transaction) {
1074
+ const limit = parameters[2];
1075
+ const fastRows = await this.#state.query({
1076
+ sql: this.#state.statements.claim.claim,
1077
+ parameters,
1078
+ transaction
1079
+ });
1080
+ const picked = fastRows.filter((row) => row["delivery_id"] !== null);
1081
+ if (picked.length < limit && fastRows[0]["window_saturated"] === true) {
1082
+ const slowRows = await this.#state.query({
1083
+ sql: this.#state.statements.claim.claimBehindBlockedKeys,
1084
+ parameters,
1085
+ transaction
1086
+ });
1087
+ const pickedIds = new Set(picked.map((row) => row["delivery_id"]));
1088
+ picked.push(
1089
+ ...slowRows.filter(
1090
+ (row) => row["delivery_id"] !== null && !pickedIds.has(row["delivery_id"])
1091
+ )
1092
+ );
1093
+ return keepKeyHeads(picked, limit);
1094
+ }
1095
+ return picked;
1096
+ }
1097
+ /** Writes the picked ids into their lane rows and frees the lanes that picked nothing. */
1098
+ async #recordClaim(claimed, {
1099
+ lanes,
1100
+ request,
1101
+ transaction
1102
+ }) {
1103
+ const idsByLane = /* @__PURE__ */ new Map();
1104
+ for (const row of claimed) {
1105
+ const lane = readCount(row, "lane");
1106
+ idsByLane.set(lane, [...idsByLane.get(lane) ?? [], row["delivery_id"]]);
1107
+ }
1108
+ const idleLanes = [...lanes].filter((lane) => !idsByLane.has(lane));
1109
+ if (idsByLane.size === 0 && idleLanes.length === 0) {
1110
+ return;
1111
+ }
1112
+ await this.#state.query({
1113
+ sql: this.#state.statements.claim.recordClaim,
1114
+ parameters: [
1115
+ JSON.stringify([...idsByLane].map(([lane, ids]) => ({ lane, ids }))),
1116
+ JSON.stringify(idleLanes),
1117
+ request.leaseOwner,
1118
+ request.leaseExpiresAt.toISOString()
1119
+ ],
1120
+ transaction
1121
+ });
1122
+ }
1123
+ };
1124
+ var GENERATION_STEP = 1n;
1125
+ var PostgresCompletion = class extends internal.LeaseSettlement {
1126
+ #state;
1127
+ constructor(state) {
1128
+ super();
1129
+ this.#state = state;
1130
+ }
1131
+ /** Implements {@link PostgresEventStore.complete}. */
1132
+ async complete(lease, { now, transaction }) {
1133
+ return (await this.#completeLeases([lease], { now, transaction }))[0];
1134
+ }
1135
+ /** Implements {@link PostgresEventStore.completeMany}. */
1136
+ completeMany(leases, now) {
1137
+ return this.#completeLeases(leases, { now, transaction: void 0 });
1138
+ }
1139
+ /** A lease this store issued; any other token cannot be held. */
1140
+ findHeld(lease) {
1141
+ return parseLeaseToken(lease.leaseToken);
1142
+ }
1143
+ writeExtension(held, { lease, leaseExpiresAt }) {
1144
+ return this.#changed(this.#state.statements.completion.extendLease, [
1145
+ ...fenceOf(lease, held),
1146
+ leaseExpiresAt.toISOString()
1147
+ ]);
1148
+ }
1149
+ writeChange(held, { lease, change }) {
1150
+ const { completion } = this.#state.statements;
1151
+ const fence = fenceOf(lease, held);
1152
+ if (change.kind === "reschedule") {
1153
+ return this.#changed(completion.reschedule, [
1154
+ ...fence,
1155
+ change.availableAt.toISOString(),
1156
+ JSON.stringify(change.error),
1157
+ change.now.toISOString()
1158
+ ]);
1159
+ }
1160
+ if (change.kind === "bury") {
1161
+ return this.#changed(completion.bury, [
1162
+ ...fence,
1163
+ JSON.stringify(change.error),
1164
+ change.now.toISOString()
1165
+ ]);
1166
+ }
1167
+ return this.#changed(completion.release, [...fence, change.now.toISOString()]);
1168
+ }
1169
+ /** Forgets a handed-out lease once the store answered about it (any answer: the claim is over or fenced out). */
1170
+ forgetHandedOut(lease) {
1171
+ this.#state.handedOut.forget(lease);
1172
+ }
1173
+ async #changed(sql, parameters) {
1174
+ const rows = await this.#state.query({ sql, parameters });
1175
+ return readCount(rows[0], "count") > 0;
1176
+ }
1177
+ /**
1178
+ * Deletes the delivery of every lease that is still the current claim of its
1179
+ * delivery. Returns one flag per lease, in order. Results are matched to leases
1180
+ * by position, so two leases of one delivery (a stale and a current one) get
1181
+ * their own answers; a lease listed twice completes once, at its first position.
1182
+ */
1183
+ async #completeLeases(leases, { now, transaction }) {
1184
+ const issued = issuedLeases(leases);
1185
+ const completed = /* @__PURE__ */ new Set();
1186
+ if (issued.length > 0) {
1187
+ const rows = await this.#state.query({
1188
+ sql: this.#state.statements.completion.complete,
1189
+ parameters: [JSON.stringify(issued), now.toISOString()],
1190
+ transaction
1191
+ });
1192
+ for (const row of rows) {
1193
+ completed.add(readCount(row, "ord"));
1194
+ }
1195
+ }
1196
+ if (transaction === void 0) {
1197
+ for (const lease of leases) {
1198
+ this.#state.handedOut.forget(lease);
1199
+ }
1200
+ }
1201
+ return leases.map((_, ord) => completed.has(ord));
1202
+ }
1203
+ };
1204
+ function fenceOf(lease, held) {
1205
+ return [lease.deliveryId, held.lane, held.epoch, held.claims];
1206
+ }
1207
+ function issuedLeases(leases) {
1208
+ const issued = [];
1209
+ const seen = /* @__PURE__ */ new Set();
1210
+ leases.forEach((lease, ord) => {
1211
+ const held = parseLeaseToken(lease.leaseToken);
1212
+ const identity = internal.DeliveryLeaseIdentity.of(lease.deliveryId, lease.leaseToken);
1213
+ if (held !== void 0 && !seen.has(identity)) {
1214
+ seen.add(identity);
1215
+ issued.push({ ord, id: lease.deliveryId, ...held });
1216
+ }
1217
+ });
1218
+ return issued;
1219
+ }
1220
+ var OwnedConnections = class {
1221
+ #clients = /* @__PURE__ */ new Set();
1222
+ /**
1223
+ * The `Client` class for a pool the store creates: every connection is tracked from the moment it
1224
+ * is created, so one still connecting or authenticating is destroyed too.
1225
+ */
1226
+ clientClass() {
1227
+ const owned = this;
1228
+ return class OwnedClient extends pg.Client {
1229
+ constructor(config) {
1230
+ super(config);
1231
+ owned.track(this);
1232
+ }
1233
+ };
1234
+ }
1235
+ /** Tracks one connection until it ends. */
1236
+ track(client) {
1237
+ this.#clients.add(client);
1238
+ client.once("end", () => this.#clients.delete(client));
1239
+ }
1240
+ /**
1241
+ * Destroys the socket of every tracked connection. Its queries fail with "Connection terminated"
1242
+ * and the pool drops it. The server ends the session when it next talks to the client.
1243
+ */
1244
+ destroyAll() {
1245
+ for (const client of this.#clients) {
1246
+ client.on("error", () => void 0);
1247
+ client.connection.stream.destroy();
1248
+ }
1249
+ this.#clients.clear();
1250
+ }
1251
+ };
1252
+
1253
+ // src/postgres-maintenance.ts
1254
+ var PostgresMaintenance = class {
1255
+ #state;
1256
+ constructor(state) {
1257
+ this.#state = state;
1258
+ }
1259
+ /** Implements {@link PostgresEventStore.relinquish}. */
1260
+ async relinquish(leaseOwner) {
1261
+ await this.#state.query({
1262
+ sql: this.#state.statements.claim.relinquish,
1263
+ parameters: [leaseOwner]
1264
+ });
1265
+ }
1266
+ /** Implements {@link PostgresEventStore.listDeliveries}. */
1267
+ async listDeliveries(query) {
1268
+ const statement = this.#state.statements.queries.listDeliveries(query);
1269
+ const rows = await this.#state.executor.query(statement.sql, statement.parameters);
1270
+ return rows.map(mapDeliveryRecord);
1271
+ }
1272
+ /** Implements {@link PostgresEventStore.requeue}. */
1273
+ async requeue(deliveryIds, now) {
1274
+ const rows = await this.#state.query({
1275
+ sql: this.#state.statements.queries.requeue,
1276
+ parameters: [JSON.stringify(deliveryIds), now.toISOString()]
1277
+ });
1278
+ return readCount(rows[0], "count");
1279
+ }
1280
+ /** Implements {@link PostgresEventStore.statistics}. */
1281
+ async statistics(now) {
1282
+ const stored = { pending: 0, dead: 0, completed: 0 };
1283
+ for (const row of await this.#state.query({
1284
+ sql: this.#state.statements.queries.countByStatus,
1285
+ parameters: []
1286
+ })) {
1287
+ stored[row["status"]] = readCount(row, "count");
1288
+ }
1289
+ const [running] = await this.#state.query({
1290
+ sql: this.#state.statements.queries.countRunning,
1291
+ parameters: []
1292
+ });
1293
+ const processing = readCount(running, "count");
1294
+ let completed = stored["completed"];
1295
+ if (this.#state.completionHistory) {
1296
+ const [history] = await this.#state.query({
1297
+ sql: this.#state.statements.queries.countCompletionHistory,
1298
+ parameters: []
1299
+ });
1300
+ completed += readCount(history, "count");
1301
+ }
1302
+ const oldestDue = await this.#state.query({
1303
+ sql: this.#state.statements.queries.oldestDue,
1304
+ parameters: [now.toISOString()]
1305
+ });
1306
+ return {
1307
+ // The counts come from separate statements; a delivery that finishes in between must not make this negative.
1308
+ pending: Math.max(0, stored["pending"] - processing),
1309
+ processing,
1310
+ completed,
1311
+ dead: stored["dead"],
1312
+ oldestDueAt: mapOldestDueAt(oldestDue)
1313
+ };
1314
+ }
1315
+ /** Implements {@link PostgresEventStore.purge}. */
1316
+ async purge(request) {
1317
+ const total = { deletedDeliveries: 0, deletedEvents: 0 };
1318
+ const batches = this.#purgeBatchPlan(request);
1319
+ for (const [sql, cutoff, deleteOrphans] of batches) {
1320
+ if (cutoff === void 0) {
1321
+ continue;
1322
+ }
1323
+ let deletedInBatch = this.#state.purgeBatchSize;
1324
+ while (deletedInBatch === this.#state.purgeBatchSize) {
1325
+ const batch = await this.#purgeBatch(sql, { cutoff, deleteOrphans });
1326
+ deletedInBatch = batch.deletedDeliveries;
1327
+ total.deletedDeliveries += batch.deletedDeliveries;
1328
+ total.deletedEvents += batch.deletedEvents;
1329
+ }
1330
+ }
1331
+ total.deletedEvents += await this.#purgeAllOrphanEvents(request);
1332
+ return total;
1333
+ }
1334
+ /** The purge statements to run in order: SQL, cutoff (undefined skips it) and the orphan-event delete. */
1335
+ #purgeBatchPlan(request) {
1336
+ const batches = [
1337
+ [
1338
+ this.#state.statements.purge.purgeCompletedBatch(),
1339
+ request.completedBefore,
1340
+ this.#state.statements.purge.deleteOrphanEventsWithoutCompletion
1341
+ ],
1342
+ [
1343
+ this.#state.statements.purge.purgeDeadBatch(),
1344
+ request.deadBefore,
1345
+ this.#state.statements.purge.deleteOrphanEventsWithoutCompletion
1346
+ ]
1347
+ ];
1348
+ if (this.#state.completionHistory) {
1349
+ batches.push([
1350
+ this.#state.statements.purge.purgeCompletionHistoryBatch(),
1351
+ request.completedBefore,
1352
+ this.#state.statements.purge.deleteOrphanEventsWithoutCompletion
1353
+ ]);
1354
+ }
1355
+ return batches;
1356
+ }
1357
+ /** Removes the events left without deliveries, in two passes; returns how many events were deleted. */
1358
+ async #purgeAllOrphanEvents(request) {
1359
+ let deletedEvents = 0;
1360
+ const orphanCutoff = [request.completedBefore, request.deadBefore].filter((cutoff) => cutoff !== void 0).sort((first, second) => first.getTime() - second.getTime())[0];
1361
+ if (orphanCutoff !== void 0) {
1362
+ deletedEvents += await this.#purgeOrphanEvents(
1363
+ this.#state.statements.purge.lockOrphanEventsBatch,
1364
+ {
1365
+ deleteStatement: this.#state.statements.purge.deleteOrphanEventsWithoutCompletion,
1366
+ cutoff: orphanCutoff,
1367
+ recheckCutoff: false
1368
+ }
1369
+ );
1370
+ }
1371
+ if (request.completedBefore !== void 0) {
1372
+ deletedEvents += await this.#purgeOrphanEvents(
1373
+ this.#state.statements.purge.lockCompletedOrphanEventsBatch,
1374
+ {
1375
+ deleteStatement: this.#state.statements.purge.deleteExpiredCompletedOrphanEvents,
1376
+ cutoff: request.completedBefore,
1377
+ recheckCutoff: true
1378
+ }
1379
+ );
1380
+ }
1381
+ return deletedEvents;
1382
+ }
1383
+ async #purgeOrphanEvents(lockStatement, {
1384
+ deleteStatement,
1385
+ cutoff,
1386
+ recheckCutoff
1387
+ }) {
1388
+ let deletedEvents = 0;
1389
+ let removedInBatch = this.#state.purgeBatchSize;
1390
+ while (removedInBatch === this.#state.purgeBatchSize) {
1391
+ removedInBatch = await this.#state.executor.transaction(async (transaction) => {
1392
+ const locked = await this.#state.query({
1393
+ sql: lockStatement,
1394
+ parameters: [cutoff.toISOString(), this.#state.purgeBatchSize],
1395
+ transaction
1396
+ });
1397
+ const ids = JSON.stringify(locked.map((row) => row["id"]));
1398
+ const removed = await this.#state.query({
1399
+ sql: deleteStatement,
1400
+ parameters: recheckCutoff ? [ids, cutoff.toISOString()] : [ids],
1401
+ transaction
1402
+ });
1403
+ return readCount(removed[0], "count");
1404
+ });
1405
+ deletedEvents += removedInBatch;
1406
+ }
1407
+ return deletedEvents;
1408
+ }
1409
+ async #purgeBatch(sql, { cutoff, deleteOrphans }) {
1410
+ return this.#state.executor.transaction(async (transaction) => {
1411
+ const deleted = await this.#state.query({
1412
+ sql,
1413
+ parameters: [cutoff.toISOString(), this.#state.purgeBatchSize],
1414
+ transaction
1415
+ });
1416
+ if (deleted.length === 0) {
1417
+ return { deletedDeliveries: 0, deletedEvents: 0 };
1418
+ }
1419
+ const eventIds = JSON.stringify([
1420
+ ...new Set(deleted.map((row) => row["event_id"]))
1421
+ ]);
1422
+ const locked = await this.#state.query({
1423
+ sql: this.#state.statements.purge.lockEvents,
1424
+ parameters: [eventIds],
1425
+ transaction
1426
+ });
1427
+ const removed = await this.#state.query({
1428
+ sql: deleteOrphans,
1429
+ parameters: [JSON.stringify(locked.map((row) => row["id"]))],
1430
+ transaction
1431
+ });
1432
+ return { deletedDeliveries: deleted.length, deletedEvents: readCount(removed[0], "count") };
1433
+ });
1434
+ }
1435
+ };
1436
+ var WAKE_UPS = ["notify", "poll"];
1437
+ var DEFAULT_PURGE_BATCH_SIZE = 1e3;
1438
+ function resolveStoreSettings(options) {
1439
+ const sources = [options.pool, options.connectionString, options.executor].filter(
1440
+ (source) => source !== void 0
1441
+ );
1442
+ if (sources.length !== 1) {
1443
+ throw new core.ConfigurationError(
1444
+ "PostgresEventStore needs exactly one of pool, connectionString or executor"
1445
+ );
1446
+ }
1447
+ const purgeBatchSize = internal.PositiveInteger.of(
1448
+ options.purgeBatchSize ?? DEFAULT_PURGE_BATCH_SIZE,
1449
+ "purgeBatchSize"
1450
+ );
1451
+ const wakeUp = options.wakeUp ?? "notify";
1452
+ if (!WAKE_UPS.includes(wakeUp)) {
1453
+ throw new core.ConfigurationError(`wakeUp must be 'notify' or 'poll', received ${String(wakeUp)}`);
1454
+ }
1455
+ const preparedStatements = options.preparedStatements ?? true;
1456
+ if (typeof preparedStatements !== "boolean") {
1457
+ throw new core.ConfigurationError(
1458
+ `preparedStatements must be a boolean, received ${String(preparedStatements)}`
1459
+ );
1460
+ }
1461
+ return {
1462
+ purgeBatchSize,
1463
+ wakeUp,
1464
+ queryOptions: preparedStatements ? { prepare: true } : void 0
1465
+ };
1466
+ }
1467
+
1468
+ // src/statements-append.ts
1469
+ var AppendStatements = class {
1470
+ #names;
1471
+ constructor(names) {
1472
+ this.#names = names;
1473
+ }
1474
+ /**
1475
+ * Publishes in one statement: reserves the sequences of the keys (`[{ key, count }]` in `$4`, locked in
1476
+ * key order: each row lock is held until the publishing transaction ends, which makes sequence order equal commit order for a key), numbers the keyed events in entry order (`ordinal`), inserts
1477
+ * the events of `$1` that do not exist yet and the deliveries of `$3` that belong to them, and sends one
1478
+ * wake-up notification when a delivery it inserted is due at `$5`. Parameters:
1479
+ * `[eventsJson, createdAt, deliveriesJson, keyCountsJson, now]`. Returns one row per event of `$1`: its
1480
+ * id, its sequence, whether this statement inserted it, and (on every row) how many deliveries it
1481
+ * inserted. Deliveries of events that already existed are left to {@link insertDeliveries}.
1482
+ */
1483
+ get insertFreshEvents() {
1484
+ return `WITH ${this.#reservedSequences()}, ${this.#incomingEvents()}, ${this.#insertedEvents()}, ${this.#insertedDeliveries()}, ${this.#wakeUp("inserted_deliveries", "$5")}
1485
+ SELECT incoming.id, incoming.sequence::text AS sequence,
1486
+ incoming.id IN (SELECT inserted_events.id FROM inserted_events) AS inserted,
1487
+ (SELECT count(*) FROM inserted_deliveries)::int AS deliveries
1488
+ FROM incoming
1489
+ WHERE (SELECT count(*) FROM woken) >= 0`;
1490
+ }
1491
+ /** The CTE `reserved` of {@link insertFreshEvents}: the sequences the keys of `$4` reserve. */
1492
+ #reservedSequences() {
1493
+ return `reserved AS (
1494
+ INSERT INTO ${this.#names.keySequences} AS reserved (key, sequence)
1495
+ SELECT requested.key, requested.count
1496
+ FROM jsonb_to_recordset($4::jsonb) AS requested (key text, count bigint)
1497
+ ORDER BY requested.key
1498
+ ON CONFLICT (key) DO UPDATE SET sequence = reserved.sequence + EXCLUDED.sequence
1499
+ RETURNING reserved.key AS key, reserved.sequence AS sequence
761
1500
  )`;
762
- return `WITH held AS MATERIALIZED (
763
- SELECT lane.lane, lane.epoch, lane.in_flight
764
- FROM ${lanes} AS lane
765
- WHERE lane.lease_owner = $4::text AND lane.lease_expires_at > $1::timestamptz
766
- AND lane.claim_floor = $6::bigint
767
- ORDER BY lane.lane
768
- FOR NO KEY UPDATE
769
- ), stale AS (
770
- -- A claim that waited for a lane lock sees the new lane row but reads deliveries with the snapshot it took
771
- -- before the wait: whatever changed the lane meanwhile (a reschedule, a release) may also have changed
772
- -- deliveries it cannot see. The statement then does nothing and reports it, and the caller runs it again.
773
- SELECT EXISTS (
774
- SELECT 1 FROM held
775
- JOIN ${lanes} AS before ON before.lane = held.lane
776
- WHERE before.epoch <> held.epoch OR before.in_flight IS DISTINCT FROM held.in_flight
777
- ) AS value
778
- ), held_scope AS MATERIALIZED (
779
- SELECT held.lane, held.epoch, held.in_flight FROM held WHERE NOT (SELECT value FROM stale)
780
- ), held_running AS MATERIALIZED (
781
- SELECT delivery.handler, delivery.key
782
- FROM held_scope
783
- CROSS JOIN LATERAL unnest(held_scope.in_flight) AS flying (id)
784
- JOIN ${deliveries} AS delivery ON delivery.id = flying.id
785
- WHERE delivery.status = 'pending' AND delivery.key IS NOT NULL
786
- ), ${this.#candidates("held", "held_scope", "held_running", "$3::int")}, wanted AS MATERIALIZED (
787
- SELECT CASE WHEN (SELECT value FROM stale) THEN 0
788
- ELSE $3::int - (SELECT count(*) FROM held_candidates)::int END AS count
789
- ${free}, claim_scope AS MATERIALIZED (
790
- SELECT held_scope.lane, held_scope.epoch FROM held_scope
791
- UNION ALL
792
- SELECT free_scope.lane, free_scope.epoch FROM free_scope
793
- ), picked_rows AS MATERIALIZED (
794
- SELECT candidates.available_at AS due_at, delivery.id AS delivery_id, delivery.lane::int AS lane,
795
- scope.epoch::text AS epoch, delivery.claims AS claims, delivery.handler, delivery.key AS delivery_key, delivery.sequence::text AS sequence, (delivery.attempts + 1)::int AS attempt,
796
- ${eventColumns("event")}
797
- FROM (
798
- SELECT held_candidates.id, held_candidates.lane, held_candidates.available_at FROM held_candidates
799
- UNION ALL
800
- SELECT free_candidates.id, free_candidates.lane, free_candidates.available_at FROM free_candidates
801
- ) AS candidates
802
- JOIN claim_scope AS scope ON scope.lane = candidates.lane
803
- JOIN ${deliveries} AS delivery ON delivery.id = candidates.id
804
- JOIN ${this.#names.events} AS event ON event.id = delivery.event_id
805
- ), by_lane AS (
806
- SELECT picked_rows.lane, array_agg(picked_rows.delivery_id) AS ids FROM picked_rows GROUP BY picked_rows.lane
807
- ), still_running AS (
808
- -- The retained ids come from the locked \`held\` rows, not from the lane table: a claim that waited for the lock
809
- -- must see the in-flight list the claim before it recorded, which the statement snapshot may not.
810
- SELECT held.lane, array_agg(delivery.id) AS ids
811
- FROM by_lane
812
- JOIN held ON held.lane = by_lane.lane
813
- CROSS JOIN LATERAL unnest(held.in_flight) AS flying (id)
814
- JOIN ${deliveries} AS delivery ON delivery.id = flying.id AND delivery.status = 'pending'
815
- GROUP BY held.lane
816
- ), extended AS (
817
- UPDATE ${lanes} AS lane
818
- SET in_flight = COALESCE(still_running.ids, '{}') || by_lane.ids,
819
- lease_expires_at = GREATEST(lane.lease_expires_at, $5::timestamptz)
820
- FROM by_lane
821
- JOIN held_scope ON held_scope.lane = by_lane.lane
822
- LEFT JOIN still_running ON still_running.lane = by_lane.lane
823
- WHERE lane.lane = by_lane.lane
824
- RETURNING lane.lane
825
- ), taken AS (
826
- UPDATE ${lanes} AS lane
827
- SET epoch = free_scope.epoch, lease_owner = $4::text, lease_expires_at = $5::timestamptz,
828
- in_flight = by_lane.ids, claim_floor = $6::bigint
829
- FROM free_scope
830
- JOIN by_lane ON by_lane.lane = free_scope.lane
831
- WHERE lane.lane = free_scope.lane
832
- RETURNING lane.lane
833
- ), saturated_lanes AS (
834
- SELECT held_window.lane FROM held_window GROUP BY held_window.lane HAVING count(*) >= ${LANE_SCAN_WINDOW}
835
- ), freed AS (
836
- UPDATE ${lanes} AS lane
837
- SET lease_owner = NULL, lease_expires_at = NULL, in_flight = '{}'
838
- FROM held
839
- WHERE lane.lane = held.lane
840
- AND lane.lane NOT IN (SELECT lane FROM by_lane)
841
- AND lane.lane NOT IN (SELECT lane FROM saturated_lanes)
842
- AND (SELECT count(*) FROM held_window) < ${CLAIM_SCAN_WINDOW}
843
- AND NOT (SELECT value FROM stale)
844
- AND ${this.#noRunningDelivery("lane")}
845
- RETURNING lane.lane
846
- )
847
- SELECT (SELECT value FROM stale) AS stale,
848
- EXISTS (SELECT 1 FROM free_lanes WHERE free_lanes.running) AS takeover_waiting,
849
- (SELECT count(*) FROM free_lanes) = (SELECT count FROM wanted) + ${FREE_LANE_SURPLUS} AS unexamined_free_lanes,
850
- (
851
- SELECT COALESCE(jsonb_agg(delivery.id), '[]'::jsonb)
852
- FROM held
853
- CROSS JOIN LATERAL unnest(held.in_flight) AS flying (id)
854
- JOIN ${deliveries} AS delivery ON delivery.id = flying.id AND delivery.status = 'pending'
855
- )::text AS running_ids,
856
- (SELECT COALESCE(jsonb_agg(held.lane), '[]'::jsonb) FROM held)::text AS examined_lanes,
857
- picked.*
858
- FROM (SELECT 1) AS anchor
859
- LEFT JOIN picked_rows AS picked ON true
860
- ORDER BY picked.due_at, picked.delivery_id`;
1501
+ }
1502
+ /** The CTE `incoming` of {@link insertFreshEvents}: the events of `$1` with their key sequences. */
1503
+ #incomingEvents() {
1504
+ return `incoming AS (
1505
+ SELECT incoming.*,
1506
+ CASE WHEN incoming.key IS NULL THEN NULL ELSE
1507
+ reserved.sequence - count(*) OVER (PARTITION BY incoming.key)
1508
+ + row_number() OVER (PARTITION BY incoming.key ORDER BY incoming.ordinal)
1509
+ END AS sequence
1510
+ FROM jsonb_to_recordset($1::jsonb) AS incoming (
1511
+ ordinal int, id text, type text, source text, key text, payload jsonb, headers jsonb,
1512
+ correlation_id text, causation_id text, occurred_at timestamptz, handlers text[])
1513
+ LEFT JOIN reserved ON reserved.key = incoming.key
1514
+ )`;
1515
+ }
1516
+ /** The CTE `inserted_events` of {@link insertFreshEvents}: the events of `$1` that do not exist yet. */
1517
+ #insertedEvents() {
1518
+ return `inserted_events AS (
1519
+ INSERT INTO ${this.#names.events}
1520
+ (id, type, source, key, sequence, payload, headers, correlation_id, causation_id, occurred_at, created_at, handlers)
1521
+ SELECT incoming.id, incoming.type, incoming.source, incoming.key, incoming.sequence,
1522
+ COALESCE(incoming.payload, 'null'::jsonb), COALESCE(incoming.headers, '{}'::jsonb),
1523
+ incoming.correlation_id, incoming.causation_id, incoming.occurred_at, $2::timestamptz,
1524
+ COALESCE(incoming.handlers, '{}')
1525
+ FROM incoming
1526
+ ORDER BY incoming.id
1527
+ ON CONFLICT (id) DO NOTHING
1528
+ RETURNING id, key, sequence
1529
+ )`;
1530
+ }
1531
+ /** The CTE `inserted_deliveries` of {@link insertFreshEvents}: the deliveries of `$3` of the inserted events. */
1532
+ #insertedDeliveries() {
1533
+ return `inserted_deliveries AS (
1534
+ INSERT INTO ${this.#names.deliveries}
1535
+ (id, event_id, handler, lane, status, attempts, available_at, key, sequence, created_at, updated_at)
1536
+ SELECT delivery.id, delivery.event_id, delivery.handler, ${laneExpression("event.key", "event.id")}, 'pending', 0,
1537
+ delivery.available_at, event.key, event.sequence, $2::timestamptz, $2::timestamptz
1538
+ FROM jsonb_to_recordset($3::jsonb) AS delivery (id text, event_id text, handler text, available_at timestamptz)
1539
+ JOIN inserted_events AS event ON event.id = delivery.event_id
1540
+ ORDER BY delivery.event_id, delivery.handler
1541
+ ON CONFLICT (event_id, handler) DO NOTHING
1542
+ RETURNING available_at
1543
+ )`;
1544
+ }
1545
+ /** One wake-up notification on {@link PostgresNames.notificationChannel}, outside any publishing transaction. */
1546
+ get notify() {
1547
+ return `SELECT pg_notify('${this.#names.notificationChannel}', '')`;
861
1548
  }
862
1549
  /**
863
- * The head (lowest pending sequence) of every key of every wanted handler in the lane `scope.lane`,
864
- * as a subquery returning `(id, lane, available_at, handler, key)`. A loose index scan on the
865
- * pending-key index hops from key to key of a handler, and after its last key on to the first key of
866
- * the next wanted handler, so the cost follows the number of keys, not the number of followers. The
867
- * handlers are walked inside the recursion instead of joined to it: a join multiplied the planner's
868
- * cost estimate by the handler count, which pushed the claim statements over `jit_above_cost`, and
869
- * compiling them took longer than running them.
1550
+ * A CTE `woken` that sends one notification on {@link PostgresNames.notificationChannel} when a row of
1551
+ * `inserted` is due at `now`. PostgreSQL delivers it when the transaction commits, merges the same
1552
+ * notification sent twice in one transaction, and drops it on rollback. The statement must reference
1553
+ * `woken` for it to run.
870
1554
  */
871
- #keyHeads(scope) {
872
- const deliveries = this.#names.deliveries;
873
- const pending = `candidate.lane = ${scope}.lane AND candidate.status = 'pending'`;
874
- const nextHandler = (after) => `(SELECT min(wanted.handler) FROM jsonb_array_elements_text($2::jsonb) AS wanted (handler)${after === "" ? "" : ` WHERE wanted.handler > ${after}`})`;
875
- return `
876
- WITH RECURSIVE hops (handler, key) AS (
877
- SELECT ${nextHandler("")}, NULL::text
878
- UNION ALL
879
- SELECT CASE WHEN next.key IS NULL THEN ${nextHandler("hops.handler")} ELSE hops.handler END, next.key
880
- FROM hops
881
- CROSS JOIN LATERAL (
882
- SELECT CASE WHEN hops.key IS NULL
883
- THEN (SELECT candidate.key FROM ${deliveries} AS candidate
884
- WHERE ${pending} AND candidate.handler = hops.handler AND candidate.key IS NOT NULL
885
- ORDER BY candidate.key LIMIT 1)
886
- ELSE (SELECT candidate.key FROM ${deliveries} AS candidate
887
- WHERE ${pending} AND candidate.handler = hops.handler AND candidate.key > hops.key
888
- ORDER BY candidate.key LIMIT 1)
889
- END AS key
890
- OFFSET 0
891
- ) AS next
892
- WHERE hops.handler IS NOT NULL
893
- )
894
- SELECT lowest.id, lowest.lane, lowest.available_at, lowest.handler, lowest.key
895
- FROM hops
896
- CROSS JOIN LATERAL (
897
- SELECT candidate.id, candidate.lane, candidate.available_at, candidate.handler, candidate.key
898
- FROM ${deliveries} AS candidate
899
- WHERE ${pending} AND candidate.handler = hops.handler AND candidate.key = hops.key
900
- ORDER BY candidate.sequence LIMIT 1
901
- ) AS lowest
902
- WHERE hops.key IS NOT NULL
903
- `;
1555
+ #wakeUp(inserted, now) {
1556
+ return `woken AS (
1557
+ SELECT pg_notify('${this.#names.notificationChannel}', '') FROM (
1558
+ SELECT 1 FROM ${inserted} WHERE ${inserted}.available_at <= ${now}::timestamptz LIMIT 1
1559
+ ) AS due
1560
+ )`;
904
1561
  }
905
1562
  /**
906
- * CTEs that pick up to `limit` claimable deliveries from the lanes of the CTE `scope` (columns `lane`,
907
- * `epoch`, `in_flight`), named after `prefix`; the result is `<prefix>_candidates (id, lane, available_at)`.
908
- * The rules are those of {@link claim}. The fast window reads the first {@link LANE_SCAN_WINDOW} due
909
- * deliveries of each lane (at most {@link CLAIM_SCAN_WINDOW} together); of a key, only the lowest sequence
910
- * in the window can be its head, so only that one is checked for an earlier pending delivery. Only when
911
- * that yields fewer than `limit` are the lanes whose window filled up looked at again with a loose index
912
- * scan over the heads of all their keys. `running` names the CTE of the keyed deliveries running in the
913
- * lanes, or is null when nothing runs there.
1563
+ * Parameters: `[eventsJson, createdAt]`. Returns the ids that were inserted.
1564
+ * `handlers` is the list of handlers the event gets deliveries for: it
1565
+ * outlives the deliveries, which are deleted when they complete.
914
1566
  */
915
- #candidates(prefix, scope, running, limit) {
916
- const deliveries = this.#names.deliveries;
917
- const notRunning = (alias) => running === null ? "true" : `NOT EXISTS (SELECT 1 FROM ${running} AS running WHERE running.handler = ${alias}.handler AND running.key = ${alias}.key)`;
918
- return `${prefix}_window AS MATERIALIZED (
919
- SELECT recent.id, recent.lane, recent.available_at, recent.handler, recent.key, recent.sequence
920
- FROM ${scope}
921
- CROSS JOIN LATERAL (
922
- SELECT delivery.id, delivery.lane, delivery.available_at, delivery.handler, delivery.key, delivery.sequence
923
- FROM ${deliveries} AS delivery
924
- WHERE delivery.lane = ${scope}.lane AND delivery.status = 'pending'
925
- AND delivery.available_at <= $1::timestamptz AND ${HANDLER_PREDICATE}
926
- AND delivery.id <> ALL (${scope}.in_flight)
927
- ORDER BY delivery.available_at, delivery.id
928
- LIMIT ${LANE_SCAN_WINDOW}
929
- ) AS recent
930
- ORDER BY recent.available_at, recent.id
931
- LIMIT ${CLAIM_SCAN_WINDOW}
932
- ), ${prefix}_fast AS MATERIALIZED (
933
- SELECT picked.id, picked.lane, picked.available_at FROM (
934
- SELECT unkeyed.id, unkeyed.lane, unkeyed.available_at FROM ${prefix}_window AS unkeyed WHERE unkeyed.key IS NULL
935
- UNION ALL
936
- SELECT head.id, head.lane, head.available_at
937
- FROM (
938
- SELECT DISTINCT ON (keyed.handler, keyed.key) keyed.id, keyed.lane, keyed.available_at, keyed.handler, keyed.key, keyed.sequence
939
- FROM ${prefix}_window AS keyed
940
- WHERE keyed.key IS NOT NULL
941
- ORDER BY keyed.handler, keyed.key, keyed.sequence
942
- ) AS head
943
- WHERE NOT EXISTS (
944
- SELECT 1 FROM ${deliveries} AS earlier
945
- WHERE earlier.lane = head.lane AND earlier.handler = head.handler AND earlier.key = head.key
946
- AND earlier.status = 'pending' AND earlier.key IS NOT NULL AND earlier.sequence < head.sequence
947
- ) AND ${notRunning("head")}
948
- ) AS picked
949
- ORDER BY picked.available_at, picked.id
950
- LIMIT ${limit}
951
- ), ${prefix}_saturated AS MATERIALIZED (
952
- SELECT ${scope}.lane, ${scope}.in_flight FROM ${scope}
953
- WHERE (SELECT count(*) FROM ${prefix}_fast) < ${limit}
954
- AND ((SELECT count(*) FROM ${prefix}_window) >= ${CLAIM_SCAN_WINDOW}
955
- OR ${scope}.lane IN (
956
- SELECT full_lane.lane FROM ${prefix}_window AS full_lane
957
- GROUP BY full_lane.lane HAVING count(*) >= ${LANE_SCAN_WINDOW}
958
- ))
959
- ), ${prefix}_heads AS MATERIALIZED (
960
- SELECT head.id, head.lane, head.available_at, head.handler, head.key
961
- FROM ${prefix}_saturated AS scope
962
- CROSS JOIN LATERAL (${this.#keyHeads("scope")}) AS head
963
- ), ${prefix}_keyed AS MATERIALIZED (
964
- SELECT heads.id, heads.lane, heads.available_at
965
- FROM ${prefix}_heads AS heads
966
- JOIN ${prefix}_saturated AS scope ON scope.lane = heads.lane
967
- WHERE heads.available_at <= $1::timestamptz AND heads.id <> ALL (scope.in_flight) AND ${notRunning("heads")}
968
- ORDER BY heads.available_at, heads.id
969
- LIMIT ${limit}
970
- ), ${prefix}_unkeyed AS MATERIALIZED (
971
- SELECT recent.id, recent.lane, recent.available_at
972
- FROM ${prefix}_saturated AS scope
973
- CROSS JOIN LATERAL (
974
- SELECT delivery.id, delivery.lane, delivery.available_at
975
- FROM ${deliveries} AS delivery
976
- WHERE delivery.lane = scope.lane AND delivery.key IS NULL AND delivery.status = 'pending'
977
- AND delivery.available_at <= $1::timestamptz AND ${HANDLER_PREDICATE}
978
- AND delivery.id <> ALL (scope.in_flight)
979
- ORDER BY delivery.available_at, delivery.id
980
- LIMIT ${limit}
981
- ) AS recent
982
- ORDER BY recent.available_at, recent.id
983
- LIMIT ${limit}
984
- ), ${prefix}_candidates AS MATERIALIZED (
985
- SELECT chosen.id, chosen.lane, chosen.available_at FROM (
986
- SELECT fast.id, fast.lane, fast.available_at FROM ${prefix}_fast AS fast
987
- UNION
988
- SELECT keyed.id, keyed.lane, keyed.available_at FROM ${prefix}_keyed AS keyed
989
- UNION
990
- SELECT unkeyed.id, unkeyed.lane, unkeyed.available_at FROM ${prefix}_unkeyed AS unkeyed
991
- ) AS chosen
992
- ORDER BY chosen.available_at, chosen.id
993
- LIMIT ${limit}
994
- )`;
1567
+ get insertEvents() {
1568
+ return `INSERT INTO ${this.#names.events}
1569
+ (id, type, source, key, sequence, payload, headers, correlation_id, causation_id, occurred_at, created_at, handlers)
1570
+ SELECT incoming.id, incoming.type, incoming.source, incoming.key, incoming.sequence,
1571
+ COALESCE(incoming.payload, 'null'::jsonb), COALESCE(incoming.headers, '{}'::jsonb),
1572
+ incoming.correlation_id, incoming.causation_id, incoming.occurred_at, $2::timestamptz,
1573
+ COALESCE(incoming.handlers, '{}')
1574
+ FROM jsonb_to_recordset($1::jsonb) AS incoming (
1575
+ id text, type text, source text, key text, sequence bigint, payload jsonb, headers jsonb,
1576
+ correlation_id text, causation_id text, occurred_at timestamptz, handlers text[])
1577
+ ORDER BY incoming.id
1578
+ ON CONFLICT (id) DO NOTHING
1579
+ RETURNING id`;
995
1580
  }
996
1581
  /**
997
- * Takes up to `$3` free lanes (no lease, or an expired one) that have a due
998
- * delivery of the requested handlers, the ones with the oldest due delivery
999
- * first. Parameters: `[now, handlersJson, laneCount, leaseOwner, leaseExpiresAt, generation]`.
1000
- * The lane gets the owner's `generation` as its `claim_floor`. A lane the owner itself holds under a live
1001
- * lease with a lower floor was taken by a claim statement that reached the server after its caller gave up
1002
- * and the owner reconciled (or by an earlier incarnation of the same owner): it is taken over like the lane of a
1003
- * crashed worker, so its listed deliveries are charged an attempt.
1582
+ * Locks events that were not inserted by the current append, in id order, so
1583
+ * that appends adding handlers to the same event run one after the other.
1584
+ * Parameters: `[eventIdsJson]`. `FOR NO KEY UPDATE` conflicts with itself and
1585
+ * not with the foreign key checks of other inserts.
1586
+ */
1587
+ get lockExistingEvents() {
1588
+ return `SELECT id FROM ${this.#names.events}
1589
+ WHERE id = ANY (ARRAY(SELECT jsonb_array_elements_text($1::jsonb)))
1590
+ ORDER BY id
1591
+ FOR NO KEY UPDATE`;
1592
+ }
1593
+ /**
1594
+ * Parameters: `[deliveriesJson, createdAt, insertedEventIdsJson, now]`. Key and
1595
+ * sequence are copied from the stored event, so a delivery added later to an
1596
+ * existing event keeps the event's position in its key and lands in the same
1597
+ * lane. Returns the ids that were inserted.
1004
1598
  *
1005
- * A taken lane gets a new epoch, which fences everything the previous owner
1006
- * still holds. Deliveries the previous owner had in flight are the ones whose
1007
- * attempt was never recorded (a claim writes no delivery row), so each of
1008
- * them is charged one attempt here: a worker that crashes with a poison
1009
- * message still runs it into the retry limit. Lanes another claimer has locked
1010
- * are skipped, never waited for.
1599
+ * The deliveries of events this call just inserted are all new. For an event
1600
+ * that already existed, a handler gets a delivery only if the event does not
1601
+ * list it yet: a completed delivery is deleted, but its handler stays listed,
1602
+ * so a redelivered or republished event never runs a handler twice. The
1603
+ * handlers added to an existing event are appended to its list.
1011
1604
  */
1012
- get takeFreeLanes() {
1013
- const deliveries = this.#names.deliveries;
1014
- const lanes = this.#names.lanes;
1015
- return `WITH picked AS (
1016
- -- in_flight is read here, from the locked version of the lane: a settlement that committed after the
1017
- -- statement took its snapshot has taken its delivery out of the list already and must not be charged again.
1018
- SELECT free.lane, free.in_flight
1019
- FROM ${lanes} AS free
1020
- CROSS JOIN LATERAL (
1021
- SELECT delivery.available_at, delivery.id
1022
- FROM ${deliveries} AS delivery
1023
- WHERE delivery.lane = free.lane AND delivery.status = 'pending'
1024
- AND delivery.available_at <= $1::timestamptz AND ${HANDLER_PREDICATE}
1025
- ORDER BY delivery.available_at, delivery.id
1026
- LIMIT 1
1027
- ) AS oldest
1028
- WHERE free.lease_expires_at IS NULL OR free.lease_expires_at <= $1::timestamptz
1029
- OR (free.lease_owner = $4::text AND free.claim_floor < $6::bigint)
1030
- ORDER BY oldest.available_at, oldest.id
1031
- LIMIT $3::int
1032
- FOR UPDATE OF free SKIP LOCKED
1033
- ), charged AS (
1034
- UPDATE ${deliveries} AS delivery SET attempts = delivery.attempts + 1, claims = delivery.claims + 1
1605
+ get insertDeliveries() {
1606
+ const events = this.#names.events;
1607
+ const insertedEvents = `ARRAY(SELECT jsonb_array_elements_text($3::jsonb))`;
1608
+ return `WITH incoming AS (
1609
+ SELECT incoming.id, incoming.event_id, incoming.handler, incoming.available_at
1610
+ FROM jsonb_to_recordset($1::jsonb) AS incoming (id text, event_id text, handler text, available_at timestamptz)
1611
+ ), fresh AS (
1612
+ SELECT incoming.*, event.key, event.sequence, ${laneExpression("event.key", "event.id")} AS lane
1613
+ FROM incoming
1614
+ JOIN ${events} AS event ON event.id = incoming.event_id
1615
+ WHERE event.id = ANY (${insertedEvents}) OR NOT incoming.handler = ANY (event.handlers)
1616
+ ), inserted AS (
1617
+ INSERT INTO ${this.#names.deliveries}
1618
+ (id, event_id, handler, lane, status, attempts, available_at, key, sequence, created_at, updated_at)
1619
+ SELECT fresh.id, fresh.event_id, fresh.handler, fresh.lane, 'pending', 0, fresh.available_at,
1620
+ fresh.key, fresh.sequence, $2::timestamptz, $2::timestamptz
1621
+ FROM fresh
1622
+ ORDER BY fresh.event_id, fresh.handler
1623
+ ON CONFLICT (event_id, handler) DO NOTHING
1624
+ RETURNING id, event_id, handler, available_at
1625
+ ), extended AS (
1626
+ UPDATE ${events} AS event SET handlers = event.handlers || added.handlers
1035
1627
  FROM (
1036
- SELECT unnest(picked.in_flight) AS id FROM picked
1037
- ) AS lost
1038
- WHERE delivery.id = lost.id AND delivery.status = 'pending'
1039
- RETURNING delivery.id
1040
- ), taken AS (
1041
- UPDATE ${lanes} AS lane
1042
- SET epoch = lane.epoch + 1, lease_owner = $4::text, lease_expires_at = $5::timestamptz, in_flight = '{}',
1043
- claim_floor = $6::bigint
1044
- FROM picked WHERE lane.lane = picked.lane
1045
- RETURNING lane.lane, lane.epoch
1046
- )
1047
- SELECT lane::int AS lane, epoch::text AS epoch FROM taken ORDER BY lane`;
1628
+ SELECT inserted.event_id, array_agg(inserted.handler) AS handlers FROM inserted
1629
+ WHERE inserted.event_id <> ALL (${insertedEvents}) GROUP BY inserted.event_id
1630
+ ) AS added
1631
+ WHERE event.id = added.event_id
1632
+ RETURNING event.id
1633
+ ), ${this.#wakeUp("inserted", "$4")}
1634
+ SELECT id FROM inserted WHERE (SELECT count(*) FROM woken) >= 0`;
1635
+ }
1636
+ };
1637
+
1638
+ // src/statement-fragments.ts
1639
+ var CLAIM_SCAN_WINDOW = 256;
1640
+ var LANE_SCAN_WINDOW = 64;
1641
+ var LANE_DEPTH_CAP = 16;
1642
+ var INITIAL_FREE_LANES = 4;
1643
+ var HANDLER_PREDICATE = `delivery.handler = ANY (ARRAY(SELECT jsonb_array_elements_text($2::jsonb)))`;
1644
+ function eventColumns(alias) {
1645
+ return `${alias}.id AS event_id,
1646
+ ${alias}.type AS event_type,
1647
+ ${alias}.source AS event_source,
1648
+ ${alias}.key AS event_key,
1649
+ ${alias}.payload::text AS event_payload,
1650
+ ${alias}.headers::text AS event_headers,
1651
+ ${alias}.correlation_id AS event_correlation_id,
1652
+ ${alias}.causation_id AS event_causation_id,
1653
+ ${isoText(`${alias}.occurred_at`)} AS event_occurred_at`;
1654
+ }
1655
+ function countChanged(dml) {
1656
+ return `WITH changed AS (${dml} RETURNING 1) SELECT count(*)::int AS count FROM changed`;
1657
+ }
1658
+ function keyHeads(names, scope) {
1659
+ const deliveries = names.deliveries;
1660
+ const pending = `candidate.lane = ${scope}.lane AND candidate.status = 'pending'`;
1661
+ const nextHandler = (after) => `(SELECT min(wanted.handler) FROM jsonb_array_elements_text($2::jsonb) AS wanted (handler)${after === "" ? "" : ` WHERE wanted.handler > ${after}`})`;
1662
+ return `
1663
+ WITH RECURSIVE hops (handler, key) AS (
1664
+ SELECT ${nextHandler("")}, NULL::text
1665
+ UNION ALL
1666
+ SELECT CASE WHEN next.key IS NULL THEN ${nextHandler("hops.handler")} ELSE hops.handler END, next.key
1667
+ FROM hops
1668
+ CROSS JOIN LATERAL (
1669
+ SELECT CASE WHEN hops.key IS NULL
1670
+ THEN (SELECT candidate.key FROM ${deliveries} AS candidate
1671
+ WHERE ${pending} AND candidate.handler = hops.handler AND candidate.key IS NOT NULL
1672
+ ORDER BY candidate.key LIMIT 1)
1673
+ ELSE (SELECT candidate.key FROM ${deliveries} AS candidate
1674
+ WHERE ${pending} AND candidate.handler = hops.handler AND candidate.key > hops.key
1675
+ ORDER BY candidate.key LIMIT 1)
1676
+ END AS key
1677
+ OFFSET 0
1678
+ ) AS next
1679
+ WHERE hops.handler IS NOT NULL
1680
+ )
1681
+ SELECT lowest.id, lowest.lane, lowest.available_at, lowest.handler, lowest.key
1682
+ FROM hops
1683
+ CROSS JOIN LATERAL (
1684
+ SELECT candidate.id, candidate.lane, candidate.available_at, candidate.handler, candidate.key
1685
+ FROM ${deliveries} AS candidate
1686
+ WHERE ${pending} AND candidate.handler = hops.handler AND candidate.key = hops.key
1687
+ ORDER BY candidate.sequence LIMIT 1
1688
+ ) AS lowest
1689
+ WHERE hops.key IS NOT NULL
1690
+ `;
1691
+ }
1692
+ function noRunningDelivery(names, laneAlias) {
1693
+ return `NOT EXISTS (
1694
+ SELECT 1 FROM unnest(${laneAlias}.in_flight) AS flying (id)
1695
+ JOIN ${names.deliveries} AS delivery ON delivery.id = flying.id AND delivery.status = 'pending'
1696
+ )`;
1697
+ }
1698
+
1699
+ // src/statements-claim.ts
1700
+ var LANE_LIST = `ARRAY(SELECT jsonb_array_elements_text($4::jsonb)::smallint)`;
1701
+ var ClaimStatements = class {
1702
+ #names;
1703
+ constructor(names) {
1704
+ this.#names = names;
1048
1705
  }
1049
1706
  /**
1050
1707
  * Fast claim: picks from the first {@link CLAIM_SCAN_WINDOW} due deliveries of
@@ -1116,7 +1773,7 @@ ${this.#pickedRows(`((SELECT count(*) FROM due_window) >= ${CLAIM_SCAN_WINDOW} O
1116
1773
  heads AS MATERIALIZED (
1117
1774
  SELECT head.id, head.lane, head.available_at, head.handler, head.key
1118
1775
  FROM scope
1119
- CROSS JOIN LATERAL (${this.#keyHeads("scope")}) AS head
1776
+ CROSS JOIN LATERAL (${keyHeads(this.#names, "scope")}) AS head
1120
1777
  ), keyed AS MATERIALIZED (
1121
1778
  SELECT heads.id, heads.lane, heads.available_at
1122
1779
  FROM heads
@@ -1235,18 +1892,11 @@ ORDER BY picked.due_at, picked.delivery_id`;
1235
1892
  SET lease_owner = NULL, lease_expires_at = NULL, in_flight = '{}'
1236
1893
  WHERE lane.lane = ANY (ARRAY(SELECT jsonb_array_elements_text($2::jsonb)::smallint))
1237
1894
  AND lane.lease_owner = $3::text
1238
- AND ${this.#noRunningDelivery("lane")}
1895
+ AND ${noRunningDelivery(this.#names, "lane")}
1239
1896
  RETURNING lane.lane
1240
1897
  )
1241
1898
  SELECT (SELECT count(*) FROM extended)::int AS extended, (SELECT count(*) FROM freed)::int AS freed`;
1242
1899
  }
1243
- /** True when none of the lane's listed deliveries exists as a pending row. */
1244
- #noRunningDelivery(laneAlias) {
1245
- return `NOT EXISTS (
1246
- SELECT 1 FROM unnest(${laneAlias}.in_flight) AS flying (id)
1247
- JOIN ${this.#names.deliveries} AS delivery ON delivery.id = flying.id AND delivery.status = 'pending'
1248
- )`;
1249
- }
1250
1900
  /**
1251
1901
  * Keeps in the in-flight lists of the lanes `$1` holds only the claims in `$2`
1252
1902
  * (`[{ id, lane, epoch, claims }]`, the ones this store handed out and nobody
@@ -1289,7 +1939,15 @@ WHERE lane.lane = owned.lane AND lane.lease_owner = $1::text AND lane.claim_floo
1289
1939
  UPDATE ${this.#names.lanes} AS lane
1290
1940
  SET lease_owner = NULL, lease_expires_at = NULL, in_flight = '{}'
1291
1941
  FROM owned
1292
- WHERE lane.lane = owned.lane AND lane.lease_owner = $1::text AND ${this.#noRunningDelivery("lane")}`;
1942
+ WHERE lane.lane = owned.lane AND lane.lease_owner = $1::text AND ${noRunningDelivery(this.#names, "lane")}`;
1943
+ }
1944
+ };
1945
+
1946
+ // src/statements-completion.ts
1947
+ var CompletionStatements = class {
1948
+ #names;
1949
+ constructor(names) {
1950
+ this.#names = names;
1293
1951
  }
1294
1952
  /**
1295
1953
  * Extends the lease of the lane a running delivery belongs to; the lane lease
@@ -1390,158 +2048,493 @@ SELECT ord FROM completed`;
1390
2048
  */
1391
2049
  #leaveFlight(assignments) {
1392
2050
  const lanes = this.#names.lanes;
1393
- return `WITH fence AS MATERIALIZED (
1394
- SELECT lane.lane FROM ${lanes} AS lane
1395
- WHERE lane.lane = $2::smallint AND lane.epoch = $3::bigint AND $1::text = ANY (lane.in_flight)
1396
- FOR NO KEY UPDATE
1397
- ), changed AS (
1398
- UPDATE ${this.#names.deliveries} AS delivery ${assignments}
1399
- FROM fence
1400
- WHERE delivery.id = $1::text AND delivery.lane = fence.lane AND delivery.status = 'pending'
1401
- AND delivery.claims = $4::int
1402
- RETURNING delivery.id
1403
- ), trimmed AS (
1404
- UPDATE ${lanes} AS lane SET in_flight = array_remove(lane.in_flight, $1::text)
1405
- FROM changed WHERE lane.lane = $2::smallint
1406
- RETURNING lane.lane
1407
- )
1408
- SELECT count(*)::int AS count FROM changed`;
2051
+ return `WITH fence AS MATERIALIZED (
2052
+ SELECT lane.lane FROM ${lanes} AS lane
2053
+ WHERE lane.lane = $2::smallint AND lane.epoch = $3::bigint AND $1::text = ANY (lane.in_flight)
2054
+ FOR NO KEY UPDATE
2055
+ ), changed AS (
2056
+ UPDATE ${this.#names.deliveries} AS delivery ${assignments}
2057
+ FROM fence
2058
+ WHERE delivery.id = $1::text AND delivery.lane = fence.lane AND delivery.status = 'pending'
2059
+ AND delivery.claims = $4::int
2060
+ RETURNING delivery.id
2061
+ ), trimmed AS (
2062
+ UPDATE ${lanes} AS lane SET in_flight = array_remove(lane.in_flight, $1::text)
2063
+ FROM changed WHERE lane.lane = $2::smallint
2064
+ RETURNING lane.lane
2065
+ )
2066
+ SELECT count(*)::int AS count FROM changed`;
2067
+ }
2068
+ };
2069
+
2070
+ // src/statements-candidates.ts
2071
+ function notRunning(running, alias) {
2072
+ return running === null ? "true" : `NOT EXISTS (SELECT 1 FROM ${running} AS running WHERE running.handler = ${alias}.handler AND running.key = ${alias}.key)`;
2073
+ }
2074
+ var CandidateStatements = class {
2075
+ #names;
2076
+ constructor(names) {
2077
+ this.#names = names;
2078
+ }
2079
+ /**
2080
+ * CTEs that pick up to `limit` claimable deliveries from the lanes of the CTE `scope` (columns `lane`,
2081
+ * `epoch`, `in_flight`), named after `prefix`; the result is `<prefix>_candidates (id, lane, available_at)`.
2082
+ * The rules are those of `ClaimStatements.claim`. The fast window reads the first {@link LANE_SCAN_WINDOW} due
2083
+ * deliveries of each lane (at most {@link CLAIM_SCAN_WINDOW} together); of a key, only the lowest sequence
2084
+ * in the window can be its head, so only that one is checked for an earlier pending delivery. Only when
2085
+ * that yields fewer than `limit` are the lanes whose window filled up looked at again with a loose index
2086
+ * scan over the heads of all their keys. `running` names the CTE of the keyed deliveries running in the
2087
+ * lanes, or is null when nothing runs there.
2088
+ */
2089
+ candidates(prefix, {
2090
+ scope,
2091
+ running,
2092
+ limit
2093
+ }) {
2094
+ const spec = { prefix, scope, running, limit };
2095
+ return this.#fastCandidates(spec) + this.#slowCandidates(spec);
2096
+ }
2097
+ /** The fast window of each lane and the claimable deliveries in it (`<prefix>_window`, `<prefix>_fast`). */
2098
+ #fastCandidates({ prefix, scope, running, limit }) {
2099
+ const deliveries = this.#names.deliveries;
2100
+ return `${prefix}_window AS MATERIALIZED (
2101
+ SELECT recent.id, recent.lane, recent.available_at, recent.handler, recent.key, recent.sequence
2102
+ FROM ${scope}
2103
+ CROSS JOIN LATERAL (
2104
+ SELECT delivery.id, delivery.lane, delivery.available_at, delivery.handler, delivery.key, delivery.sequence
2105
+ FROM ${deliveries} AS delivery
2106
+ WHERE delivery.lane = ${scope}.lane AND delivery.status = 'pending'
2107
+ AND delivery.available_at <= $1::timestamptz AND ${HANDLER_PREDICATE}
2108
+ AND delivery.id <> ALL (${scope}.in_flight)
2109
+ ORDER BY delivery.available_at, delivery.id
2110
+ LIMIT ${LANE_SCAN_WINDOW}
2111
+ ) AS recent
2112
+ ORDER BY recent.available_at, recent.id
2113
+ LIMIT ${CLAIM_SCAN_WINDOW}
2114
+ ), ${prefix}_fast AS MATERIALIZED (
2115
+ SELECT picked.id, picked.lane, picked.available_at FROM (
2116
+ SELECT unkeyed.id, unkeyed.lane, unkeyed.available_at FROM ${prefix}_window AS unkeyed WHERE unkeyed.key IS NULL
2117
+ UNION ALL
2118
+ SELECT head.id, head.lane, head.available_at
2119
+ FROM (
2120
+ SELECT DISTINCT ON (keyed.handler, keyed.key) keyed.id, keyed.lane, keyed.available_at, keyed.handler, keyed.key, keyed.sequence
2121
+ FROM ${prefix}_window AS keyed
2122
+ WHERE keyed.key IS NOT NULL
2123
+ ORDER BY keyed.handler, keyed.key, keyed.sequence
2124
+ ) AS head
2125
+ WHERE NOT EXISTS (
2126
+ SELECT 1 FROM ${deliveries} AS earlier
2127
+ WHERE earlier.lane = head.lane AND earlier.handler = head.handler AND earlier.key = head.key
2128
+ AND earlier.status = 'pending' AND earlier.key IS NOT NULL AND earlier.sequence < head.sequence
2129
+ ) AND ${notRunning(running, "head")}
2130
+ ) AS picked
2131
+ ORDER BY picked.available_at, picked.id
2132
+ LIMIT ${limit}
2133
+ `;
2134
+ }
2135
+ /**
2136
+ * For lanes whose window filled up while the fast pick came up short: the heads of all their keys and
2137
+ * their unkeyed deliveries, then the union with the fast pick (`<prefix>_candidates`).
2138
+ */
2139
+ #slowCandidates(spec) {
2140
+ return this.#saturatedLanes(spec) + this.#keyHeads(spec) + this.#keyedHeads(spec) + this.#unkeyedRecent(spec) + this.#unionCandidates(spec);
2141
+ }
2142
+ /** `<prefix>_saturated`: the lanes whose window filled up while the fast pick came up short. */
2143
+ #saturatedLanes({ prefix, scope, limit }) {
2144
+ return `), ${prefix}_saturated AS MATERIALIZED (
2145
+ SELECT ${scope}.lane, ${scope}.in_flight FROM ${scope}
2146
+ WHERE (SELECT count(*) FROM ${prefix}_fast) < ${limit}
2147
+ AND ((SELECT count(*) FROM ${prefix}_window) >= ${CLAIM_SCAN_WINDOW}
2148
+ OR ${scope}.lane IN (
2149
+ SELECT full_lane.lane FROM ${prefix}_window AS full_lane
2150
+ GROUP BY full_lane.lane HAVING count(*) >= ${LANE_SCAN_WINDOW}
2151
+ ))
2152
+ `;
2153
+ }
2154
+ /** `<prefix>_heads`: the head of every key of the saturated lanes. */
2155
+ #keyHeads({ prefix }) {
2156
+ return `), ${prefix}_heads AS MATERIALIZED (
2157
+ SELECT head.id, head.lane, head.available_at, head.handler, head.key
2158
+ FROM ${prefix}_saturated AS scope
2159
+ CROSS JOIN LATERAL (${keyHeads(this.#names, "scope")}) AS head
2160
+ `;
2161
+ }
2162
+ /** `<prefix>_keyed`: the due heads that are not in flight and whose key runs nowhere. */
2163
+ #keyedHeads({ prefix, running, limit }) {
2164
+ return `), ${prefix}_keyed AS MATERIALIZED (
2165
+ SELECT heads.id, heads.lane, heads.available_at
2166
+ FROM ${prefix}_heads AS heads
2167
+ JOIN ${prefix}_saturated AS scope ON scope.lane = heads.lane
2168
+ WHERE heads.available_at <= $1::timestamptz AND heads.id <> ALL (scope.in_flight) AND ${notRunning(running, "heads")}
2169
+ ORDER BY heads.available_at, heads.id
2170
+ LIMIT ${limit}
2171
+ `;
2172
+ }
2173
+ /** `<prefix>_unkeyed`: the due unkeyed deliveries of the saturated lanes that are not in flight. */
2174
+ #unkeyedRecent({ prefix, limit }) {
2175
+ const deliveries = this.#names.deliveries;
2176
+ return `), ${prefix}_unkeyed AS MATERIALIZED (
2177
+ SELECT recent.id, recent.lane, recent.available_at
2178
+ FROM ${prefix}_saturated AS scope
2179
+ CROSS JOIN LATERAL (
2180
+ SELECT delivery.id, delivery.lane, delivery.available_at
2181
+ FROM ${deliveries} AS delivery
2182
+ WHERE delivery.lane = scope.lane AND delivery.key IS NULL AND delivery.status = 'pending'
2183
+ AND delivery.available_at <= $1::timestamptz AND ${HANDLER_PREDICATE}
2184
+ AND delivery.id <> ALL (scope.in_flight)
2185
+ ORDER BY delivery.available_at, delivery.id
2186
+ LIMIT ${limit}
2187
+ ) AS recent
2188
+ ORDER BY recent.available_at, recent.id
2189
+ LIMIT ${limit}
2190
+ `;
2191
+ }
2192
+ /** `<prefix>_candidates`: the union of the fast pick and the slow picks, oldest first, at most `limit`. */
2193
+ #unionCandidates({ prefix, limit }) {
2194
+ return `), ${prefix}_candidates AS MATERIALIZED (
2195
+ SELECT chosen.id, chosen.lane, chosen.available_at FROM (
2196
+ SELECT fast.id, fast.lane, fast.available_at FROM ${prefix}_fast AS fast
2197
+ UNION
2198
+ SELECT keyed.id, keyed.lane, keyed.available_at FROM ${prefix}_keyed AS keyed
2199
+ UNION
2200
+ SELECT unkeyed.id, unkeyed.lane, unkeyed.available_at FROM ${prefix}_unkeyed AS unkeyed
2201
+ ) AS chosen
2202
+ ORDER BY chosen.available_at, chosen.id
2203
+ LIMIT ${limit}
2204
+ )`;
2205
+ }
2206
+ };
2207
+
2208
+ // src/statements-lanes.ts
2209
+ var FREE_LANE_SURPLUS = 16;
2210
+ var freeLane = (alias) => `(${alias}.lease_expires_at IS NULL OR ${alias}.lease_expires_at <= $1::timestamptz
2211
+ OR (${alias}.lease_owner = $4::text AND ${alias}.claim_floor < $6::bigint))`;
2212
+ var NO_FREE_LANES = `), free_lanes AS (
2213
+ SELECT NULL::smallint AS lane, NULL::bigint AS epoch, NULL::text[] AS in_flight, false AS running WHERE false
2214
+ ), free_scope AS (
2215
+ SELECT NULL::smallint AS lane, NULL::bigint AS epoch WHERE false
2216
+ ), free_candidates AS (
2217
+ SELECT NULL::text AS id, NULL::smallint AS lane, NULL::timestamptz AS available_at WHERE false
2218
+ )`;
2219
+ var LaneStatements = class {
2220
+ #names;
2221
+ #candidateStatements;
2222
+ constructor(names) {
2223
+ this.#names = names;
2224
+ this.#candidateStatements = new CandidateStatements(names);
2225
+ }
2226
+ /**
2227
+ * A claim in one statement, the only one in the common case. Parameters:
2228
+ * `[now, handlersJson, limit, leaseOwner, leaseExpiresAt, generation]`.
2229
+ *
2230
+ * 1. **Held lanes.** The lanes this owner holds under a live lease whose `claim_floor` is exactly the
2231
+ * generation are locked `FOR NO KEY UPDATE` in lane order. A lane above it was raised by a
2232
+ * reconciliation, so a statement of a claim whose caller gave up (a client timeout does not cancel
2233
+ * the statement on the server) and that reaches the server late records nothing; a lane below it was
2234
+ * taken by such a late statement after the reconciliation, and its listed deliveries were never
2235
+ * handed out. A lane that another transaction of this owner is changing makes the statement wait;
2236
+ * the statement then reports `stale` and does nothing (see the `stale` CTE).
2237
+ * 2. **Free lanes**, only when the held lanes yield fewer than `limit`: the free lanes with a due
2238
+ * delivery (one index probe per free lane) are ranked by their oldest due delivery; the first
2239
+ * {@link INITIAL_FREE_LANES} that can give something now, and further ones until what they can give
2240
+ * (estimated from their first {@link LANE_DEPTH_CAP} due deliveries) covers what is still wanted, are locked
2241
+ * `FOR UPDATE SKIP LOCKED`, so a lane another claimer has is skipped, never waited
2242
+ * for. A lane that still runs deliveries of an expired lease is left to the transaction of
2243
+ * {@link takeFreeLanes}, which charges their lost attempts (`takeover_waiting`); so is any lane whose
2244
+ * locked row differs from what the statement's snapshot saw. A lane that runs nothing and did not
2245
+ * change cannot have had a delivery written since the snapshot, so its deliveries can be read here.
2246
+ * 3. **Picks** follow the rules of `ClaimStatements.claim`: the fast window of each lane, and for lanes whose
2247
+ * window filled up while the batch is not full, the heads of all keys (a loose index scan per lane).
2248
+ * 4. **Writes**: one update per lane. A held lane records its picks; a taken lane gets a new epoch, the
2249
+ * owner, the lease and its picks; a taken lane that yielded nothing is not written. Held lanes that
2250
+ * picked nothing, run nothing and have no full scan window are freed.
2251
+ *
2252
+ * Always returns one row per picked delivery, or one row whose `delivery_id` is null. `running_ids`
2253
+ * (JSON text) lists the deliveries the held lanes still run, `examined_lanes` the held lanes.
2254
+ */
2255
+ get claimLanes() {
2256
+ return this.#claimLanes(true);
2257
+ }
2258
+ /**
2259
+ * {@link claimLanes} limited to the lanes the owner holds: the same parameters, rows and writes, without
2260
+ * looking at free lanes (`takeover_waiting` and `unexamined_free_lanes` are false). A worker draining a
2261
+ * backlog finds its batch in the lanes it holds; this statement is about half as costly to plan, and
2262
+ * the caller runs {@link claimLanes} after it only when it came up short.
2263
+ */
2264
+ get claimHeldLanes() {
2265
+ return this.#claimLanes(false);
2266
+ }
2267
+ #claimLanes(includeFreeLanes) {
2268
+ const free = includeFreeLanes ? this.#freeLaneChoice() + this.#freeLaneDepth() + this.#freeLaneTake() : NO_FREE_LANES;
2269
+ return this.#heldLanes() + free + this.#claimPicks() + this.#claimRecord() + this.#claimRelease();
2270
+ }
2271
+ /** Locks the held lanes, detects a stale snapshot and picks from the held lanes; ends with `wanted`. */
2272
+ #heldLanes() {
2273
+ const deliveries = this.#names.deliveries;
2274
+ const lanes = this.#names.lanes;
2275
+ return `WITH held AS MATERIALIZED (
2276
+ SELECT lane.lane, lane.epoch, lane.in_flight
2277
+ FROM ${lanes} AS lane
2278
+ WHERE lane.lease_owner = $4::text AND lane.lease_expires_at > $1::timestamptz
2279
+ AND lane.claim_floor = $6::bigint
2280
+ ORDER BY lane.lane
2281
+ FOR NO KEY UPDATE
2282
+ ), stale AS (
2283
+ -- A claim that waited for a lane lock sees the new lane row but reads deliveries with the snapshot it took
2284
+ -- before the wait: whatever changed the lane meanwhile (a reschedule, a release) may also have changed
2285
+ -- deliveries it cannot see. The statement then does nothing and reports it, and the caller runs it again.
2286
+ SELECT EXISTS (
2287
+ SELECT 1 FROM held
2288
+ JOIN ${lanes} AS before ON before.lane = held.lane
2289
+ WHERE before.epoch <> held.epoch OR before.in_flight IS DISTINCT FROM held.in_flight
2290
+ ) AS value
2291
+ ), held_scope AS MATERIALIZED (
2292
+ SELECT held.lane, held.epoch, held.in_flight FROM held WHERE NOT (SELECT value FROM stale)
2293
+ ), held_running AS MATERIALIZED (
2294
+ SELECT delivery.handler, delivery.key
2295
+ FROM held_scope
2296
+ CROSS JOIN LATERAL unnest(held_scope.in_flight) AS flying (id)
2297
+ JOIN ${deliveries} AS delivery ON delivery.id = flying.id
2298
+ WHERE delivery.status = 'pending' AND delivery.key IS NOT NULL
2299
+ ), ${this.#candidateStatements.candidates("held", { scope: "held_scope", running: "held_running", limit: "$3::int" })}, wanted AS MATERIALIZED (
2300
+ SELECT CASE WHEN (SELECT value FROM stale) THEN 0
2301
+ ELSE $3::int - (SELECT count(*) FROM held_candidates)::int END AS count
2302
+ `;
2303
+ }
2304
+ /** The oldest due delivery of each free lane, at most the number still wanted plus a surplus. */
2305
+ #freeLaneChoice() {
2306
+ const deliveries = this.#names.deliveries;
2307
+ const lanes = this.#names.lanes;
2308
+ return `), free_lanes AS MATERIALIZED (
2309
+ -- The oldest due delivery of each free lane: one index probe per free lane, bounded by the due time,
2310
+ -- so deliveries scheduled for later are never read.
2311
+ SELECT lane.lane, lane.epoch, lane.in_flight, lane.lease_owner, lane.lease_expires_at,
2312
+ oldest.available_at, oldest.id, NOT ${noRunningDelivery(this.#names, "lane")} AS running
2313
+ FROM ${lanes} AS lane
2314
+ CROSS JOIN LATERAL (
2315
+ SELECT delivery.available_at, delivery.id
2316
+ FROM ${deliveries} AS delivery
2317
+ WHERE delivery.lane = lane.lane AND delivery.status = 'pending'
2318
+ AND delivery.available_at <= $1::timestamptz AND ${HANDLER_PREDICATE}
2319
+ ORDER BY delivery.available_at, delivery.id
2320
+ LIMIT 1
2321
+ ) AS oldest
2322
+ WHERE (SELECT count FROM wanted) > 0 AND ${freeLane("lane")}
2323
+ ORDER BY oldest.available_at, oldest.id
2324
+ LIMIT (SELECT count FROM wanted) + ${FREE_LANE_SURPLUS}
2325
+ `;
1409
2326
  }
1410
- /** Parameters: `[deliveryIdsJson, now]`. The dead rows are locked in id order first. */
1411
- get requeue() {
2327
+ /** What each free candidate lane can give now, and their order. */
2328
+ #freeLaneDepth() {
1412
2329
  const deliveries = this.#names.deliveries;
1413
- return countChanged(`WITH dead AS MATERIALIZED (
1414
- SELECT delivery.id FROM ${deliveries} AS delivery
1415
- WHERE delivery.id = ANY (ARRAY(SELECT jsonb_array_elements_text($1::jsonb))) AND delivery.status = 'dead'
1416
- ORDER BY delivery.id
1417
- FOR NO KEY UPDATE
1418
- )
1419
- UPDATE ${deliveries} AS delivery
1420
- SET status = 'pending', attempts = 0, available_at = $2::timestamptz, updated_at = $2::timestamptz
1421
- FROM dead
1422
- WHERE delivery.id = dead.id AND delivery.status = 'dead'`);
2330
+ return `), free_depth AS MATERIALIZED (
2331
+ -- What each candidate lane can give now, from its first due deliveries: unkeyed ones, and of a key its
2332
+ -- lowest one when nothing earlier of the key is pending. Lanes that can give nothing are passed over;
2333
+ -- of the others, the first ${INITIAL_FREE_LANES} are taken, and further ones until they cover what is
2334
+ -- still wanted, so the earliest due work of the free lanes comes first.
2335
+ SELECT runnable.lane,
2336
+ row_number() OVER (ORDER BY runnable.available_at, runnable.id) AS rank,
2337
+ sum(runnable.count) OVER (ORDER BY runnable.available_at, runnable.id ROWS UNBOUNDED PRECEDING) - runnable.count AS due_before
2338
+ FROM (
2339
+ SELECT lane_candidate.lane, lane_candidate.available_at, lane_candidate.id, depth.count
2340
+ FROM free_lanes AS lane_candidate
2341
+ CROSS JOIN LATERAL (
2342
+ -- A full window that shows nothing runnable may hide runnable work behind it: such a lane counts
2343
+ -- once, and the claim looks behind its blocked deliveries.
2344
+ SELECT GREATEST(count(*) FILTER (
2345
+ WHERE due.key IS NULL OR (due.position = 1 AND NOT EXISTS (
2346
+ SELECT 1 FROM ${deliveries} AS earlier
2347
+ WHERE earlier.lane = due.lane AND earlier.handler = due.handler AND earlier.key = due.key
2348
+ AND earlier.status = 'pending' AND earlier.key IS NOT NULL AND earlier.sequence < due.sequence
2349
+ ))
2350
+ ), CASE WHEN count(*) >= ${LANE_DEPTH_CAP} THEN 1 ELSE 0 END) AS count
2351
+ FROM (
2352
+ SELECT window_rows.*,
2353
+ row_number() OVER (PARTITION BY window_rows.handler, window_rows.key ORDER BY window_rows.sequence) AS position
2354
+ FROM (
2355
+ SELECT delivery.lane, delivery.handler, delivery.key, delivery.sequence FROM ${deliveries} AS delivery
2356
+ WHERE delivery.lane = lane_candidate.lane AND delivery.status = 'pending'
2357
+ AND delivery.available_at <= $1::timestamptz AND ${HANDLER_PREDICATE}
2358
+ ORDER BY delivery.available_at, delivery.id
2359
+ LIMIT ${LANE_DEPTH_CAP}
2360
+ ) AS window_rows
2361
+ ) AS due
2362
+ ) AS depth
2363
+ WHERE NOT lane_candidate.running AND depth.count > 0
2364
+ ) AS runnable
2365
+ `;
1423
2366
  }
1424
- /**
1425
- * Builds the filtered, creation-ordered listing. A delivery is `processing`
1426
- * while its row exists as `pending` and its lane lists its id; its attempt
1427
- * count, lease owner and lease expiry then come from the claim and the lane.
1428
- * Records of the completion history (when kept) are listed as `completed`.
1429
- */
1430
- listDeliveries(query) {
1431
- const parameters = [];
1432
- const bind = (value) => {
1433
- parameters.push(value);
1434
- return `$${parameters.length}::${typeof value === "number" ? "int" : "text"}`;
1435
- };
1436
- const filters = {
1437
- handler: query.handler === void 0 ? void 0 : bind(query.handler),
1438
- eventType: query.eventType === void 0 ? void 0 : bind(query.eventType),
1439
- eventId: query.eventId === void 0 ? void 0 : bind(query.eventId),
1440
- status: query.status === void 0 ? void 0 : bind(query.status)
1441
- };
1442
- const limit = bind(query.limit ?? DEFAULT_LIST_LIMIT);
1443
- const common = (alias) => [
1444
- ...filters.handler === void 0 ? [] : [`${alias}.handler = ${filters.handler}`],
1445
- ...filters.eventType === void 0 ? [] : [`event.type = ${filters.eventType}`],
1446
- ...filters.eventId === void 0 ? [] : [`${alias}.event_id = ${filters.eventId}`]
1447
- ];
1448
- const inFlight = `(delivery.status = 'pending' AND delivery.id = ANY (lane.in_flight))`;
1449
- const whereCurrent = [
1450
- ...common("delivery"),
1451
- ...filters.status === void 0 ? [] : [
1452
- `(CASE WHEN ${inFlight} THEN 'processing' ELSE delivery.status END) = ${filters.status}`
1453
- ]
1454
- ];
1455
- const current = `SELECT delivery.id AS delivery_id, delivery.handler,
1456
- CASE WHEN ${inFlight} THEN 'processing' ELSE delivery.status END AS status,
1457
- (delivery.attempts + CASE WHEN ${inFlight} THEN 1 ELSE 0 END)::int AS attempts,
1458
- ${isoText("delivery.available_at")} AS available_at,
1459
- CASE WHEN ${inFlight} THEN lane.lease_owner END AS lease_owner,
1460
- CASE WHEN ${inFlight} THEN ${isoText("lane.lease_expires_at")} END AS lease_expires_at,
1461
- delivery.last_error::text AS last_error,
1462
- ${isoText("delivery.created_at")} AS created_at,
1463
- ${isoText("delivery.updated_at")} AS updated_at,
1464
- ${isoText("delivery.completed_at")} AS completed_at,
1465
- ${eventColumns("event")}
1466
- FROM ${this.#names.deliveries} AS delivery
1467
- JOIN ${this.#names.lanes} AS lane ON lane.lane = delivery.lane
1468
- JOIN ${this.#names.events} AS event ON event.id = delivery.event_id
1469
- ${whereCurrent.length === 0 ? "" : `WHERE ${whereCurrent.join(" AND ")}`}`;
1470
- const includeHistory = this.#names.completionHistory && (query.status === void 0 || query.status === "completed");
1471
- if (!includeHistory) {
1472
- return {
1473
- sql: `${current}
1474
- ORDER BY delivery.created_at, delivery.id
1475
- LIMIT ${limit}`,
1476
- parameters
1477
- };
1478
- }
1479
- const history = `SELECT done.id AS delivery_id, done.handler, 'completed' AS status, done.attempts,
1480
- ${isoText("done.available_at")} AS available_at,
1481
- NULL AS lease_owner,
1482
- NULL AS lease_expires_at,
1483
- NULL AS last_error,
1484
- ${isoText("done.created_at")} AS created_at,
1485
- ${isoText("done.completed_at")} AS updated_at,
1486
- ${isoText("done.completed_at")} AS completed_at,
1487
- ${eventColumns("event")}
1488
- FROM ${this.#names.completions} AS done
1489
- JOIN ${this.#names.events} AS event ON event.id = done.event_id
1490
- ${common("done").length === 0 ? "" : `WHERE ${common("done").join(" AND ")}`}`;
1491
- return {
1492
- sql: `SELECT * FROM (
1493
- ${current}
1494
- UNION ALL
1495
- ${history}
1496
- ) AS listed
1497
- ORDER BY created_at, delivery_id
1498
- LIMIT ${limit}`,
1499
- parameters
1500
- };
2367
+ /** Locks the chosen free lanes that did not change hands and picks from them. */
2368
+ #freeLaneTake() {
2369
+ const lanes = this.#names.lanes;
2370
+ return `), free_locked AS MATERIALIZED (
2371
+ SELECT lane.lane, lane.epoch, lane.in_flight, lane.lease_owner, lane.lease_expires_at
2372
+ FROM ${lanes} AS lane
2373
+ WHERE lane.lane IN (
2374
+ SELECT chosen.lane FROM free_depth AS chosen
2375
+ WHERE chosen.rank <= ${INITIAL_FREE_LANES} OR chosen.due_before < (SELECT count FROM wanted)
2376
+ )
2377
+ AND ${freeLane("lane")}
2378
+ ORDER BY lane.lane
2379
+ FOR UPDATE SKIP LOCKED
2380
+ ), free_scope AS MATERIALIZED (
2381
+ -- A lane whose locked row is not the one the snapshot saw changed hands meanwhile: left for a later claim.
2382
+ SELECT locked.lane, locked.epoch + 1 AS epoch, '{}'::text[] AS in_flight
2383
+ FROM free_locked AS locked
2384
+ JOIN free_lanes AS seen ON seen.lane = locked.lane
2385
+ WHERE locked.epoch = seen.epoch AND locked.in_flight = seen.in_flight
2386
+ AND locked.lease_owner IS NOT DISTINCT FROM seen.lease_owner
2387
+ AND locked.lease_expires_at IS NOT DISTINCT FROM seen.lease_expires_at
2388
+ ), ${this.#candidateStatements.candidates("free", { scope: "free_scope", running: null, limit: "(SELECT count FROM wanted)" })}`;
1501
2389
  }
1502
- /**
1503
- * Counts of rows by stored status (`pending` includes running deliveries;
1504
- * `completed` rows exist only after an upgrade from 0.1.0). Each count is
1505
- * answered from a partial index.
1506
- */
1507
- get countByStatus() {
2390
+ /** The picked deliveries of held and taken lanes with their events, and the picks per lane. */
2391
+ #claimPicks() {
1508
2392
  const deliveries = this.#names.deliveries;
1509
- return ["pending", "dead", "completed"].map(
1510
- (status) => `SELECT '${status}' AS status, count(*)::text AS count FROM ${deliveries} WHERE status = '${status}'`
1511
- ).join("\nUNION ALL\n");
2393
+ return `, claim_scope AS MATERIALIZED (
2394
+ SELECT held_scope.lane, held_scope.epoch FROM held_scope
2395
+ UNION ALL
2396
+ SELECT free_scope.lane, free_scope.epoch FROM free_scope
2397
+ ), picked_rows AS MATERIALIZED (
2398
+ SELECT candidates.available_at AS due_at, delivery.id AS delivery_id, delivery.lane::int AS lane,
2399
+ scope.epoch::text AS epoch, delivery.claims AS claims, delivery.handler, delivery.key AS delivery_key, delivery.sequence::text AS sequence, (delivery.attempts + 1)::int AS attempt,
2400
+ ${eventColumns("event")}
2401
+ FROM (
2402
+ SELECT held_candidates.id, held_candidates.lane, held_candidates.available_at FROM held_candidates
2403
+ UNION ALL
2404
+ SELECT free_candidates.id, free_candidates.lane, free_candidates.available_at FROM free_candidates
2405
+ ) AS candidates
2406
+ JOIN claim_scope AS scope ON scope.lane = candidates.lane
2407
+ JOIN ${deliveries} AS delivery ON delivery.id = candidates.id
2408
+ JOIN ${this.#names.events} AS event ON event.id = delivery.event_id
2409
+ ), by_lane AS (
2410
+ SELECT picked_rows.lane, array_agg(picked_rows.delivery_id) AS ids FROM picked_rows GROUP BY picked_rows.lane
2411
+ `;
1512
2412
  }
1513
- /** Deliveries that are running: listed by a lane and still pending as rows. */
1514
- get countRunning() {
1515
- return `SELECT count(*)::text AS count
1516
- FROM ${this.#names.lanes} AS lane
1517
- CROSS JOIN LATERAL unnest(lane.in_flight) AS flying (id)
1518
- JOIN ${this.#names.deliveries} AS delivery ON delivery.id = flying.id AND delivery.status = 'pending'`;
2413
+ /** Records the picks in the held lanes and takes the free lanes that yielded picks. */
2414
+ #claimRecord() {
2415
+ const deliveries = this.#names.deliveries;
2416
+ const lanes = this.#names.lanes;
2417
+ return `), still_running AS (
2418
+ -- The retained ids come from the locked \`held\` rows, not from the lane table: a claim that waited for the lock
2419
+ -- must see the in-flight list the claim before it recorded, which the statement snapshot may not.
2420
+ SELECT held.lane, array_agg(delivery.id) AS ids
2421
+ FROM by_lane
2422
+ JOIN held ON held.lane = by_lane.lane
2423
+ CROSS JOIN LATERAL unnest(held.in_flight) AS flying (id)
2424
+ JOIN ${deliveries} AS delivery ON delivery.id = flying.id AND delivery.status = 'pending'
2425
+ GROUP BY held.lane
2426
+ ), extended AS (
2427
+ UPDATE ${lanes} AS lane
2428
+ SET in_flight = COALESCE(still_running.ids, '{}') || by_lane.ids,
2429
+ lease_expires_at = GREATEST(lane.lease_expires_at, $5::timestamptz)
2430
+ FROM by_lane
2431
+ JOIN held_scope ON held_scope.lane = by_lane.lane
2432
+ LEFT JOIN still_running ON still_running.lane = by_lane.lane
2433
+ WHERE lane.lane = by_lane.lane
2434
+ RETURNING lane.lane
2435
+ ), taken AS (
2436
+ UPDATE ${lanes} AS lane
2437
+ SET epoch = free_scope.epoch, lease_owner = $4::text, lease_expires_at = $5::timestamptz,
2438
+ in_flight = by_lane.ids, claim_floor = $6::bigint
2439
+ FROM free_scope
2440
+ JOIN by_lane ON by_lane.lane = free_scope.lane
2441
+ WHERE lane.lane = free_scope.lane
2442
+ RETURNING lane.lane
2443
+ `;
1519
2444
  }
1520
- get countCompletionHistory() {
1521
- return `SELECT count(*)::text AS count FROM ${this.#names.completions}`;
2445
+ /** Frees the held lanes that yielded nothing and run nothing, and selects the result rows. */
2446
+ #claimRelease() {
2447
+ const deliveries = this.#names.deliveries;
2448
+ const lanes = this.#names.lanes;
2449
+ return `), saturated_lanes AS (
2450
+ SELECT held_window.lane FROM held_window GROUP BY held_window.lane HAVING count(*) >= ${LANE_SCAN_WINDOW}
2451
+ ), freed AS (
2452
+ UPDATE ${lanes} AS lane
2453
+ SET lease_owner = NULL, lease_expires_at = NULL, in_flight = '{}'
2454
+ FROM held
2455
+ WHERE lane.lane = held.lane
2456
+ AND lane.lane NOT IN (SELECT lane FROM by_lane)
2457
+ AND lane.lane NOT IN (SELECT lane FROM saturated_lanes)
2458
+ AND (SELECT count(*) FROM held_window) < ${CLAIM_SCAN_WINDOW}
2459
+ AND NOT (SELECT value FROM stale)
2460
+ AND ${noRunningDelivery(this.#names, "lane")}
2461
+ RETURNING lane.lane
2462
+ )
2463
+ SELECT (SELECT value FROM stale) AS stale,
2464
+ EXISTS (SELECT 1 FROM free_lanes WHERE free_lanes.running) AS takeover_waiting,
2465
+ (SELECT count(*) FROM free_lanes) = (SELECT count FROM wanted) + ${FREE_LANE_SURPLUS} AS unexamined_free_lanes,
2466
+ (
2467
+ SELECT COALESCE(jsonb_agg(delivery.id), '[]'::jsonb)
2468
+ FROM held
2469
+ CROSS JOIN LATERAL unnest(held.in_flight) AS flying (id)
2470
+ JOIN ${deliveries} AS delivery ON delivery.id = flying.id AND delivery.status = 'pending'
2471
+ )::text AS running_ids,
2472
+ (SELECT COALESCE(jsonb_agg(held.lane), '[]'::jsonb) FROM held)::text AS examined_lanes,
2473
+ picked.*
2474
+ FROM (SELECT 1) AS anchor
2475
+ LEFT JOIN picked_rows AS picked ON true
2476
+ ORDER BY picked.due_at, picked.delivery_id`;
1522
2477
  }
1523
2478
  /**
1524
- * Oldest instant at which a delivery became claimable: `available_at` of a
1525
- * pending delivery that nobody runs, or the lease expiry of a lane that runs
1526
- * deliveries. Parameters: `[now]`.
2479
+ * Takes up to `$3` free lanes (no lease, or an expired one) that have a due
2480
+ * delivery of the requested handlers, the ones with the oldest due delivery
2481
+ * first. Parameters: `[now, handlersJson, laneCount, leaseOwner, leaseExpiresAt, generation]`.
2482
+ * The lane gets the owner's `generation` as its `claim_floor`. A lane the owner itself holds under a live
2483
+ * lease with a lower floor was taken by a claim statement that reached the server after its caller gave up
2484
+ * and the owner reconciled (or by an earlier incarnation of the same owner): it is taken over like the lane of a
2485
+ * crashed worker, so its listed deliveries are charged an attempt.
2486
+ *
2487
+ * A taken lane gets a new epoch, which fences everything the previous owner
2488
+ * still holds. Deliveries the previous owner had in flight are the ones whose
2489
+ * attempt was never recorded (a claim writes no delivery row), so each of
2490
+ * them is charged one attempt here: a worker that crashes with a poison
2491
+ * message still runs it into the retry limit. Lanes another claimer has locked
2492
+ * are skipped, never waited for.
1527
2493
  */
1528
- get oldestDue() {
2494
+ get takeFreeLanes() {
1529
2495
  const deliveries = this.#names.deliveries;
1530
2496
  const lanes = this.#names.lanes;
1531
- return `SELECT ${isoText("min(due.due_at)")} AS oldest_due_at FROM (
1532
- SELECT waiting.available_at AS due_at
1533
- FROM ${lanes} AS lane
2497
+ return `WITH picked AS (
2498
+ -- in_flight is read here, from the locked version of the lane: a settlement that committed after the
2499
+ -- statement took its snapshot has taken its delivery out of the list already and must not be charged again.
2500
+ SELECT free.lane, free.in_flight
2501
+ FROM ${lanes} AS free
1534
2502
  CROSS JOIN LATERAL (
1535
- SELECT delivery.available_at FROM ${deliveries} AS delivery
1536
- WHERE delivery.lane = lane.lane AND delivery.status = 'pending'
1537
- AND delivery.available_at <= $1::timestamptz AND delivery.id <> ALL (lane.in_flight)
1538
- ORDER BY delivery.available_at LIMIT 1
1539
- ) AS waiting
1540
- UNION ALL
1541
- SELECT lane.lease_expires_at AS due_at
1542
- FROM ${lanes} AS lane
1543
- WHERE lane.lease_expires_at <= $1::timestamptz AND NOT ${this.#noRunningDelivery("lane")}
1544
- ) AS due`;
2503
+ SELECT delivery.available_at, delivery.id
2504
+ FROM ${deliveries} AS delivery
2505
+ WHERE delivery.lane = free.lane AND delivery.status = 'pending'
2506
+ AND delivery.available_at <= $1::timestamptz AND ${HANDLER_PREDICATE}
2507
+ ORDER BY delivery.available_at, delivery.id
2508
+ LIMIT 1
2509
+ ) AS oldest
2510
+ WHERE free.lease_expires_at IS NULL OR free.lease_expires_at <= $1::timestamptz
2511
+ OR (free.lease_owner = $4::text AND free.claim_floor < $6::bigint)
2512
+ ORDER BY oldest.available_at, oldest.id
2513
+ LIMIT $3::int
2514
+ FOR UPDATE OF free SKIP LOCKED
2515
+ ), charged AS (
2516
+ UPDATE ${deliveries} AS delivery SET attempts = delivery.attempts + 1, claims = delivery.claims + 1
2517
+ FROM (
2518
+ SELECT unnest(picked.in_flight) AS id FROM picked
2519
+ ) AS lost
2520
+ WHERE delivery.id = lost.id AND delivery.status = 'pending'
2521
+ RETURNING delivery.id
2522
+ ), taken AS (
2523
+ UPDATE ${lanes} AS lane
2524
+ SET epoch = lane.epoch + 1, lease_owner = $4::text, lease_expires_at = $5::timestamptz, in_flight = '{}',
2525
+ claim_floor = $6::bigint
2526
+ FROM picked WHERE lane.lane = picked.lane
2527
+ RETURNING lane.lane, lane.epoch
2528
+ )
2529
+ SELECT lane::int AS lane, epoch::text AS epoch FROM taken ORDER BY lane`;
2530
+ }
2531
+ };
2532
+
2533
+ // src/statements-purge.ts
2534
+ var PurgeStatements = class {
2535
+ #names;
2536
+ constructor(names) {
2537
+ this.#names = names;
1545
2538
  }
1546
2539
  /**
1547
2540
  * Deletes one bounded batch of purgeable deliveries (or of the completion
@@ -1550,25 +2543,22 @@ JOIN ${this.#names.deliveries} AS delivery ON delivery.id = flying.id AND delive
1550
2543
  * on, or being waited on by, anything.
1551
2544
  */
1552
2545
  purgeCompletedBatch() {
1553
- return this.#purgeBatch(
1554
- this.#names.deliveries,
1555
- `status = 'completed' AND completed_at < $1::timestamptz`,
1556
- "completed_at"
1557
- );
2546
+ return this.#purgeBatch(this.#names.deliveries, {
2547
+ condition: `status = 'completed' AND completed_at < $1::timestamptz`,
2548
+ orderColumn: "completed_at"
2549
+ });
1558
2550
  }
1559
2551
  purgeCompletionHistoryBatch() {
1560
- return this.#purgeBatch(
1561
- this.#names.completions,
1562
- `completed_at < $1::timestamptz`,
1563
- "completed_at"
1564
- );
2552
+ return this.#purgeBatch(this.#names.completions, {
2553
+ condition: `completed_at < $1::timestamptz`,
2554
+ orderColumn: "completed_at"
2555
+ });
1565
2556
  }
1566
2557
  purgeDeadBatch() {
1567
- return this.#purgeBatch(
1568
- this.#names.deliveries,
1569
- `status = 'dead' AND updated_at < $1::timestamptz`,
1570
- "updated_at"
1571
- );
2558
+ return this.#purgeBatch(this.#names.deliveries, {
2559
+ condition: `status = 'dead' AND updated_at < $1::timestamptz`,
2560
+ orderColumn: "updated_at"
2561
+ });
1572
2562
  }
1573
2563
  /**
1574
2564
  * Selects and locks one bounded batch of events created before the cutoff
@@ -1643,7 +2633,7 @@ WHERE event.id = ANY (ARRAY(SELECT jsonb_array_elements_text($1::jsonb)))
1643
2633
  const history = this.#names.completionHistory ? ` AND NOT EXISTS (SELECT 1 FROM ${this.#names.completions} AS done WHERE done.event_id = ${eventAlias}.id)` : "";
1644
2634
  return `NOT EXISTS (SELECT 1 FROM ${this.#names.deliveries} AS delivery WHERE delivery.event_id = ${eventAlias}.id)${history}`;
1645
2635
  }
1646
- #purgeBatch(table, condition, orderColumn) {
2636
+ #purgeBatch(table, { condition, orderColumn }) {
1647
2637
  return `WITH batch AS MATERIALIZED (
1648
2638
  SELECT id FROM ${table}
1649
2639
  WHERE ${condition}
@@ -1657,668 +2647,382 @@ SELECT event_id FROM deleted`;
1657
2647
  }
1658
2648
  };
1659
2649
 
1660
- // src/postgres-event-store.ts
1661
- var WAKE_UPS = ["notify", "poll"];
1662
- var DEFAULT_PURGE_BATCH_SIZE = 1e3;
1663
- var INITIAL_LANES_PER_ROUND = 4;
1664
- var MAXIMUM_STALE_CLAIM_ATTEMPTS = 3;
1665
- var NOTIFICATION_INTERVAL_MS = 10;
1666
- function handedOutKey(deliveryId, leaseToken) {
1667
- return `${deliveryId}\0${leaseToken}`;
2650
+ // src/statements-queries.ts
2651
+ var DEFAULT_LIST_LIMIT = 100;
2652
+ function bindListFilters(query) {
2653
+ const parameters = [];
2654
+ const bind = (value) => {
2655
+ parameters.push(value);
2656
+ return `$${parameters.length}::${typeof value === "number" ? "int" : "text"}`;
2657
+ };
2658
+ const filters = {
2659
+ handler: query.handler === void 0 ? void 0 : bind(query.handler),
2660
+ eventType: query.eventType === void 0 ? void 0 : bind(query.eventType),
2661
+ eventId: query.eventId === void 0 ? void 0 : bind(query.eventId),
2662
+ status: query.status === void 0 ? void 0 : bind(query.status)
2663
+ };
2664
+ const limit = bind(query.limit ?? DEFAULT_LIST_LIMIT);
2665
+ return { parameters, filters, limit };
1668
2666
  }
1669
- var PostgresEventStore = class {
1670
- #executor;
1671
- #statements;
1672
- #schemaStatements;
1673
- #setupLockKey;
1674
- #schemaMarker;
1675
- #purgeBatchSize;
1676
- #completionHistory;
1677
- #logger;
1678
- /** Passed with every statement whose text is fixed: asks the executor to prepare it (`preparedStatements`). */
1679
- #queryOptions;
1680
- #notifies;
1681
- /** Every claim handed to a caller and not settled yet, by delivery id and exact lease token. */
1682
- #handedOut = /* @__PURE__ */ new Map();
1683
- #unreconciled = /* @__PURE__ */ new Set();
1684
- #generations = /* @__PURE__ */ new Map();
1685
- #ownerQueues = /* @__PURE__ */ new Map();
1686
- /** Owners whose last claim was filled from the lanes they held. */
1687
- #drainingOwners = /* @__PURE__ */ new Set();
1688
- #ownedPool;
1689
- /** The `pg` pool behind the store, when it has one: listening for notifications needs a connection of its own. */
1690
- #pool;
1691
- #notificationChannel;
1692
- #lastNotificationAt = Number.NEGATIVE_INFINITY;
1693
- #trailingNotification;
1694
- /** Set by `close()`: a transaction that commits afterwards schedules no wake-up. */
1695
- #closed = false;
1696
- /** The transactions `transaction` runs right now, and whether one of their appends left its wake-up for after the commit. */
1697
- #ownTransactions = /* @__PURE__ */ new Map();
1698
- constructor(options) {
1699
- const sources = [options.pool, options.connectionString, options.executor].filter(
1700
- (source) => source !== void 0
1701
- );
1702
- if (sources.length !== 1) {
1703
- throw new core.ConfigurationError(
1704
- "PostgresEventStore needs exactly one of pool, connectionString or executor"
1705
- );
1706
- }
1707
- const purgeBatchSize = options.purgeBatchSize ?? DEFAULT_PURGE_BATCH_SIZE;
1708
- if (!Number.isInteger(purgeBatchSize) || purgeBatchSize < 1) {
1709
- throw new core.ConfigurationError(
1710
- `purgeBatchSize must be a positive integer, received ${String(purgeBatchSize)}`
1711
- );
1712
- }
1713
- const wakeUp = options.wakeUp ?? "notify";
1714
- if (!WAKE_UPS.includes(wakeUp)) {
1715
- throw new core.ConfigurationError(`wakeUp must be 'notify' or 'poll', received ${String(wakeUp)}`);
1716
- }
1717
- const preparedStatements = options.preparedStatements ?? true;
1718
- if (typeof preparedStatements !== "boolean") {
1719
- throw new core.ConfigurationError(
1720
- `preparedStatements must be a boolean, received ${String(preparedStatements)}`
1721
- );
1722
- }
1723
- this.#notifies = wakeUp === "notify";
1724
- this.#queryOptions = preparedStatements ? { prepare: true } : void 0;
1725
- const names = resolvePostgresNames(options);
1726
- this.#statements = new PostgresStatements(names);
1727
- this.#schemaStatements = createPostgresSchemaStatements(options);
1728
- this.#setupLockKey = this.#statements.setupLockKey;
1729
- this.#schemaMarker = postgresSchemaMarker(this.#schemaStatements.slice(0, -1));
1730
- this.#purgeBatchSize = purgeBatchSize;
1731
- this.#completionHistory = names.completionHistory;
1732
- this.#logger = options.logger ?? console;
1733
- this.#notificationChannel = names.notificationChannel;
1734
- if (options.executor !== void 0) {
1735
- this.#executor = options.executor;
1736
- this.#pool = void 0;
1737
- } else {
1738
- const pool = options.pool ?? new pg.Pool({ ...options.poolConfig, connectionString: options.connectionString });
1739
- if (options.pool === void 0) {
1740
- const logger = options.logger ?? console;
1741
- pool.on("error", (error) => {
1742
- logger.error(
1743
- `[PostgresEventStore](constructor) Idle connection failed, the pool replaces it | error=${error.message}`
1744
- );
1745
- });
1746
- this.#ownedPool = pool;
1747
- }
1748
- this.#pool = pool;
1749
- this.#executor = new PgExecutor(pool, {
1750
- logger: this.#logger
1751
- });
1752
- }
1753
- }
1754
- /**
1755
- * Creates the schema, tables and indexes if they do not exist. Safe to call
1756
- * from many processes at once: a transaction-scoped advisory lock serializes
1757
- * the DDL, so racing `CREATE TABLE IF NOT EXISTS` statements cannot collide.
1758
- * When the lane table already carries the marker of the current statements
1759
- * ({@link postgresSchemaMarker}) nothing runs and no table is locked, so instances
1760
- * that start next to running dispatchers cannot deadlock with their claims.
1761
- * Skip it (`setupStore: false`) when a migration tool applies
1762
- * `createPostgresSchemaSql()` instead.
1763
- */
1764
- async setup() {
1765
- if (await this.#schemaIsCurrent()) {
1766
- return;
1767
- }
1768
- await this.#executor.transaction(async (transaction) => {
1769
- await this.#executor.query(this.#statements.setupLock, [this.#setupLockKey], transaction);
1770
- if (await this.#schemaIsCurrent(transaction)) {
1771
- return;
1772
- }
1773
- for (const statement of this.#schemaStatements) {
1774
- await this.#executor.query(statement, [], transaction);
1775
- }
1776
- });
1777
- }
1778
- async #schemaIsCurrent(transaction) {
1779
- const rows = await this.#executor.query(
1780
- this.#statements.schemaCurrent,
1781
- [this.#schemaMarker],
1782
- transaction
1783
- );
1784
- return rows[0]["current"] === true;
1785
- }
1786
- async transaction(work) {
1787
- const own = { wake: false };
1788
- const result = await this.#executor.transaction(async (transaction) => {
1789
- this.#ownTransactions.set(transaction, own);
1790
- try {
1791
- return await work(transaction);
1792
- } finally {
1793
- this.#ownTransactions.delete(transaction);
1794
- }
1795
- });
1796
- if (own.wake) {
1797
- this.#wakeAfterCommit();
1798
- }
1799
- return result;
1800
- }
1801
- /**
1802
- * Writes the entries in `transaction`, normally with one statement
1803
- * ({@link PostgresStatements.insertFreshEvents}): keyed events reserve their sequence numbers (locking
1804
- * the key rows in sorted order, so publishers cannot deadlock inside one call), then the new events and
1805
- * their deliveries are inserted, ordered by primary key, and one notification wakes the dispatchers
1806
- * when a delivery is due now. Events that already existed get their missing handlers' deliveries in
1807
- * two more statements. Two transactions that append keys in opposite order across several calls can
1808
- * still deadlock (40P01); the database rolls one back and the caller retries.
1809
- * A duplicate event id still consumes a sequence number; gaps are harmless
1810
- * because only the order of sequences matters.
1811
- */
1812
- async append(transaction, entries, now) {
1813
- const plan = planAppend(entries);
1814
- if (plan.events.length === 0) {
1815
- return { appendedEventIds: [], duplicateEventIds: [], appendedDeliveryCount: 0 };
1816
- }
1817
- const createdAt = now.toISOString();
1818
- const wakeAt = this.#wakeAt(plan.deliveries, now, transaction);
1819
- const handlersByEvent = /* @__PURE__ */ new Map();
1820
- for (const delivery of plan.deliveries) {
1821
- handlersByEvent.set(delivery.event_id, [
1822
- ...handlersByEvent.get(delivery.event_id) ?? [],
1823
- delivery.handler
1824
- ]);
1825
- }
1826
- const keyCounts = /* @__PURE__ */ new Map();
1827
- for (const event of plan.events) {
1828
- if (event.key !== null) {
1829
- keyCounts.set(event.key, (keyCounts.get(event.key) ?? 0) + 1);
1830
- }
1831
- }
1832
- const fresh = await this.#query(
1833
- this.#statements.insertFreshEvents,
1834
- [
1835
- JSON.stringify(
1836
- plan.events.map((event, ordinal) => ({
1837
- ordinal,
1838
- ...toEventRow(event, null, handlersByEvent.get(event.id) ?? [])
1839
- }))
1840
- ),
1841
- createdAt,
1842
- JSON.stringify(plan.deliveries),
1843
- JSON.stringify([...keyCounts].map(([key, count]) => ({ key, count }))),
1844
- wakeAt
1845
- ],
1846
- transaction
1847
- );
1848
- const insertedEventIds = new Set(
1849
- fresh.filter((row) => row["inserted"] === true).map((row) => row["id"])
1850
- );
1851
- let appendedDeliveryCount = readCount(fresh[0], "deliveries");
1852
- const remaining = plan.deliveries.filter(
1853
- (delivery) => !insertedEventIds.has(delivery.event_id)
1854
- );
1855
- if (remaining.length > 0) {
1856
- appendedDeliveryCount += await this.#appendToExistingEvents(
1857
- transaction,
1858
- plan,
1859
- fresh,
1860
- remaining,
1861
- handlersByEvent,
1862
- insertedEventIds,
1863
- createdAt,
1864
- wakeAt
1865
- );
1866
- }
1867
- const appendedEventIds = [];
1868
- const duplicateEventIds = [];
1869
- for (const { eventId, repeated } of plan.outcomes) {
1870
- (!repeated && insertedEventIds.has(eventId) ? appendedEventIds : duplicateEventIds).push(
1871
- eventId
1872
- );
1873
- }
1874
- return { appendedEventIds, duplicateEventIds, appendedDeliveryCount };
1875
- }
1876
- /**
1877
- * The time the publishing statements notify the dispatchers for (deliveries due by then wake them),
1878
- * or `null` for no notification. An append in a transaction of the caller's always notifies, at its
1879
- * commit. In a transaction of this store, only the first append within {@link NOTIFICATION_INTERVAL_MS}
1880
- * does; the others leave the wake-up to {@link #wakeAfterCommit}, which runs once their transaction
1881
- * committed, so no committed delivery waits for the poll.
1882
- */
1883
- #wakeAt(deliveries, now, transaction) {
1884
- if (!this.#notifies || !deliveries.some((delivery) => Date.parse(delivery.available_at) <= now.getTime())) {
1885
- return null;
1886
- }
1887
- const own = this.#ownTransactions.get(transaction);
1888
- if (own !== void 0 && Date.now() - this.#lastNotificationAt < NOTIFICATION_INTERVAL_MS) {
1889
- own.wake = true;
1890
- return null;
1891
- }
1892
- this.#lastNotificationAt = Date.now();
1893
- return now.toISOString();
1894
- }
1895
- /**
1896
- * Wakes the dispatchers for a committed transaction whose appends sent no notification: at once when
1897
- * none was sent within {@link NOTIFICATION_INTERVAL_MS}, otherwise once when the interval ends. A
1898
- * wake-up already scheduled covers this transaction too, since it is sent after this commit. Nothing is
1899
- * sent once the store is closed or the `pg` pool behind it is ending: no dispatcher can be woken through
1900
- * it then, and the deliveries are found by polling.
1901
- */
1902
- #wakeAfterCommit() {
1903
- if (this.#trailingNotification !== void 0 || this.#closed) {
1904
- return;
1905
- }
1906
- this.#trailingNotification = setTimeout(
1907
- () => {
1908
- this.#trailingNotification = void 0;
1909
- if (this.#pool?.ending === true) {
1910
- return;
1911
- }
1912
- this.#lastNotificationAt = Date.now();
1913
- this.#query(this.#statements.notify, []).catch((error) => {
1914
- this.#logger.error(
1915
- `[PostgresEventStore](wakeAfterCommit) Waking the dispatchers failed, they find the deliveries when they poll | error=${error instanceof Error ? error.message : String(error)}`
1916
- );
1917
- });
1918
- },
1919
- Math.max(0, this.#lastNotificationAt + NOTIFICATION_INTERVAL_MS - Date.now())
1920
- );
2667
+ function listConditions(filters, alias) {
2668
+ return [
2669
+ ...filters.handler === void 0 ? [] : [`${alias}.handler = ${filters.handler}`],
2670
+ ...filters.eventType === void 0 ? [] : [`event.type = ${filters.eventType}`],
2671
+ ...filters.eventId === void 0 ? [] : [`${alias}.event_id = ${filters.eventId}`]
2672
+ ];
2673
+ }
2674
+ var QueryStatements = class {
2675
+ #names;
2676
+ constructor(names) {
2677
+ this.#names = names;
1921
2678
  }
1922
- /**
1923
- * Adds the deliveries of events the publishing statement found already stored. An event that already
1924
- * exists may be getting the same new handler from another transaction at this very moment. Whoever
1925
- * holds the event decides, so the freshness check of the insert sees what the other transaction
1926
- * committed, even if its delivery has completed since. An existing event that a purge removed
1927
- * meanwhile is inserted again (with the sequence reserved for it) instead of being skipped; its id is
1928
- * added to `insertedEventIds`. Returns how many deliveries were inserted.
1929
- */
1930
- async #appendToExistingEvents(transaction, plan, fresh, deliveries, handlersByEvent, insertedEventIds, createdAt, wakeAt) {
1931
- const sequences = new Map(
1932
- fresh.map((row) => [row["id"], row["sequence"]])
1933
- );
1934
- const reinserted = [];
1935
- let existing = plan.events.filter((event) => !insertedEventIds.has(event.id)).map(
1936
- (event) => toEventRow(event, sequences.get(event.id), handlersByEvent.get(event.id) ?? [])
1937
- );
1938
- while (existing.length > 0) {
1939
- const locked = new Set(
1940
- (await this.#query(
1941
- this.#statements.lockExistingEvents,
1942
- [JSON.stringify(existing.map((row) => row.id).sort())],
1943
- transaction
1944
- )).map((row) => row["id"])
1945
- );
1946
- const gone = existing.filter((row) => !locked.has(row.id));
1947
- if (gone.length > 0) {
1948
- const inserted2 = await this.#query(
1949
- this.#statements.insertEvents,
1950
- [JSON.stringify(gone), createdAt],
1951
- transaction
1952
- );
1953
- for (const row of inserted2) {
1954
- insertedEventIds.add(row["id"]);
1955
- reinserted.push(row["id"]);
1956
- }
1957
- }
1958
- existing = gone.filter((row) => !insertedEventIds.has(row.id));
1959
- }
1960
- const inserted = await this.#query(
1961
- this.#statements.insertDeliveries,
1962
- [JSON.stringify(deliveries), createdAt, JSON.stringify(reinserted), wakeAt],
1963
- transaction
1964
- );
1965
- return inserted.length;
2679
+ /** Parameters: `[deliveryIdsJson, now]`. The dead rows are locked in id order first. */
2680
+ get requeue() {
2681
+ const deliveries = this.#names.deliveries;
2682
+ return countChanged(`WITH dead AS MATERIALIZED (
2683
+ SELECT delivery.id FROM ${deliveries} AS delivery
2684
+ WHERE delivery.id = ANY (ARRAY(SELECT jsonb_array_elements_text($1::jsonb))) AND delivery.status = 'dead'
2685
+ ORDER BY delivery.id
2686
+ FOR NO KEY UPDATE
2687
+ )
2688
+ UPDATE ${deliveries} AS delivery
2689
+ SET status = 'pending', attempts = 0, available_at = $2::timestamptz, updated_at = $2::timestamptz
2690
+ FROM dead
2691
+ WHERE delivery.id = dead.id AND delivery.status = 'dead'`);
1966
2692
  }
1967
2693
  /**
1968
- * Claims up to `limit` due deliveries, normally with one statement
1969
- * ({@link PostgresStatements.claimLanes}): from the lanes this owner already holds, then from free lanes
1970
- * it takes in the same statement, recording the batch in the lane rows. A claim that would have to take
1971
- * over a lane whose previous holder still runs deliveries under an expired lease continues in a
1972
- * transaction that charges their lost attempts first, taking free lanes a few at a time (4, 8, 16, ...);
1973
- * so does a claim whose statement kept seeing a lane it holds change under it, and one that came up
1974
- * short with more free lanes left than it looked at. The step that fails after
1975
- * the first one has recorded deliveries returns those instead of failing: a recorded delivery nobody
1976
- * received would be held for a whole lease. See {@link PostgresStatements.claim} for the rules a delivery
1977
- * must satisfy.
2694
+ * Builds the filtered, creation-ordered listing. A delivery is `processing`
2695
+ * while its row exists as `pending` and its lane lists its id; its attempt
2696
+ * count, lease owner and lease expiry then come from the claim and the lane.
2697
+ * Records of the completion history (when kept) are listed as `completed`.
1978
2698
  */
1979
- claim(request) {
1980
- if (request.handlers.length === 0 || !(request.limit > 0)) {
1981
- return Promise.resolve([]);
2699
+ listDeliveries(query) {
2700
+ const { parameters, filters, limit } = bindListFilters(query);
2701
+ const current = this.#currentDeliveries(filters);
2702
+ const includeHistory = this.#names.completionHistory && (query.status === void 0 || query.status === "completed");
2703
+ if (!includeHistory) {
2704
+ return {
2705
+ sql: `${current}
2706
+ ORDER BY delivery.created_at, delivery.id
2707
+ LIMIT ${limit}`,
2708
+ parameters
2709
+ };
1982
2710
  }
1983
- return this.#serialized(request.leaseOwner, () => this.#claim(request));
2711
+ const history = this.#completionHistory(filters);
2712
+ return {
2713
+ sql: `SELECT * FROM (
2714
+ ${current}
2715
+ UNION ALL
2716
+ ${history}
2717
+ ) AS listed
2718
+ ORDER BY created_at, delivery_id
2719
+ LIMIT ${limit}`,
2720
+ parameters
2721
+ };
2722
+ }
2723
+ /** The deliveries that still exist, with a running one reported as `processing`. */
2724
+ #currentDeliveries(filters) {
2725
+ const inFlight = `(delivery.status = 'pending' AND delivery.id = ANY (lane.in_flight))`;
2726
+ const whereCurrent = [
2727
+ ...listConditions(filters, "delivery"),
2728
+ ...filters.status === void 0 ? [] : [
2729
+ `(CASE WHEN ${inFlight} THEN 'processing' ELSE delivery.status END) = ${filters.status}`
2730
+ ]
2731
+ ];
2732
+ return `SELECT delivery.id AS delivery_id, delivery.handler,
2733
+ CASE WHEN ${inFlight} THEN 'processing' ELSE delivery.status END AS status,
2734
+ (delivery.attempts + CASE WHEN ${inFlight} THEN 1 ELSE 0 END)::int AS attempts,
2735
+ ${isoText("delivery.available_at")} AS available_at,
2736
+ CASE WHEN ${inFlight} THEN lane.lease_owner END AS lease_owner,
2737
+ CASE WHEN ${inFlight} THEN ${isoText("lane.lease_expires_at")} END AS lease_expires_at,
2738
+ delivery.last_error::text AS last_error,
2739
+ ${isoText("delivery.created_at")} AS created_at,
2740
+ ${isoText("delivery.updated_at")} AS updated_at,
2741
+ ${isoText("delivery.completed_at")} AS completed_at,
2742
+ ${eventColumns("event")}
2743
+ FROM ${this.#names.deliveries} AS delivery
2744
+ JOIN ${this.#names.lanes} AS lane ON lane.lane = delivery.lane
2745
+ JOIN ${this.#names.events} AS event ON event.id = delivery.event_id
2746
+ ${whereCurrent.length === 0 ? "" : `WHERE ${whereCurrent.join(" AND ")}`}`;
2747
+ }
2748
+ /** The completion history records, listed as `completed`. */
2749
+ #completionHistory(filters) {
2750
+ const common = (alias) => listConditions(filters, alias);
2751
+ return `SELECT done.id AS delivery_id, done.handler, 'completed' AS status, done.attempts,
2752
+ ${isoText("done.available_at")} AS available_at,
2753
+ NULL AS lease_owner,
2754
+ NULL AS lease_expires_at,
2755
+ NULL AS last_error,
2756
+ ${isoText("done.created_at")} AS created_at,
2757
+ ${isoText("done.completed_at")} AS updated_at,
2758
+ ${isoText("done.completed_at")} AS completed_at,
2759
+ ${eventColumns("event")}
2760
+ FROM ${this.#names.completions} AS done
2761
+ JOIN ${this.#names.events} AS event ON event.id = done.event_id
2762
+ ${common("done").length === 0 ? "" : `WHERE ${common("done").join(" AND ")}`}`;
1984
2763
  }
1985
2764
  /**
1986
- * Runs `work` after the earlier claims of the same owner on this store settled. The
1987
- * bookkeeping of what was handed out and the reconciliation of a failed claim need
1988
- * one claim at a time per owner (the dispatcher claims one batch at a time anyway;
1989
- * `runOnce` next to a running dispatcher is what this guards against).
2765
+ * Counts of rows by stored status (`pending` includes running deliveries;
2766
+ * `completed` rows exist only after an upgrade from 0.1.0). Each count is
2767
+ * answered from a partial index.
1990
2768
  */
1991
- #serialized(owner, work) {
1992
- const previous = this.#ownerQueues.get(owner) ?? Promise.resolve();
1993
- const run = previous.then(work, work);
1994
- const tail = run.then(
1995
- () => void 0,
1996
- () => void 0
1997
- );
1998
- this.#ownerQueues.set(owner, tail);
1999
- void tail.then(() => {
2000
- if (this.#ownerQueues.get(owner) === tail) {
2001
- this.#ownerQueues.delete(owner);
2002
- }
2003
- });
2004
- return run;
2005
- }
2006
- async #claim(request) {
2007
- if (this.#unreconciled.has(request.leaseOwner) && !await this.#reconcile(request.leaseOwner)) {
2008
- throw new Error(
2009
- `[PostgresEventStore](claim) Giving back unreturned deliveries still fails, so no claim is made | owner=${request.leaseOwner}`
2010
- );
2011
- }
2012
- const heldFirst = this.#drainingOwners.has(request.leaseOwner);
2013
- let rows;
2014
- try {
2015
- rows = await this.#claimInOneStatement(
2016
- request,
2017
- heldFirst ? this.#statements.claimHeldLanes : this.#statements.claimLanes
2018
- );
2019
- } catch (error) {
2020
- await this.#reconcile(request.leaseOwner);
2021
- throw error;
2022
- }
2023
- const heldLanes = new Set(JSON.parse(rows[0]["examined_lanes"]));
2024
- const claimed = this.#takeClaimed(rows, request.leaseOwner);
2025
- if (heldFirst && claimed.length < request.limit && rows[0]["stale"] !== true) {
2026
- try {
2027
- rows = await this.#claimInOneStatement(
2028
- { ...request, limit: request.limit - claimed.length },
2029
- this.#statements.claimLanes
2030
- );
2031
- } catch (error) {
2032
- await this.#reconcile(request.leaseOwner);
2033
- if (claimed.length === 0) {
2034
- this.#drainingOwners.delete(request.leaseOwner);
2035
- throw error;
2036
- }
2037
- this.#logger.error(
2038
- `[PostgresEventStore](claim) Claiming from free lanes failed, returning the deliveries already claimed | claimed=${claimed.length} | error=${error instanceof Error ? error.message : String(error)}`
2039
- );
2040
- return this.#handOutInDueOrder(claimed, request, heldLanes);
2041
- }
2042
- claimed.push(...this.#takeClaimed(rows, request.leaseOwner));
2043
- }
2044
- if (claimed.length < request.limit && (rows[0]["takeover_waiting"] === true || rows[0]["unexamined_free_lanes"] === true || rows[0]["stale"] === true)) {
2045
- try {
2046
- const more = await this.#claimMore(request, claimed.length);
2047
- this.#handOut(more, request.leaseOwner);
2048
- claimed.push(...more);
2049
- } catch (error) {
2050
- await this.#reconcile(request.leaseOwner);
2051
- if (claimed.length === 0) {
2052
- throw error;
2053
- }
2054
- this.#logger.error(
2055
- `[PostgresEventStore](claim) Taking more lanes failed, returning the deliveries already claimed | claimed=${claimed.length} | error=${error instanceof Error ? error.message : String(error)}`
2056
- );
2057
- }
2058
- }
2059
- return this.#handOutInDueOrder(claimed, request, heldLanes);
2769
+ get countByStatus() {
2770
+ const deliveries = this.#names.deliveries;
2771
+ return ["pending", "dead", "completed"].map(
2772
+ (status) => `SELECT '${status}' AS status, count(*)::text AS count FROM ${deliveries} WHERE status = '${status}'`
2773
+ ).join("\nUNION ALL\n");
2060
2774
  }
2061
- /** The deliveries a claim statement picked, registered as handed out to `leaseOwner`. */
2062
- #takeClaimed(rows, leaseOwner) {
2063
- const claimed = rows.filter((row) => row["delivery_id"] !== null);
2064
- this.#forgetFinished(rows[0], leaseOwner, claimed);
2065
- this.#handOut(claimed, leaseOwner);
2066
- return claimed;
2775
+ /** Deliveries that are running: listed by a lane and still pending as rows. */
2776
+ get countRunning() {
2777
+ return `SELECT count(*)::text AS count
2778
+ FROM ${this.#names.lanes} AS lane
2779
+ CROSS JOIN LATERAL unnest(lane.in_flight) AS flying (id)
2780
+ JOIN ${this.#names.deliveries} AS delivery ON delivery.id = flying.id AND delivery.status = 'pending'`;
2067
2781
  }
2068
- #handOutInDueOrder(claimed, request, heldLanes) {
2069
- if (claimed.length >= request.limit && claimed.every((row) => heldLanes.has(readCount(row, "lane")))) {
2070
- this.#drainingOwners.add(request.leaseOwner);
2071
- } else {
2072
- this.#drainingOwners.delete(request.leaseOwner);
2073
- }
2074
- claimed.sort(compareDue);
2075
- return claimed.map((row) => mapClaimedDelivery(row, request.leaseExpiresAt));
2782
+ get countCompletionHistory() {
2783
+ return `SELECT count(*)::text AS count FROM ${this.#names.completions}`;
2076
2784
  }
2077
2785
  /**
2078
- * Runs `statement` ({@link PostgresStatements.claimLanes} or its held-lane form), again when it reports that it waited for a lane lock and
2079
- * read deliveries with an older snapshot than the lane it got. A held lane is only locked by an
2080
- * operation of its own owner (another claim, a reschedule), so a few attempts are enough.
2786
+ * Oldest instant at which a delivery became claimable: `available_at` of a
2787
+ * pending delivery that nobody runs, or the lease expiry of a lane that runs
2788
+ * deliveries. Parameters: `[now]`.
2081
2789
  */
2082
- async #claimInOneStatement(request, statement) {
2083
- const parameters = [
2084
- request.now.toISOString(),
2085
- JSON.stringify(request.handlers),
2086
- request.limit,
2087
- request.leaseOwner,
2088
- request.leaseExpiresAt.toISOString(),
2089
- this.#generation(request.leaseOwner).toString()
2090
- ];
2091
- let rows = await this.#query(statement, parameters);
2092
- for (let attempt = 1; attempt < MAXIMUM_STALE_CLAIM_ATTEMPTS && rows[0]["stale"] === true; attempt += 1) {
2093
- rows = await this.#query(statement, parameters);
2094
- }
2095
- return rows;
2790
+ get oldestDue() {
2791
+ const deliveries = this.#names.deliveries;
2792
+ const lanes = this.#names.lanes;
2793
+ return `SELECT ${isoText("min(due.due_at)")} AS oldest_due_at FROM (
2794
+ SELECT waiting.available_at AS due_at
2795
+ FROM ${lanes} AS lane
2796
+ CROSS JOIN LATERAL (
2797
+ SELECT delivery.available_at FROM ${deliveries} AS delivery
2798
+ WHERE delivery.lane = lane.lane AND delivery.status = 'pending'
2799
+ AND delivery.available_at <= $1::timestamptz AND delivery.id <> ALL (lane.in_flight)
2800
+ ORDER BY delivery.available_at LIMIT 1
2801
+ ) AS waiting
2802
+ UNION ALL
2803
+ SELECT lane.lease_expires_at AS due_at
2804
+ FROM ${lanes} AS lane
2805
+ WHERE lane.lease_expires_at <= $1::timestamptz AND NOT ${noRunningDelivery(this.#names, "lane")}
2806
+ ) AS due`;
2807
+ }
2808
+ };
2809
+
2810
+ // src/statements-setup.ts
2811
+ var SetupStatements = class {
2812
+ #names;
2813
+ constructor(names) {
2814
+ this.#names = names;
2096
2815
  }
2097
2816
  /**
2098
- * Second step of {@link PostgresEventStore.claim}, in one transaction: takes free lanes a few at a
2099
- * time, lanes to take over included (their lost attempts are charged first), and records the batch.
2100
- * `alreadyClaimed` deliveries of the first step are in flight, so they are not picked again.
2817
+ * Lock advisory key text for {@link setupLock}. It is derived from the schema
2818
+ * name only, so stores with different table prefixes in one schema also
2819
+ * serialize their `CREATE SCHEMA IF NOT EXISTS`, which is not race-free.
2101
2820
  */
2102
- async #claimMore(request, alreadyClaimed) {
2103
- const now = request.now.toISOString();
2104
- const leaseExpiresAt = request.leaseExpiresAt.toISOString();
2105
- const handlers = JSON.stringify(request.handlers);
2106
- const generation = this.#generation(request.leaseOwner).toString();
2107
- return this.#executor.transaction(async (transaction) => {
2108
- const claimed = [];
2109
- const remaining = () => request.limit - alreadyClaimed - claimed.length;
2110
- const takenLanes = /* @__PURE__ */ new Set();
2111
- for (let laneCount = INITIAL_LANES_PER_ROUND; remaining() > 0; ) {
2112
- const taken = await this.#query(
2113
- this.#statements.takeFreeLanes,
2114
- [now, handlers, laneCount, request.leaseOwner, leaseExpiresAt, generation],
2115
- transaction
2116
- );
2117
- if (taken.length === 0) {
2118
- break;
2119
- }
2120
- const lanes = taken.map((row) => readCount(row, "lane"));
2121
- for (const lane of lanes) {
2122
- takenLanes.add(lane);
2123
- }
2124
- claimed.push(
2125
- ...await this.#pickDeliveries(
2126
- [now, handlers, remaining(), JSON.stringify(lanes)],
2127
- transaction
2128
- )
2129
- );
2130
- laneCount *= 2;
2131
- }
2132
- await this.#recordClaim(claimed, takenLanes, request, transaction);
2133
- return claimed;
2134
- });
2821
+ get setupLockKey() {
2822
+ return `relaybus:${this.#names.schema}`;
2135
2823
  }
2136
2824
  /**
2137
- * The generation of the owner's claim statements. It only grows, also across restarts (it starts at the
2138
- * clock), and a reconciliation moves it up, which fences off every earlier statement still in flight.
2825
+ * Whether the lane table carries `marker` as its comment, that is whether the schema statements
2826
+ * that produce that marker already ran. Takes no table lock. Parameters: `[marker]`.
2139
2827
  */
2140
- #generation(owner, advance = false) {
2141
- const current = this.#generations.get(owner) ?? BigInt(Date.now());
2142
- const next = advance ? current + 1n > BigInt(Date.now()) ? current + 1n : BigInt(Date.now()) : current;
2143
- this.#generations.set(owner, next);
2144
- return next;
2828
+ get schemaCurrent() {
2829
+ return `SELECT EXISTS (
2830
+ SELECT 1 FROM pg_catalog.pg_class AS c
2831
+ JOIN pg_catalog.pg_namespace AS n ON n.oid = c.relnamespace
2832
+ JOIN pg_catalog.pg_description AS d
2833
+ ON d.objoid = c.oid AND d.classoid = 'pg_catalog.pg_class'::regclass AND d.objsubid = 0
2834
+ WHERE n.nspname = '${this.#names.schema}' AND c.relname = '${this.#names.lanesTableName}' AND d.description = $1::text
2835
+ ) AS current`;
2145
2836
  }
2146
- /** Forgets a handed-out lease once the store answered about it (any answer: the claim is over or fenced out). */
2147
- async #settled(lease, work) {
2148
- const result = await work;
2149
- this.#handedOut.delete(handedOutKey(lease.deliveryId, lease.leaseToken));
2150
- return result;
2837
+ get setupLock() {
2838
+ return "SELECT pg_advisory_xact_lock(hashtextextended($1::text, 0))";
2151
2839
  }
2152
- /** Remembers the claims handed to the caller of `claim` until they are settled. */
2153
- #handOut(rows, leaseOwner) {
2154
- for (const row of rows) {
2155
- const claim = mapClaimedDelivery(row, /* @__PURE__ */ new Date(0));
2156
- this.#handedOut.set(handedOutKey(claim.deliveryId, claim.leaseToken), {
2157
- owner: leaseOwner,
2158
- deliveryId: claim.deliveryId,
2159
- ...parseLeaseToken(claim.leaseToken)
2160
- });
2840
+ };
2841
+
2842
+ // src/statements.ts
2843
+ var PostgresStatements = class {
2844
+ setup;
2845
+ append;
2846
+ lanes;
2847
+ claim;
2848
+ completion;
2849
+ queries;
2850
+ purge;
2851
+ constructor(names) {
2852
+ this.setup = new SetupStatements(names);
2853
+ this.append = new AppendStatements(names);
2854
+ this.lanes = new LaneStatements(names);
2855
+ this.claim = new ClaimStatements(names);
2856
+ this.completion = new CompletionStatements(names);
2857
+ this.queries = new QueryStatements(names);
2858
+ this.purge = new PurgeStatements(names);
2859
+ }
2860
+ };
2861
+
2862
+ // src/postgres-event-store.ts
2863
+ var PostgresEventStore = class {
2864
+ #state;
2865
+ #appender;
2866
+ #claims;
2867
+ #completion;
2868
+ #maintenance;
2869
+ #owned = new OwnedConnections();
2870
+ constructor(options) {
2871
+ const { purgeBatchSize, wakeUp, queryOptions } = resolveStoreSettings(options);
2872
+ const names = resolvePostgresNames(options);
2873
+ const statements = new PostgresStatements(names);
2874
+ const schemaStatements = createPostgresSchemaStatements(options);
2875
+ const logger = options.logger ?? console;
2876
+ const { executor, pool, ownedPool } = connect(options, { logger, owned: this.#owned });
2877
+ const state = {
2878
+ executor,
2879
+ statements,
2880
+ schemaStatements,
2881
+ setupLockKey: statements.setup.setupLockKey,
2882
+ schemaMarker: postgresSchemaMarker(schemaStatements.slice(0, -1)),
2883
+ purgeBatchSize,
2884
+ completionHistory: names.completionHistory,
2885
+ logger,
2886
+ queryOptions,
2887
+ notifies: wakeUp === "notify",
2888
+ query: ({ sql, parameters, transaction }) => executor.query(sql, parameters, transaction, queryOptions),
2889
+ handedOut: new internal.HandedOutLedger(),
2890
+ unreconciled: /* @__PURE__ */ new Set(),
2891
+ generations: /* @__PURE__ */ new Map(),
2892
+ drainingOwners: /* @__PURE__ */ new Set(),
2893
+ ownedPool,
2894
+ pool,
2895
+ notificationChannel: names.notificationChannel,
2896
+ lastNotificationAt: Number.NEGATIVE_INFINITY,
2897
+ trailingNotification: void 0,
2898
+ closed: false,
2899
+ ownTransactions: /* @__PURE__ */ new Map()
2900
+ };
2901
+ this.#state = state;
2902
+ this.#appender = new PostgresAppend(state);
2903
+ this.#claims = new PostgresClaims(state);
2904
+ this.#completion = new PostgresCompletion(state);
2905
+ this.#maintenance = new PostgresMaintenance(state);
2906
+ }
2907
+ /**
2908
+ * Creates the schema, tables and indexes if they do not exist. Safe to call
2909
+ * from many processes at once: a transaction-scoped advisory lock serializes
2910
+ * the DDL, so racing `CREATE TABLE IF NOT EXISTS` statements cannot collide.
2911
+ * When the lane table already carries the marker of the current statements
2912
+ * ({@link postgresSchemaMarker}) nothing runs and no table is locked, so instances
2913
+ * that start next to running dispatchers cannot deadlock with their claims.
2914
+ * Skip it (`setupStore: false`) when a migration tool applies
2915
+ * `createPostgresSchemaSql()` instead.
2916
+ */
2917
+ async setup() {
2918
+ if (await this.#schemaIsCurrent()) {
2919
+ return;
2161
2920
  }
2921
+ await this.#state.executor.transaction(async (transaction) => {
2922
+ await this.#state.executor.query(
2923
+ this.#state.statements.setup.setupLock,
2924
+ [this.#state.setupLockKey],
2925
+ transaction
2926
+ );
2927
+ if (await this.#schemaIsCurrent(transaction)) {
2928
+ return;
2929
+ }
2930
+ for (const statement of this.#state.schemaStatements) {
2931
+ await this.#state.executor.query(statement, [], transaction);
2932
+ }
2933
+ });
2934
+ }
2935
+ async #schemaIsCurrent(transaction) {
2936
+ const rows = await this.#state.executor.query(
2937
+ this.#state.statements.setup.schemaCurrent,
2938
+ [this.#state.schemaMarker],
2939
+ transaction
2940
+ );
2941
+ return rows[0]["current"] === true;
2162
2942
  }
2163
2943
  /**
2164
- * Drops the registrations of the owner's held lanes whose delivery no longer runs there
2165
- * (completed inside the caller's transaction, deleted, or listed no more). Registrations of
2166
- * lanes this claim did not see stay: their tokens may still be valid.
2944
+ * Runs `work` in a transaction of the executor. With `options.timeoutMs` (from `bus.transaction`)
2945
+ * the server holds the transaction to the budget: `statement_timeout`, and for executors with
2946
+ * `sessionTimeouts` (`pg`) also `idle_in_transaction_session_timeout` and, on PostgreSQL 17+,
2947
+ * `transaction_timeout`, all scoped to the transaction. An executor with `transactionWithBudget`
2948
+ * (`PgExecutor`) sets them with `BEGIN`, so the budget costs no round trip; on a pool the store
2949
+ * created they are kept per connection and sent only when the connection does not have them yet.
2167
2950
  */
2168
- #forgetFinished(row, leaseOwner, claimed) {
2169
- const heldLanes = new Set(JSON.parse(row["examined_lanes"]));
2170
- const running = /* @__PURE__ */ new Set([
2171
- ...JSON.parse(row["running_ids"]),
2172
- ...claimed.map((picked) => picked["delivery_id"])
2173
- ]);
2174
- for (const [key, entry] of this.#handedOut) {
2175
- if (entry.owner === leaseOwner && heldLanes.has(entry.lane) && !running.has(entry.deliveryId)) {
2176
- this.#handedOut.delete(key);
2951
+ async transaction(work, options) {
2952
+ const own = { wake: false };
2953
+ const tracked = async (transaction) => {
2954
+ this.#state.ownTransactions.set(transaction, own);
2955
+ try {
2956
+ return await work(transaction);
2957
+ } finally {
2958
+ this.#state.ownTransactions.delete(transaction);
2177
2959
  }
2960
+ };
2961
+ const result = await (options === void 0 ? this.#state.executor.transaction(tracked) : inBudgetedTransaction(this.#state, { work: tracked, timeoutMs: options.timeoutMs }));
2962
+ if (own.wake) {
2963
+ this.#appender.wakeAfterCommit();
2178
2964
  }
2965
+ return result;
2179
2966
  }
2180
2967
  /**
2181
- * After a claim failed, possibly after the database recorded its batch: gives back the recorded
2182
- * deliveries that were never handed out, keeping exactly the claims handed out and not settled.
2183
- * A failure is remembered and retried before the owner's next claim: other deliveries keep
2184
- * renewing the lane, so an unreturned one would otherwise never be recovered.
2968
+ * Writes the entries in `transaction`, normally with one statement
2969
+ * (`AppendStatements.insertFreshEvents`): keyed events reserve their sequence numbers (locking
2970
+ * the key rows in sorted order, so publishers cannot deadlock inside one call), then the new events and
2971
+ * their deliveries are inserted, ordered by primary key, and one notification wakes the dispatchers
2972
+ * when a delivery is due now. Events that already existed get their missing handlers' deliveries in
2973
+ * two more statements. Two transactions that append keys in opposite order across several calls can
2974
+ * still deadlock (40P01); the database rolls one back and the caller retries.
2975
+ * A duplicate event id still consumes a sequence number; gaps are harmless
2976
+ * because only the order of sequences matters.
2185
2977
  */
2186
- async #reconcile(leaseOwner) {
2187
- const generation = this.#generation(leaseOwner, true);
2188
- const handedOut = [...this.#handedOut.values()].filter((entry) => entry.owner === leaseOwner).map(({ deliveryId, lane, epoch, claims }) => ({ id: deliveryId, lane, epoch, claims }));
2189
- try {
2190
- await this.#query(this.#statements.reconcileClaims, [
2191
- leaseOwner,
2192
- JSON.stringify(handedOut),
2193
- generation.toString()
2194
- ]);
2195
- this.#unreconciled.delete(leaseOwner);
2196
- return true;
2197
- } catch (error) {
2198
- this.#unreconciled.add(leaseOwner);
2199
- this.#logger.error(
2200
- `[PostgresEventStore](reconcile) Giving back unreturned deliveries failed, it is retried before the next claim | owner=${leaseOwner} | error=${error instanceof Error ? error.message : String(error)}`
2201
- );
2202
- return false;
2203
- }
2204
- }
2205
- /** Fast claim, then the slow one when the fast window was full of blocked followers. */
2206
- async #pickDeliveries(parameters, transaction) {
2207
- const limit = parameters[2];
2208
- const fastRows = await this.#query(this.#statements.claim, parameters, transaction);
2209
- const picked = fastRows.filter((row) => row["delivery_id"] !== null);
2210
- if (picked.length < limit && fastRows[0]["window_saturated"] === true) {
2211
- const slowRows = await this.#query(
2212
- this.#statements.claimBehindBlockedKeys,
2213
- parameters,
2214
- transaction
2215
- );
2216
- const pickedIds = new Set(picked.map((row) => row["delivery_id"]));
2217
- picked.push(
2218
- ...slowRows.filter(
2219
- (row) => row["delivery_id"] !== null && !pickedIds.has(row["delivery_id"])
2220
- )
2221
- );
2222
- const seenKeys = /* @__PURE__ */ new Set();
2223
- const kept = [...picked].sort((left, right) => compareSequence(left, right)).filter((row) => {
2224
- if (row["delivery_key"] === null) {
2225
- return true;
2226
- }
2227
- const group = `${row["handler"]}\0${row["delivery_key"]}`;
2228
- return !seenKeys.has(group) && Boolean(seenKeys.add(group));
2229
- });
2230
- kept.sort(compareDue);
2231
- return kept.slice(0, limit);
2232
- }
2233
- return picked;
2978
+ // eslint-disable-next-line relaybus/max-two-params -- EventStore port signature
2979
+ append(transaction, entries, now) {
2980
+ return this.#appender.append(transaction, { entries, now });
2234
2981
  }
2235
- /** Writes the picked ids into their lane rows and frees the lanes that picked nothing. */
2236
- async #recordClaim(claimed, lanes, request, transaction) {
2237
- const idsByLane = /* @__PURE__ */ new Map();
2238
- for (const row of claimed) {
2239
- const lane = readCount(row, "lane");
2240
- idsByLane.set(lane, [...idsByLane.get(lane) ?? [], row["delivery_id"]]);
2241
- }
2242
- const idleLanes = [...lanes].filter((lane) => !idsByLane.has(lane));
2243
- if (idsByLane.size === 0 && idleLanes.length === 0) {
2244
- return;
2245
- }
2246
- await this.#query(
2247
- this.#statements.recordClaim,
2248
- [
2249
- JSON.stringify([...idsByLane].map(([lane, ids]) => ({ lane, ids }))),
2250
- JSON.stringify(idleLanes),
2251
- request.leaseOwner,
2252
- request.leaseExpiresAt.toISOString()
2253
- ],
2254
- transaction
2255
- );
2982
+ /**
2983
+ * Claims up to `limit` due deliveries, normally with one statement
2984
+ * (`LaneStatements.claimLanes`): from the lanes this owner already holds, then from free lanes
2985
+ * it takes in the same statement, recording the batch in the lane rows. A claim that would have to take
2986
+ * over a lane whose previous holder still runs deliveries under an expired lease continues in a
2987
+ * transaction that charges their lost attempts first, taking free lanes a few at a time (4, 8, 16, ...);
2988
+ * so does a claim whose statement kept seeing a lane it holds change under it, and one that came up
2989
+ * short with more free lanes left than it looked at. The step that fails after
2990
+ * the first one has recorded deliveries returns those instead of failing: a recorded delivery nobody
2991
+ * received would be held for a whole lease. See `ClaimStatements.claim` for the rules a delivery
2992
+ * must satisfy.
2993
+ */
2994
+ claim(request) {
2995
+ return this.#claims.claim(request);
2256
2996
  }
2257
2997
  extendLease(lease, leaseExpiresAt) {
2258
- return this.#whenIssued(
2259
- lease,
2260
- (held) => this.#changed(this.#statements.extendLease, [
2261
- lease.deliveryId,
2262
- held.lane,
2263
- held.epoch,
2264
- held.claims,
2265
- leaseExpiresAt.toISOString()
2266
- ])
2267
- );
2998
+ return Promise.resolve(this.#completion.extendLease({ lease, leaseExpiresAt }));
2268
2999
  }
2269
- async complete(lease, now, transaction) {
2270
- return (await this.#completeLeases([lease], now, transaction))[0];
3000
+ // eslint-disable-next-line relaybus/max-two-params -- EventStore port signature
3001
+ complete(lease, now, transaction) {
3002
+ return this.#completion.complete(lease, { now, transaction });
2271
3003
  }
2272
3004
  completeMany(leases, now) {
2273
- return this.#completeLeases(leases, now, void 0);
3005
+ return this.#completion.completeMany(leases, now);
2274
3006
  }
3007
+ // eslint-disable-next-line relaybus/max-two-params -- EventStore port signature
2275
3008
  reschedule(lease, availableAt, error, now) {
2276
- return this.#settled(
2277
- lease,
2278
- this.#whenIssued(
3009
+ return Promise.resolve(
3010
+ this.#completion.changeClaim({
2279
3011
  lease,
2280
- (held) => this.#changed(this.#statements.reschedule, [
2281
- lease.deliveryId,
2282
- held.lane,
2283
- held.epoch,
2284
- held.claims,
2285
- availableAt.toISOString(),
2286
- JSON.stringify(error),
2287
- now.toISOString()
2288
- ])
2289
- )
3012
+ change: { kind: "reschedule", availableAt, error, now }
3013
+ })
2290
3014
  );
2291
3015
  }
3016
+ // eslint-disable-next-line relaybus/max-two-params -- EventStore port signature
2292
3017
  bury(lease, error, now) {
2293
- return this.#settled(
2294
- lease,
2295
- this.#whenIssued(
2296
- lease,
2297
- (held) => this.#changed(this.#statements.bury, [
2298
- lease.deliveryId,
2299
- held.lane,
2300
- held.epoch,
2301
- held.claims,
2302
- JSON.stringify(error),
2303
- now.toISOString()
2304
- ])
2305
- )
3018
+ return Promise.resolve(
3019
+ this.#completion.changeClaim({ lease, change: { kind: "bury", error, now } })
2306
3020
  );
2307
3021
  }
2308
3022
  /** Returns a leased delivery to pending, due now, without counting the attempt. */
2309
3023
  release(lease, now) {
2310
- return this.#settled(
2311
- lease,
2312
- this.#whenIssued(
2313
- lease,
2314
- (held) => this.#changed(this.#statements.release, [
2315
- lease.deliveryId,
2316
- held.lane,
2317
- held.epoch,
2318
- held.claims,
2319
- now.toISOString()
2320
- ])
2321
- )
3024
+ return Promise.resolve(
3025
+ this.#completion.changeClaim({ lease, change: { kind: "release", now } })
2322
3026
  );
2323
3027
  }
2324
3028
  /**
@@ -2331,38 +3035,33 @@ var PostgresEventStore = class {
2331
3035
  * wakes its own dispatcher).
2332
3036
  */
2333
3037
  async watchNewDeliveries(listener) {
2334
- if (this.#pool === void 0 || !this.#notifies) {
3038
+ if (this.#state.pool === void 0 || !this.#state.notifies) {
2335
3039
  return void 0;
2336
3040
  }
2337
- const options = this.#pool.options;
3041
+ const options = this.#state.pool.options;
2338
3042
  const watcher = new NotificationListener(
2339
3043
  async () => {
2340
3044
  const client = new pg.Client(options);
3045
+ this.#owned.track(client);
2341
3046
  await client.connect();
2342
3047
  return client;
2343
3048
  },
2344
- this.#notificationChannel,
3049
+ this.#state.notificationChannel,
2345
3050
  listener,
2346
- this.#logger
3051
+ this.#state.logger
2347
3052
  );
2348
3053
  await watcher.start();
2349
3054
  return () => watcher.close();
2350
3055
  }
2351
3056
  /** Frees the lanes `leaseOwner` holds that run no delivery, so other workers need not wait for the lease to expire. */
2352
- async relinquish(leaseOwner) {
2353
- await this.#query(this.#statements.relinquish, [leaseOwner]);
3057
+ relinquish(leaseOwner) {
3058
+ return this.#maintenance.relinquish(leaseOwner);
2354
3059
  }
2355
- async listDeliveries(query) {
2356
- const statement = this.#statements.listDeliveries(query);
2357
- const rows = await this.#executor.query(statement.sql, statement.parameters);
2358
- return rows.map(mapDeliveryRecord);
3060
+ listDeliveries(query) {
3061
+ return this.#maintenance.listDeliveries(query);
2359
3062
  }
2360
- async requeue(deliveryIds, now) {
2361
- const rows = await this.#query(this.#statements.requeue, [
2362
- JSON.stringify(deliveryIds),
2363
- now.toISOString()
2364
- ]);
2365
- return readCount(rows[0], "count");
3063
+ requeue(deliveryIds, now) {
3064
+ return this.#maintenance.requeue(deliveryIds, now);
2366
3065
  }
2367
3066
  /**
2368
3067
  * Deletes purgeable deliveries in batches of `purgeBatchSize`, one short
@@ -2373,239 +3072,65 @@ var PostgresEventStore = class {
2373
3072
  * was subscribed. Events that a concurrent publish is attaching a delivery
2374
3073
  * to are skipped and kept.
2375
3074
  */
2376
- async purge(request) {
2377
- const total = { deletedDeliveries: 0, deletedEvents: 0 };
2378
- const batches = [
2379
- [
2380
- this.#statements.purgeCompletedBatch(),
2381
- request.completedBefore,
2382
- this.#statements.deleteOrphanEventsWithoutCompletion
2383
- ],
2384
- [
2385
- this.#statements.purgeDeadBatch(),
2386
- request.deadBefore,
2387
- this.#statements.deleteOrphanEventsWithoutCompletion
2388
- ]
2389
- ];
2390
- if (this.#completionHistory) {
2391
- batches.push([
2392
- this.#statements.purgeCompletionHistoryBatch(),
2393
- request.completedBefore,
2394
- this.#statements.deleteOrphanEventsWithoutCompletion
2395
- ]);
2396
- }
2397
- for (const [sql, cutoff, deleteOrphans] of batches) {
2398
- if (cutoff === void 0) {
2399
- continue;
2400
- }
2401
- let deletedInBatch = this.#purgeBatchSize;
2402
- while (deletedInBatch === this.#purgeBatchSize) {
2403
- const batch = await this.#purgeBatch(sql, cutoff, deleteOrphans);
2404
- deletedInBatch = batch.deletedDeliveries;
2405
- total.deletedDeliveries += batch.deletedDeliveries;
2406
- total.deletedEvents += batch.deletedEvents;
2407
- }
2408
- }
2409
- const orphanCutoff = [request.completedBefore, request.deadBefore].filter((cutoff) => cutoff !== void 0).sort((first, second) => first.getTime() - second.getTime())[0];
2410
- if (orphanCutoff !== void 0) {
2411
- total.deletedEvents += await this.#purgeOrphanEvents(
2412
- this.#statements.lockOrphanEventsBatch,
2413
- this.#statements.deleteOrphanEventsWithoutCompletion,
2414
- orphanCutoff,
2415
- false
2416
- );
2417
- }
2418
- if (request.completedBefore !== void 0) {
2419
- total.deletedEvents += await this.#purgeOrphanEvents(
2420
- this.#statements.lockCompletedOrphanEventsBatch,
2421
- this.#statements.deleteExpiredCompletedOrphanEvents,
2422
- request.completedBefore,
2423
- true
2424
- );
2425
- }
2426
- return total;
2427
- }
2428
- async statistics(now) {
2429
- const stored = { pending: 0, dead: 0, completed: 0 };
2430
- for (const row of await this.#query(this.#statements.countByStatus, [])) {
2431
- stored[row["status"]] = readCount(row, "count");
2432
- }
2433
- const [running] = await this.#query(this.#statements.countRunning, []);
2434
- const processing = readCount(running, "count");
2435
- let completed = stored["completed"];
2436
- if (this.#completionHistory) {
2437
- const [history] = await this.#query(this.#statements.countCompletionHistory, []);
2438
- completed += readCount(history, "count");
2439
- }
2440
- const oldestDue = await this.#query(this.#statements.oldestDue, [now.toISOString()]);
2441
- return {
2442
- // The counts come from separate statements; a delivery that finishes in between must not make this negative.
2443
- pending: Math.max(0, stored["pending"] - processing),
2444
- processing,
2445
- completed,
2446
- dead: stored["dead"],
2447
- oldestDueAt: mapOldestDueAt(oldestDue)
2448
- };
2449
- }
2450
- /** Ends the pool this store created from `connectionString`. Pools and executors you passed in stay open. */
2451
- async close() {
2452
- this.#closed = true;
2453
- clearTimeout(this.#trailingNotification);
2454
- this.#trailingNotification = void 0;
2455
- const pool = this.#ownedPool;
2456
- this.#ownedPool = void 0;
2457
- await pool?.end();
2458
- }
2459
- /** Runs a statement of {@link PostgresStatements} whose text never changes, prepared when `preparedStatements` is on. */
2460
- #query(sql, parameters, transaction) {
2461
- return this.#executor.query(sql, parameters, transaction, this.#queryOptions);
3075
+ purge(request) {
3076
+ return this.#maintenance.purge(request);
2462
3077
  }
2463
- async #changed(sql, parameters) {
2464
- const rows = await this.#query(sql, parameters);
2465
- return readCount(rows[0], "count") > 0;
2466
- }
2467
- /** Runs `operation` for a lease this store issued; any other token cannot be held. */
2468
- async #whenIssued(lease, operation) {
2469
- const held = parseLeaseToken(lease.leaseToken);
2470
- return held !== void 0 && await operation(held);
3078
+ statistics(now) {
3079
+ return this.#maintenance.statistics(now);
2471
3080
  }
2472
3081
  /**
2473
- * Deletes the delivery of every lease that is still the current claim of its
2474
- * delivery. Returns one flag per lease, in order. Results are matched to leases
2475
- * by position, so two leases of one delivery (a stale and a current one) get
2476
- * their own answers; a lease listed twice completes once, at its first position.
3082
+ * Ends the pool this store created from `connectionString`. Pools and executors you passed in stay
3083
+ * open. Once `options.signal` aborts (the shutdown deadline passed), the connections the store opened
3084
+ * itself are destroyed: queries still running on them fail at once instead of holding the shutdown.
2477
3085
  */
2478
- async #completeLeases(leases, now, transaction) {
2479
- const issued = [];
2480
- const seen = /* @__PURE__ */ new Set();
2481
- leases.forEach((lease, ord) => {
2482
- const held = parseLeaseToken(lease.leaseToken);
2483
- const identity = `${lease.deliveryId}\0${lease.leaseToken}`;
2484
- if (held !== void 0 && !seen.has(identity)) {
2485
- seen.add(identity);
2486
- issued.push({ ord, id: lease.deliveryId, ...held });
2487
- }
2488
- });
2489
- const completed = /* @__PURE__ */ new Set();
2490
- if (issued.length > 0) {
2491
- const rows = await this.#query(
2492
- this.#statements.complete,
2493
- [JSON.stringify(issued), now.toISOString()],
2494
- transaction
2495
- );
2496
- for (const row of rows) {
2497
- completed.add(readCount(row, "ord"));
2498
- }
2499
- }
2500
- if (transaction === void 0) {
2501
- for (const lease of leases) {
2502
- this.#handedOut.delete(handedOutKey(lease.deliveryId, lease.leaseToken));
2503
- }
3086
+ async close(options = {}) {
3087
+ this.#state.closed = true;
3088
+ this.#state.trailingNotification?.();
3089
+ this.#state.trailingNotification = void 0;
3090
+ const pool = this.#state.ownedPool;
3091
+ this.#state.ownedPool = void 0;
3092
+ const { signal } = options;
3093
+ const force = () => this.#owned.destroyAll();
3094
+ if (signal?.aborted === true) {
3095
+ force();
2504
3096
  }
2505
- return leases.map((_, ord) => completed.has(ord));
2506
- }
2507
- async #purgeOrphanEvents(lockStatement, deleteStatement, cutoff, recheckCutoff) {
2508
- let deletedEvents = 0;
2509
- let removedInBatch = this.#purgeBatchSize;
2510
- while (removedInBatch === this.#purgeBatchSize) {
2511
- removedInBatch = await this.#executor.transaction(async (transaction) => {
2512
- const locked = await this.#query(
2513
- lockStatement,
2514
- [cutoff.toISOString(), this.#purgeBatchSize],
2515
- transaction
2516
- );
2517
- const ids = JSON.stringify(locked.map((row) => row["id"]));
2518
- const removed = await this.#query(
2519
- deleteStatement,
2520
- recheckCutoff ? [ids, cutoff.toISOString()] : [ids],
2521
- transaction
2522
- );
2523
- return readCount(removed[0], "count");
2524
- });
2525
- deletedEvents += removedInBatch;
3097
+ signal?.addEventListener("abort", force, { once: true });
3098
+ try {
3099
+ await pool?.end();
3100
+ } finally {
3101
+ signal?.removeEventListener("abort", force);
2526
3102
  }
2527
- return deletedEvents;
2528
3103
  }
2529
- async #purgeBatch(sql, cutoff, deleteOrphans) {
2530
- return this.#executor.transaction(async (transaction) => {
2531
- const deleted = await this.#query(
2532
- sql,
2533
- [cutoff.toISOString(), this.#purgeBatchSize],
2534
- transaction
2535
- );
2536
- if (deleted.length === 0) {
2537
- return { deletedDeliveries: 0, deletedEvents: 0 };
2538
- }
2539
- const eventIds = JSON.stringify([
2540
- ...new Set(deleted.map((row) => row["event_id"]))
2541
- ]);
2542
- const locked = await this.#query(this.#statements.lockEvents, [eventIds], transaction);
2543
- const removed = await this.#query(
2544
- deleteOrphans,
2545
- [JSON.stringify(locked.map((row) => row["id"]))],
2546
- transaction
3104
+ };
3105
+ function connect(options, { logger, owned }) {
3106
+ if (options.executor !== void 0) {
3107
+ return { executor: options.executor, pool: void 0, ownedPool: void 0 };
3108
+ }
3109
+ const pool = options.pool ?? new pg.Pool({
3110
+ ...options.poolConfig,
3111
+ connectionString: options.connectionString,
3112
+ Client: owned.clientClass()
3113
+ });
3114
+ let ownedPool;
3115
+ if (options.pool === void 0) {
3116
+ const poolLogger = options.logger ?? console;
3117
+ pool.on("error", (error) => {
3118
+ poolLogger.error(
3119
+ `[PostgresEventStore](connect) Idle connection failed, the pool replaces it | error=${error.message}`
2547
3120
  );
2548
- return { deletedDeliveries: deleted.length, deletedEvents: readCount(removed[0], "count") };
2549
3121
  });
3122
+ ownedPool = pool;
2550
3123
  }
2551
- };
2552
- function planAppend(entries) {
2553
- const events = [];
2554
- const deliveries = [];
2555
- const outcomes = [];
2556
- const seenEventIds = /* @__PURE__ */ new Set();
2557
- const seenDeliveries = /* @__PURE__ */ new Set();
2558
- for (const { event, deliveries: newDeliveries } of entries) {
2559
- const repeated = seenEventIds.has(event.id);
2560
- seenEventIds.add(event.id);
2561
- outcomes.push({ eventId: event.id, repeated });
2562
- if (!repeated) {
2563
- events.push(event);
2564
- }
2565
- for (const delivery of newDeliveries) {
2566
- const identity = `${event.id}\0${delivery.handler}`;
2567
- if (!seenDeliveries.has(identity)) {
2568
- seenDeliveries.add(identity);
2569
- deliveries.push({
2570
- id: delivery.id,
2571
- event_id: event.id,
2572
- handler: delivery.handler,
2573
- available_at: delivery.availableAt.toISOString()
2574
- });
2575
- }
2576
- }
2577
- }
2578
- return { events, deliveries, outcomes };
2579
- }
2580
- function toEventRow(event, sequence, handlers) {
2581
- return {
2582
- id: event.id,
2583
- type: event.type,
2584
- source: event.source,
2585
- key: event.key,
2586
- sequence,
2587
- payload: event.payload,
2588
- headers: event.headers,
2589
- correlation_id: event.correlationId,
2590
- causation_id: event.causationId,
2591
- occurred_at: event.occurredAt.toISOString(),
2592
- handlers
2593
- };
2594
- }
2595
- function compareSequence(left, right) {
2596
- const first = BigInt(left["sequence"] ?? 0);
2597
- const second = BigInt(right["sequence"] ?? 0);
2598
- return Number(first > second) - Number(first < second);
2599
- }
2600
- function compareDue(left, right) {
2601
- return compareText(left["due_at"], right["due_at"]) || compareText(left["delivery_id"], right["delivery_id"]);
2602
- }
2603
- function compareText(left, right) {
2604
- return Number(left > right) - Number(left < right);
3124
+ const executor = new PgExecutor(pool, {
3125
+ logger,
3126
+ sessionBudget: ownedPool !== void 0 && options.preparedStatements !== false
3127
+ });
3128
+ return { executor, pool, ownedPool };
2605
3129
  }
2606
3130
 
2607
3131
  exports.PgExecutor = PgExecutor;
2608
3132
  exports.PostgresEventStore = PostgresEventStore;
3133
+ exports.ReconcileFailedError = ReconcileFailedError;
2609
3134
  exports.createPostgresSchemaSql = createPostgresSchemaSql;
2610
3135
  exports.createPostgresSchemaStatements = createPostgresSchemaStatements;
2611
3136
  exports.resolvePostgresNames = resolvePostgresNames;