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