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