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