@relaybus/postgres 0.1.0 → 0.3.0

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