@relaybus/postgres 0.0.0-stage → 0.0.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/LICENSE +21 -0
- package/README.md +209 -2
- package/dist/chunk-UUFSUL2M.js +15 -0
- package/dist/chunk-UUFSUL2M.js.map +1 -0
- package/dist/drizzle.cjs +35 -0
- package/dist/drizzle.cjs.map +1 -0
- package/dist/drizzle.d.cts +20 -0
- package/dist/drizzle.d.ts +20 -0
- package/dist/drizzle.js +19 -0
- package/dist/drizzle.js.map +1 -0
- package/dist/executor-CSM7ZOhz.d.cts +24 -0
- package/dist/executor-CSM7ZOhz.d.ts +24 -0
- package/dist/index.cjs +975 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +191 -0
- package/dist/index.d.ts +191 -0
- package/dist/index.js +969 -0
- package/dist/index.js.map +1 -0
- package/dist/knex.cjs +33 -0
- package/dist/knex.cjs.map +1 -0
- package/dist/knex.d.cts +19 -0
- package/dist/knex.d.ts +19 -0
- package/dist/knex.js +21 -0
- package/dist/knex.js.map +1 -0
- package/dist/kysely.cjs +20 -0
- package/dist/kysely.cjs.map +1 -0
- package/dist/kysely.d.cts +23 -0
- package/dist/kysely.d.ts +23 -0
- package/dist/kysely.js +18 -0
- package/dist/kysely.js.map +1 -0
- package/dist/prisma.cjs +15 -0
- package/dist/prisma.cjs.map +1 -0
- package/dist/prisma.d.cts +26 -0
- package/dist/prisma.d.ts +26 -0
- package/dist/prisma.js +13 -0
- package/dist/prisma.js.map +1 -0
- package/dist/typeorm.cjs +15 -0
- package/dist/typeorm.cjs.map +1 -0
- package/dist/typeorm.d.cts +19 -0
- package/dist/typeorm.d.ts +19 -0
- package/dist/typeorm.js +13 -0
- package/dist/typeorm.js.map +1 -0
- package/package.json +157 -3
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,975 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var core = require('@relaybus/core');
|
|
4
|
+
var pg = require('pg');
|
|
5
|
+
|
|
6
|
+
// src/pg-executor.ts
|
|
7
|
+
var PgExecutor = class {
|
|
8
|
+
#pool;
|
|
9
|
+
constructor(pool) {
|
|
10
|
+
this.#pool = pool;
|
|
11
|
+
}
|
|
12
|
+
async transaction(work) {
|
|
13
|
+
const client = await this.#pool.connect();
|
|
14
|
+
try {
|
|
15
|
+
await client.query("BEGIN");
|
|
16
|
+
const result = await work(client);
|
|
17
|
+
await client.query("COMMIT");
|
|
18
|
+
client.release();
|
|
19
|
+
return result;
|
|
20
|
+
} catch (error) {
|
|
21
|
+
await this.#rollbackAndRelease(client);
|
|
22
|
+
throw error;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
async query(sql, parameters, transaction) {
|
|
26
|
+
const result = await (transaction ?? this.#pool).query(sql, [...parameters]);
|
|
27
|
+
return result.rows;
|
|
28
|
+
}
|
|
29
|
+
/** A client whose rollback fails is in an unknown state, so it is destroyed instead of reused. */
|
|
30
|
+
async #rollbackAndRelease(client) {
|
|
31
|
+
try {
|
|
32
|
+
await client.query("ROLLBACK");
|
|
33
|
+
client.release();
|
|
34
|
+
} catch {
|
|
35
|
+
client.release(true);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
var IDENTIFIER_PATTERN = /^[a-z_][a-z0-9_]*$/;
|
|
40
|
+
var MAXIMUM_IDENTIFIER_LENGTH = 63;
|
|
41
|
+
function assertIdentifier(label, value) {
|
|
42
|
+
if (!IDENTIFIER_PATTERN.test(value) || value.length > MAXIMUM_IDENTIFIER_LENGTH) {
|
|
43
|
+
throw new core.ConfigurationError(
|
|
44
|
+
`${label} '${value}' must match ${String(IDENTIFIER_PATTERN)} and be at most ${MAXIMUM_IDENTIFIER_LENGTH} characters`
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function quoteIdentifier(value) {
|
|
49
|
+
return `"${value}"`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// src/names.ts
|
|
53
|
+
var DEFAULT_SCHEMA = "public";
|
|
54
|
+
var DEFAULT_TABLE_PREFIX = "relaybus_";
|
|
55
|
+
var TABLE_PREFIX_PATTERN = /^[a-z0-9_]*$/;
|
|
56
|
+
function resolvePostgresNames(options = {}) {
|
|
57
|
+
const schema = options.schema ?? DEFAULT_SCHEMA;
|
|
58
|
+
const tablePrefix = options.tablePrefix ?? DEFAULT_TABLE_PREFIX;
|
|
59
|
+
assertIdentifier("schema", schema);
|
|
60
|
+
if (!TABLE_PREFIX_PATTERN.test(tablePrefix)) {
|
|
61
|
+
throw new core.ConfigurationError(
|
|
62
|
+
`tablePrefix '${tablePrefix}' must match ${String(TABLE_PREFIX_PATTERN)}`
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
const name = (suffix) => {
|
|
66
|
+
const identifier = `${tablePrefix}${suffix}`;
|
|
67
|
+
assertIdentifier(`tablePrefix '${tablePrefix}' produces the name`, identifier);
|
|
68
|
+
return identifier;
|
|
69
|
+
};
|
|
70
|
+
const quotedSchema = quoteIdentifier(schema);
|
|
71
|
+
const table = (suffix) => `${quotedSchema}.${quoteIdentifier(name(suffix))}`;
|
|
72
|
+
const index = (suffix) => quoteIdentifier(name(suffix));
|
|
73
|
+
return {
|
|
74
|
+
schema,
|
|
75
|
+
quotedSchema,
|
|
76
|
+
events: table("events"),
|
|
77
|
+
deliveries: table("deliveries"),
|
|
78
|
+
keySequences: table("key_sequences"),
|
|
79
|
+
indexes: {
|
|
80
|
+
deliveryEventHandler: index("deliveries_event_handler_unique"),
|
|
81
|
+
deliveryDue: index("deliveries_due_idx"),
|
|
82
|
+
deliveryDueUnkeyed: index("deliveries_due_unkeyed_idx"),
|
|
83
|
+
deliveryHandlerCreated: index("deliveries_handler_created_idx"),
|
|
84
|
+
eventCreated: index("events_created_idx"),
|
|
85
|
+
deliveryKeyUnfinished: index("deliveries_key_unfinished_idx"),
|
|
86
|
+
deliveryKeyProcessing: index("deliveries_key_processing_idx"),
|
|
87
|
+
deliveryStatusCreated: index("deliveries_status_created_idx"),
|
|
88
|
+
deliveryCompleted: index("deliveries_completed_idx"),
|
|
89
|
+
deliveryDead: index("deliveries_dead_idx")
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// src/sql-fragments.ts
|
|
95
|
+
function dueAtExpression(alias) {
|
|
96
|
+
const prefix = alias === void 0 ? "" : `${alias}.`;
|
|
97
|
+
return `CASE WHEN ${prefix}status = 'pending' THEN ${prefix}available_at ELSE ${prefix}lease_expires_at END`;
|
|
98
|
+
}
|
|
99
|
+
function isoText(expression) {
|
|
100
|
+
return `to_char(${expression} AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// src/schema.ts
|
|
104
|
+
function createPostgresSchemaStatements(options = {}) {
|
|
105
|
+
const names = resolvePostgresNames(options);
|
|
106
|
+
const unfinished = `status IN ('pending', 'processing')`;
|
|
107
|
+
const statements = [
|
|
108
|
+
`CREATE TABLE IF NOT EXISTS ${names.events} (
|
|
109
|
+
id text PRIMARY KEY,
|
|
110
|
+
type text NOT NULL,
|
|
111
|
+
source text NOT NULL,
|
|
112
|
+
key text,
|
|
113
|
+
sequence bigint,
|
|
114
|
+
payload jsonb NOT NULL,
|
|
115
|
+
headers jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
116
|
+
correlation_id text,
|
|
117
|
+
causation_id text,
|
|
118
|
+
occurred_at timestamptz NOT NULL,
|
|
119
|
+
created_at timestamptz NOT NULL
|
|
120
|
+
)`,
|
|
121
|
+
`CREATE INDEX IF NOT EXISTS ${names.indexes.eventCreated} ON ${names.events} (created_at, id)`,
|
|
122
|
+
`CREATE TABLE IF NOT EXISTS ${names.keySequences} (
|
|
123
|
+
key text PRIMARY KEY,
|
|
124
|
+
sequence bigint NOT NULL
|
|
125
|
+
)`,
|
|
126
|
+
`CREATE TABLE IF NOT EXISTS ${names.deliveries} (
|
|
127
|
+
id text COLLATE "C" PRIMARY KEY,
|
|
128
|
+
event_id text NOT NULL REFERENCES ${names.events} (id) ON DELETE CASCADE,
|
|
129
|
+
handler text NOT NULL,
|
|
130
|
+
status text NOT NULL CHECK (status IN ('pending', 'processing', 'completed', 'dead')),
|
|
131
|
+
attempts integer NOT NULL DEFAULT 0,
|
|
132
|
+
available_at timestamptz NOT NULL,
|
|
133
|
+
lease_owner text,
|
|
134
|
+
lease_token text,
|
|
135
|
+
lease_expires_at timestamptz,
|
|
136
|
+
last_error jsonb,
|
|
137
|
+
key text,
|
|
138
|
+
sequence bigint,
|
|
139
|
+
created_at timestamptz NOT NULL,
|
|
140
|
+
updated_at timestamptz NOT NULL,
|
|
141
|
+
completed_at timestamptz,
|
|
142
|
+
CONSTRAINT ${names.indexes.deliveryEventHandler} UNIQUE (event_id, handler)
|
|
143
|
+
)`,
|
|
144
|
+
`CREATE INDEX IF NOT EXISTS ${names.indexes.deliveryDue} ON ${names.deliveries} ((${dueAtExpression()}), id) WHERE ${unfinished}`,
|
|
145
|
+
`CREATE INDEX IF NOT EXISTS ${names.indexes.deliveryDueUnkeyed} ON ${names.deliveries} ((${dueAtExpression()}), id) WHERE key IS NULL AND ${unfinished}`,
|
|
146
|
+
`CREATE INDEX IF NOT EXISTS ${names.indexes.deliveryKeyUnfinished} ON ${names.deliveries} (handler, key, sequence) WHERE key IS NOT NULL AND ${unfinished}`,
|
|
147
|
+
`CREATE INDEX IF NOT EXISTS ${names.indexes.deliveryKeyProcessing} ON ${names.deliveries} (handler, key) WHERE key IS NOT NULL AND status = 'processing'`,
|
|
148
|
+
`CREATE INDEX IF NOT EXISTS ${names.indexes.deliveryStatusCreated} ON ${names.deliveries} (status, created_at, id)`,
|
|
149
|
+
`CREATE INDEX IF NOT EXISTS ${names.indexes.deliveryHandlerCreated} ON ${names.deliveries} (handler, created_at, id)`,
|
|
150
|
+
`CREATE INDEX IF NOT EXISTS ${names.indexes.deliveryCompleted} ON ${names.deliveries} (completed_at, id) WHERE status = 'completed'`,
|
|
151
|
+
`CREATE INDEX IF NOT EXISTS ${names.indexes.deliveryDead} ON ${names.deliveries} (updated_at, id) WHERE status = 'dead'`
|
|
152
|
+
];
|
|
153
|
+
if (names.schema !== "public") {
|
|
154
|
+
statements.unshift(`CREATE SCHEMA IF NOT EXISTS ${names.quotedSchema}`);
|
|
155
|
+
}
|
|
156
|
+
return statements;
|
|
157
|
+
}
|
|
158
|
+
function createPostgresSchemaSql(options = {}) {
|
|
159
|
+
return `${createPostgresSchemaStatements(options).join(";\n\n")};
|
|
160
|
+
`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// src/row-mapping.ts
|
|
164
|
+
function text(row, column) {
|
|
165
|
+
return row[column];
|
|
166
|
+
}
|
|
167
|
+
function nullableText(row, column) {
|
|
168
|
+
return row[column];
|
|
169
|
+
}
|
|
170
|
+
function nullableDate(row, column) {
|
|
171
|
+
const value = nullableText(row, column);
|
|
172
|
+
return value === null ? null : new Date(value);
|
|
173
|
+
}
|
|
174
|
+
function readCount(row, column) {
|
|
175
|
+
return Number(row[column]);
|
|
176
|
+
}
|
|
177
|
+
function mapEventEnvelope(row) {
|
|
178
|
+
return {
|
|
179
|
+
id: text(row, "event_id"),
|
|
180
|
+
type: text(row, "event_type"),
|
|
181
|
+
source: text(row, "event_source"),
|
|
182
|
+
key: nullableText(row, "event_key"),
|
|
183
|
+
payload: JSON.parse(text(row, "event_payload")),
|
|
184
|
+
headers: JSON.parse(text(row, "event_headers")),
|
|
185
|
+
correlationId: nullableText(row, "event_correlation_id"),
|
|
186
|
+
causationId: nullableText(row, "event_causation_id"),
|
|
187
|
+
occurredAt: new Date(text(row, "event_occurred_at"))
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
function mapClaimedDelivery(row, leaseExpiresAt) {
|
|
191
|
+
return {
|
|
192
|
+
deliveryId: text(row, "delivery_id"),
|
|
193
|
+
leaseToken: text(row, "lease_token"),
|
|
194
|
+
handler: text(row, "handler"),
|
|
195
|
+
attempt: readCount(row, "attempts"),
|
|
196
|
+
leaseExpiresAt,
|
|
197
|
+
event: mapEventEnvelope(row)
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
function mapDeliveryRecord(row) {
|
|
201
|
+
const lastError = nullableText(row, "last_error");
|
|
202
|
+
return {
|
|
203
|
+
id: text(row, "delivery_id"),
|
|
204
|
+
handler: text(row, "handler"),
|
|
205
|
+
status: text(row, "status"),
|
|
206
|
+
attempts: readCount(row, "attempts"),
|
|
207
|
+
availableAt: new Date(text(row, "available_at")),
|
|
208
|
+
leaseOwner: nullableText(row, "lease_owner"),
|
|
209
|
+
leaseExpiresAt: nullableDate(row, "lease_expires_at"),
|
|
210
|
+
lastError: lastError === null ? null : JSON.parse(lastError),
|
|
211
|
+
createdAt: new Date(text(row, "created_at")),
|
|
212
|
+
updatedAt: new Date(text(row, "updated_at")),
|
|
213
|
+
completedAt: nullableDate(row, "completed_at"),
|
|
214
|
+
event: mapEventEnvelope(row)
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
function mapOldestDueAt(rows) {
|
|
218
|
+
return rows.length === 0 ? null : new Date(text(rows[0], "oldest_due_at"));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// src/statements.ts
|
|
222
|
+
var DEFAULT_LIST_LIMIT = 100;
|
|
223
|
+
function eventColumns(alias) {
|
|
224
|
+
return `${alias}.id AS event_id,
|
|
225
|
+
${alias}.type AS event_type,
|
|
226
|
+
${alias}.source AS event_source,
|
|
227
|
+
${alias}.key AS event_key,
|
|
228
|
+
${alias}.payload::text AS event_payload,
|
|
229
|
+
${alias}.headers::text AS event_headers,
|
|
230
|
+
${alias}.correlation_id AS event_correlation_id,
|
|
231
|
+
${alias}.causation_id AS event_causation_id,
|
|
232
|
+
${isoText(`${alias}.occurred_at`)} AS event_occurred_at`;
|
|
233
|
+
}
|
|
234
|
+
function countChanged(dml) {
|
|
235
|
+
return `WITH changed AS (${dml} RETURNING 1) SELECT count(*)::int AS count FROM changed`;
|
|
236
|
+
}
|
|
237
|
+
var CLAIM_SCAN_WINDOW = 256;
|
|
238
|
+
var UNFINISHED_PREDICATE = (alias) => `${alias}.status IN ('pending', 'processing')`;
|
|
239
|
+
var HANDLER_PREDICATE = `delivery.handler = ANY (ARRAY(SELECT jsonb_array_elements_text($2::jsonb)))`;
|
|
240
|
+
var CLEAR_LEASE = "lease_owner = NULL, lease_token = NULL, lease_expires_at = NULL";
|
|
241
|
+
var PostgresStatements = class {
|
|
242
|
+
#names;
|
|
243
|
+
constructor(names) {
|
|
244
|
+
this.#names = names;
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Lock advisory key text for {@link setupLock}. It is derived from the schema
|
|
248
|
+
* name only, so stores with different table prefixes in one schema also
|
|
249
|
+
* serialize their `CREATE SCHEMA IF NOT EXISTS`, which is not race-free.
|
|
250
|
+
*/
|
|
251
|
+
get setupLockKey() {
|
|
252
|
+
return `relaybus:${this.#names.schema}`;
|
|
253
|
+
}
|
|
254
|
+
get setupLock() {
|
|
255
|
+
return "SELECT pg_advisory_xact_lock(hashtextextended($1::text, 0))";
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Reserves sequence numbers per key. Parameters: `[{ key, count }]` as JSON.
|
|
259
|
+
* Keys are locked in sorted order, so publishers cannot deadlock within one
|
|
260
|
+
* call (across calls of one transaction they can, see the README); each row lock is held until the publishing transaction ends, which makes
|
|
261
|
+
* sequence order equal commit order for a key.
|
|
262
|
+
*/
|
|
263
|
+
get reserveSequences() {
|
|
264
|
+
return `INSERT INTO ${this.#names.keySequences} AS reserved (key, sequence)
|
|
265
|
+
SELECT requested.key, requested.count
|
|
266
|
+
FROM jsonb_to_recordset($1::jsonb) AS requested (key text, count bigint)
|
|
267
|
+
ORDER BY requested.key
|
|
268
|
+
ON CONFLICT (key) DO UPDATE SET sequence = reserved.sequence + EXCLUDED.sequence
|
|
269
|
+
RETURNING reserved.key AS key, reserved.sequence::text AS sequence`;
|
|
270
|
+
}
|
|
271
|
+
/** Parameters: `[eventsJson, createdAt]`. Returns the ids that were inserted. */
|
|
272
|
+
get insertEvents() {
|
|
273
|
+
return `INSERT INTO ${this.#names.events}
|
|
274
|
+
(id, type, source, key, sequence, payload, headers, correlation_id, causation_id, occurred_at, created_at)
|
|
275
|
+
SELECT incoming.id, incoming.type, incoming.source, incoming.key, incoming.sequence,
|
|
276
|
+
COALESCE(incoming.payload, 'null'::jsonb), COALESCE(incoming.headers, '{}'::jsonb),
|
|
277
|
+
incoming.correlation_id, incoming.causation_id, incoming.occurred_at, $2::timestamptz
|
|
278
|
+
FROM jsonb_to_recordset($1::jsonb) AS incoming (
|
|
279
|
+
id text, type text, source text, key text, sequence bigint, payload jsonb, headers jsonb,
|
|
280
|
+
correlation_id text, causation_id text, occurred_at timestamptz)
|
|
281
|
+
ORDER BY incoming.id
|
|
282
|
+
ON CONFLICT (id) DO NOTHING
|
|
283
|
+
RETURNING id`;
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Parameters: `[deliveriesJson, createdAt]`. Key and sequence are copied from
|
|
287
|
+
* the stored event, so a delivery added later to an existing event keeps the
|
|
288
|
+
* event's position in its key. Returns the ids that were inserted.
|
|
289
|
+
*/
|
|
290
|
+
get insertDeliveries() {
|
|
291
|
+
return `INSERT INTO ${this.#names.deliveries}
|
|
292
|
+
(id, event_id, handler, status, attempts, available_at, key, sequence, created_at, updated_at)
|
|
293
|
+
SELECT incoming.id, incoming.event_id, incoming.handler, 'pending', 0, incoming.available_at,
|
|
294
|
+
event.key, event.sequence, $2::timestamptz, $2::timestamptz
|
|
295
|
+
FROM jsonb_to_recordset($1::jsonb) AS incoming (id text, event_id text, handler text, available_at timestamptz)
|
|
296
|
+
JOIN ${this.#names.events} AS event ON event.id = incoming.event_id
|
|
297
|
+
ORDER BY incoming.event_id, incoming.handler
|
|
298
|
+
ON CONFLICT (event_id, handler) DO NOTHING
|
|
299
|
+
RETURNING id`;
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Fast claim: leases from the first {@link CLAIM_SCAN_WINDOW} due deliveries
|
|
303
|
+
* in due order, which is everything the claim needs while the front of the
|
|
304
|
+
* queue is not clogged with blocked followers of busy keys. Parameters:
|
|
305
|
+
* `[now, handlersJson, limit, leaseOwner, leaseExpiresAt]`.
|
|
306
|
+
*
|
|
307
|
+
* Always returns one row per leased delivery, or a single row whose
|
|
308
|
+
* `delivery_id` is null when nothing was leased. `window_saturated` says
|
|
309
|
+
* whether the window was full: when it is and fewer than `limit` deliveries
|
|
310
|
+
* were leased, deliveries beyond the window may still be claimable and the
|
|
311
|
+
* caller runs {@link claimBehindBlockedKeys}. When the window is not
|
|
312
|
+
* saturated every due delivery was examined, so the result is complete.
|
|
313
|
+
*
|
|
314
|
+
* Candidates are chosen with `FOR UPDATE SKIP LOCKED`, so two claimers never
|
|
315
|
+
* lock the same row. A keyed delivery is a candidate only when it is the head
|
|
316
|
+
* of its (handler, key), that is (a) no earlier delivery of that handler and
|
|
317
|
+
* key is pending or processing, which is the ordering guarantee, and (b) no
|
|
318
|
+
* other delivery of that handler and key holds a live lease. Rule b covers a
|
|
319
|
+
* delivery that re-enters the key behind a running one (requeue, or a handler
|
|
320
|
+
* added to an old event); an expired lease does not count, because its worker
|
|
321
|
+
* is presumed dead and the key must not stall behind it.
|
|
322
|
+
*
|
|
323
|
+
* Why SKIP LOCKED alone is enough for ordering: only the head of a key can
|
|
324
|
+
* ever be a candidate, and the head is a single row. Two claimers racing
|
|
325
|
+
* for it either skip it (the other holds the row lock) or, if the other
|
|
326
|
+
* already committed, re-check the row's new version (now processing and not
|
|
327
|
+
* due), which excludes it. Followers stay blocked because their predecessor
|
|
328
|
+
* remains pending or processing in every snapshot until it completes or is
|
|
329
|
+
* buried, and that commit happens-before the next claim that sees it.
|
|
330
|
+
*/
|
|
331
|
+
get claim() {
|
|
332
|
+
const deliveries = this.#names.deliveries;
|
|
333
|
+
return `WITH due_window AS MATERIALIZED (
|
|
334
|
+
SELECT delivery.id
|
|
335
|
+
FROM ${deliveries} AS delivery
|
|
336
|
+
WHERE ${UNFINISHED_PREDICATE("delivery")}
|
|
337
|
+
AND ${dueAtExpression("delivery")} <= $1::timestamptz
|
|
338
|
+
AND ${HANDLER_PREDICATE}
|
|
339
|
+
ORDER BY ${dueAtExpression("delivery")}, delivery.id
|
|
340
|
+
LIMIT ${CLAIM_SCAN_WINDOW}
|
|
341
|
+
), candidates AS MATERIALIZED (
|
|
342
|
+
SELECT delivery.id, ${dueAtExpression("delivery")} AS due_at
|
|
343
|
+
FROM due_window
|
|
344
|
+
JOIN ${deliveries} AS delivery ON delivery.id = due_window.id
|
|
345
|
+
WHERE ${UNFINISHED_PREDICATE("delivery")}
|
|
346
|
+
AND ${dueAtExpression("delivery")} <= $1::timestamptz
|
|
347
|
+
AND (
|
|
348
|
+
delivery.key IS NULL
|
|
349
|
+
OR NOT (
|
|
350
|
+
EXISTS (
|
|
351
|
+
SELECT 1 FROM ${deliveries} AS earlier
|
|
352
|
+
WHERE earlier.key IS NOT NULL AND earlier.handler = delivery.handler AND earlier.key = delivery.key
|
|
353
|
+
AND ${UNFINISHED_PREDICATE("earlier")}
|
|
354
|
+
AND earlier.sequence < delivery.sequence
|
|
355
|
+
)
|
|
356
|
+
OR ${this.#liveLeaseElsewhere()}
|
|
357
|
+
)
|
|
358
|
+
)
|
|
359
|
+
ORDER BY due_at, delivery.id
|
|
360
|
+
LIMIT $3::int
|
|
361
|
+
FOR UPDATE OF delivery SKIP LOCKED
|
|
362
|
+
), ${this.#leaseCandidates()}
|
|
363
|
+
SELECT (SELECT count(*) FROM due_window) >= ${CLAIM_SCAN_WINDOW} AS window_saturated, leased.*
|
|
364
|
+
FROM (SELECT 1) AS anchor
|
|
365
|
+
LEFT JOIN (${this.#leasedRows()}) AS leased ON true
|
|
366
|
+
ORDER BY leased.due_at, leased.delivery_id`;
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* Slow claim, run when {@link claim} leased too few deliveries from a full
|
|
370
|
+
* window: the front of the due queue is mostly followers of blocked keys.
|
|
371
|
+
* Same parameters, rules and locking as {@link claim}, but heads are found
|
|
372
|
+
* with `DISTINCT ON (handler, key)` over the unfinished-key index, so the
|
|
373
|
+
* cost follows the number of unfinished keyed deliveries (one index-only
|
|
374
|
+
* scan) instead of one probe per due follower.
|
|
375
|
+
*/
|
|
376
|
+
get claimBehindBlockedKeys() {
|
|
377
|
+
const deliveries = this.#names.deliveries;
|
|
378
|
+
return `WITH heads AS MATERIALIZED (
|
|
379
|
+
SELECT DISTINCT ON (head.handler, head.key) head.id
|
|
380
|
+
FROM ${deliveries} AS head
|
|
381
|
+
WHERE head.key IS NOT NULL
|
|
382
|
+
AND ${UNFINISHED_PREDICATE("head")}
|
|
383
|
+
AND head.handler = ANY (ARRAY(SELECT jsonb_array_elements_text($2::jsonb)))
|
|
384
|
+
ORDER BY head.handler, head.key, head.sequence
|
|
385
|
+
), keyed AS MATERIALIZED (
|
|
386
|
+
SELECT delivery.id, ${dueAtExpression("delivery")} AS due_at
|
|
387
|
+
FROM heads
|
|
388
|
+
JOIN ${deliveries} AS delivery ON delivery.id = heads.id
|
|
389
|
+
WHERE ${UNFINISHED_PREDICATE("delivery")}
|
|
390
|
+
AND ${dueAtExpression("delivery")} <= $1::timestamptz
|
|
391
|
+
AND NOT ${this.#liveLeaseElsewhere()}
|
|
392
|
+
ORDER BY due_at, delivery.id
|
|
393
|
+
LIMIT $3::int
|
|
394
|
+
FOR UPDATE OF delivery SKIP LOCKED
|
|
395
|
+
), unkeyed AS MATERIALIZED (
|
|
396
|
+
SELECT delivery.id, ${dueAtExpression("delivery")} AS due_at
|
|
397
|
+
FROM ${deliveries} AS delivery
|
|
398
|
+
WHERE delivery.key IS NULL
|
|
399
|
+
AND ${UNFINISHED_PREDICATE("delivery")}
|
|
400
|
+
AND ${dueAtExpression("delivery")} <= $1::timestamptz
|
|
401
|
+
AND ${HANDLER_PREDICATE}
|
|
402
|
+
ORDER BY due_at, delivery.id
|
|
403
|
+
LIMIT $3::int
|
|
404
|
+
FOR UPDATE OF delivery SKIP LOCKED
|
|
405
|
+
), candidates AS MATERIALIZED (
|
|
406
|
+
SELECT id, due_at FROM (
|
|
407
|
+
SELECT id, due_at FROM keyed
|
|
408
|
+
UNION ALL
|
|
409
|
+
SELECT id, due_at FROM unkeyed
|
|
410
|
+
) AS both_sets
|
|
411
|
+
ORDER BY due_at, id
|
|
412
|
+
LIMIT $3::int
|
|
413
|
+
), ${this.#leaseCandidates()}
|
|
414
|
+
SELECT false AS window_saturated, leased.*
|
|
415
|
+
FROM (SELECT 1) AS anchor
|
|
416
|
+
LEFT JOIN (${this.#leasedRows()}) AS leased ON true
|
|
417
|
+
ORDER BY leased.due_at, leased.delivery_id`;
|
|
418
|
+
}
|
|
419
|
+
/** True when another delivery of the candidate's handler and key holds a live lease. */
|
|
420
|
+
#liveLeaseElsewhere() {
|
|
421
|
+
return `EXISTS (
|
|
422
|
+
SELECT 1 FROM ${this.#names.deliveries} AS running
|
|
423
|
+
WHERE running.key IS NOT NULL
|
|
424
|
+
AND running.handler = delivery.handler AND running.key = delivery.key
|
|
425
|
+
AND running.status = 'processing' AND running.lease_expires_at > $1::timestamptz
|
|
426
|
+
AND running.id <> delivery.id
|
|
427
|
+
)`;
|
|
428
|
+
}
|
|
429
|
+
/** The `claimed` CTE: leases every row of `candidates`. */
|
|
430
|
+
#leaseCandidates() {
|
|
431
|
+
return `claimed AS (
|
|
432
|
+
UPDATE ${this.#names.deliveries} AS target
|
|
433
|
+
SET status = 'processing',
|
|
434
|
+
attempts = target.attempts + 1,
|
|
435
|
+
lease_owner = $4::text,
|
|
436
|
+
lease_token = gen_random_uuid()::text,
|
|
437
|
+
lease_expires_at = $5::timestamptz,
|
|
438
|
+
updated_at = $1::timestamptz
|
|
439
|
+
FROM candidates
|
|
440
|
+
WHERE target.id = candidates.id
|
|
441
|
+
RETURNING target.id, target.event_id, target.handler, target.attempts, target.lease_token, candidates.due_at
|
|
442
|
+
)`;
|
|
443
|
+
}
|
|
444
|
+
/** Leased deliveries joined with their events, as the claim result columns. */
|
|
445
|
+
#leasedRows() {
|
|
446
|
+
return `SELECT claimed.due_at, claimed.id AS delivery_id, claimed.handler, claimed.attempts::int AS attempts, claimed.lease_token,
|
|
447
|
+
${eventColumns("event")}
|
|
448
|
+
FROM claimed
|
|
449
|
+
JOIN ${this.#names.events} AS event ON event.id = claimed.event_id`;
|
|
450
|
+
}
|
|
451
|
+
/** Parameters: `[deliveryId, leaseToken, leaseExpiresAt, now]`. */
|
|
452
|
+
get extendLease() {
|
|
453
|
+
return countChanged(`UPDATE ${this.#names.deliveries}
|
|
454
|
+
SET lease_expires_at = $3::timestamptz, updated_at = $4::timestamptz
|
|
455
|
+
WHERE id = $1::text AND status = 'processing' AND lease_token = $2::text`);
|
|
456
|
+
}
|
|
457
|
+
/** Parameters: `[deliveryId, leaseToken, now]`. */
|
|
458
|
+
get complete() {
|
|
459
|
+
return countChanged(`UPDATE ${this.#names.deliveries}
|
|
460
|
+
SET status = 'completed', completed_at = $3::timestamptz, updated_at = $3::timestamptz, ${CLEAR_LEASE}
|
|
461
|
+
WHERE id = $1::text AND status = 'processing' AND lease_token = $2::text`);
|
|
462
|
+
}
|
|
463
|
+
/** Parameters: `[deliveryId, leaseToken, availableAt, errorJson, now]`. */
|
|
464
|
+
get reschedule() {
|
|
465
|
+
return countChanged(`UPDATE ${this.#names.deliveries}
|
|
466
|
+
SET status = 'pending', available_at = $3::timestamptz, last_error = $4::jsonb, updated_at = $5::timestamptz, ${CLEAR_LEASE}
|
|
467
|
+
WHERE id = $1::text AND status = 'processing' AND lease_token = $2::text`);
|
|
468
|
+
}
|
|
469
|
+
/** Parameters: `[deliveryId, leaseToken, errorJson, now]`. */
|
|
470
|
+
get bury() {
|
|
471
|
+
return countChanged(`UPDATE ${this.#names.deliveries}
|
|
472
|
+
SET status = 'dead', last_error = $3::jsonb, updated_at = $4::timestamptz, ${CLEAR_LEASE}
|
|
473
|
+
WHERE id = $1::text AND status = 'processing' AND lease_token = $2::text`);
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* Gives a leased delivery back without spending its attempt: pending, due
|
|
477
|
+
* `now`, attempt count decremented. Parameters: `[deliveryId, leaseToken, now]`.
|
|
478
|
+
*/
|
|
479
|
+
get release() {
|
|
480
|
+
return countChanged(`UPDATE ${this.#names.deliveries}
|
|
481
|
+
SET status = 'pending', available_at = $3::timestamptz, attempts = attempts - 1, updated_at = $3::timestamptz, ${CLEAR_LEASE}
|
|
482
|
+
WHERE id = $1::text AND status = 'processing' AND lease_token = $2::text`);
|
|
483
|
+
}
|
|
484
|
+
/** Parameters: `[deliveryIdsJson, now]`. */
|
|
485
|
+
get requeue() {
|
|
486
|
+
return countChanged(`UPDATE ${this.#names.deliveries}
|
|
487
|
+
SET status = 'pending', attempts = 0, available_at = $2::timestamptz, updated_at = $2::timestamptz
|
|
488
|
+
WHERE id = ANY (ARRAY(SELECT jsonb_array_elements_text($1::jsonb))) AND status = 'dead'`);
|
|
489
|
+
}
|
|
490
|
+
/** Builds the filtered, creation-ordered listing. */
|
|
491
|
+
listDeliveries(query) {
|
|
492
|
+
const parameters = [];
|
|
493
|
+
const conditions = [];
|
|
494
|
+
const bind = (value) => {
|
|
495
|
+
parameters.push(value);
|
|
496
|
+
return `$${parameters.length}::${typeof value === "number" ? "int" : "text"}`;
|
|
497
|
+
};
|
|
498
|
+
if (query.status !== void 0) {
|
|
499
|
+
conditions.push(`delivery.status = ${bind(query.status)}`);
|
|
500
|
+
}
|
|
501
|
+
if (query.handler !== void 0) {
|
|
502
|
+
conditions.push(`delivery.handler = ${bind(query.handler)}`);
|
|
503
|
+
}
|
|
504
|
+
if (query.eventType !== void 0) {
|
|
505
|
+
conditions.push(`event.type = ${bind(query.eventType)}`);
|
|
506
|
+
}
|
|
507
|
+
if (query.eventId !== void 0) {
|
|
508
|
+
conditions.push(`delivery.event_id = ${bind(query.eventId)}`);
|
|
509
|
+
}
|
|
510
|
+
const where = conditions.length === 0 ? "" : `WHERE ${conditions.join(" AND ")}`;
|
|
511
|
+
const limit = bind(query.limit ?? DEFAULT_LIST_LIMIT);
|
|
512
|
+
const sql = `SELECT delivery.id AS delivery_id, delivery.handler, delivery.status, delivery.attempts::int AS attempts,
|
|
513
|
+
${isoText("delivery.available_at")} AS available_at,
|
|
514
|
+
delivery.lease_owner,
|
|
515
|
+
${isoText("delivery.lease_expires_at")} AS lease_expires_at,
|
|
516
|
+
delivery.last_error::text AS last_error,
|
|
517
|
+
${isoText("delivery.created_at")} AS created_at,
|
|
518
|
+
${isoText("delivery.updated_at")} AS updated_at,
|
|
519
|
+
${isoText("delivery.completed_at")} AS completed_at,
|
|
520
|
+
${eventColumns("event")}
|
|
521
|
+
FROM ${this.#names.deliveries} AS delivery
|
|
522
|
+
JOIN ${this.#names.events} AS event ON event.id = delivery.event_id
|
|
523
|
+
${where}
|
|
524
|
+
ORDER BY delivery.created_at, delivery.id
|
|
525
|
+
LIMIT ${limit}`;
|
|
526
|
+
return { sql, parameters };
|
|
527
|
+
}
|
|
528
|
+
get countByStatus() {
|
|
529
|
+
return `SELECT status, count(*)::text AS count FROM ${this.#names.deliveries} GROUP BY status`;
|
|
530
|
+
}
|
|
531
|
+
/** Parameters: `[now]`. */
|
|
532
|
+
get oldestDue() {
|
|
533
|
+
return `SELECT ${isoText("due_at")} AS oldest_due_at FROM (
|
|
534
|
+
SELECT ${dueAtExpression("delivery")} AS due_at
|
|
535
|
+
FROM ${this.#names.deliveries} AS delivery
|
|
536
|
+
WHERE delivery.status IN ('pending', 'processing') AND ${dueAtExpression("delivery")} <= $1::timestamptz
|
|
537
|
+
ORDER BY due_at
|
|
538
|
+
LIMIT 1
|
|
539
|
+
) AS oldest`;
|
|
540
|
+
}
|
|
541
|
+
/**
|
|
542
|
+
* Deletes one bounded batch of purgeable deliveries and returns the event id
|
|
543
|
+
* of each deleted delivery. Parameters: `[cutoff, batchSize]`.
|
|
544
|
+
* `FOR UPDATE SKIP LOCKED` keeps purge from waiting on, or being waited on by, anything.
|
|
545
|
+
*/
|
|
546
|
+
purgeCompletedBatch() {
|
|
547
|
+
return this.#purgeBatch(
|
|
548
|
+
`status = 'completed' AND completed_at < $1::timestamptz`,
|
|
549
|
+
"completed_at"
|
|
550
|
+
);
|
|
551
|
+
}
|
|
552
|
+
purgeDeadBatch() {
|
|
553
|
+
return this.#purgeBatch(`status = 'dead' AND updated_at < $1::timestamptz`, "updated_at");
|
|
554
|
+
}
|
|
555
|
+
/**
|
|
556
|
+
* Selects and locks one bounded batch of events created before the cutoff
|
|
557
|
+
* that no delivery references. Parameters: `[cutoff, batchSize]`. The ids go
|
|
558
|
+
* through {@link deleteOrphanEvents}, which re-checks under a fresh snapshot.
|
|
559
|
+
*/
|
|
560
|
+
get lockOrphanEventsBatch() {
|
|
561
|
+
return `SELECT event.id FROM ${this.#names.events} AS event
|
|
562
|
+
WHERE event.created_at < $1::timestamptz
|
|
563
|
+
AND NOT EXISTS (SELECT 1 FROM ${this.#names.deliveries} AS delivery WHERE delivery.event_id = event.id)
|
|
564
|
+
ORDER BY event.created_at, event.id
|
|
565
|
+
LIMIT $2::int
|
|
566
|
+
FOR UPDATE OF event SKIP LOCKED`;
|
|
567
|
+
}
|
|
568
|
+
/**
|
|
569
|
+
* Locks the given events, skipping any that a concurrent transaction is
|
|
570
|
+
* referencing from a new delivery (a foreign key check holds `FOR KEY
|
|
571
|
+
* SHARE`, which conflicts with this lock). Parameters: `[eventIdsJson]`.
|
|
572
|
+
*/
|
|
573
|
+
get lockEvents() {
|
|
574
|
+
return `SELECT id FROM ${this.#names.events}
|
|
575
|
+
WHERE id = ANY (ARRAY(SELECT jsonb_array_elements_text($1::jsonb)))
|
|
576
|
+
FOR UPDATE SKIP LOCKED`;
|
|
577
|
+
}
|
|
578
|
+
/**
|
|
579
|
+
* Deletes locked events that no delivery references. It must run as a
|
|
580
|
+
* statement of its own after {@link lockEvents}: it then takes a fresh
|
|
581
|
+
* snapshot that includes every delivery committed before the lock was
|
|
582
|
+
* granted, so an event that just gained a delivery is never cascaded away.
|
|
583
|
+
* Parameters: `[eventIdsJson]`.
|
|
584
|
+
*/
|
|
585
|
+
get deleteOrphanEvents() {
|
|
586
|
+
const events = this.#names.events;
|
|
587
|
+
return countChanged(`DELETE FROM ${events} AS event
|
|
588
|
+
WHERE event.id = ANY (ARRAY(SELECT jsonb_array_elements_text($1::jsonb)))
|
|
589
|
+
AND NOT EXISTS (SELECT 1 FROM ${this.#names.deliveries} AS delivery WHERE delivery.event_id = event.id)`);
|
|
590
|
+
}
|
|
591
|
+
#purgeBatch(condition, orderColumn) {
|
|
592
|
+
const deliveries = this.#names.deliveries;
|
|
593
|
+
return `WITH batch AS MATERIALIZED (
|
|
594
|
+
SELECT id FROM ${deliveries}
|
|
595
|
+
WHERE ${condition}
|
|
596
|
+
ORDER BY ${orderColumn}, id
|
|
597
|
+
LIMIT $2::int
|
|
598
|
+
FOR UPDATE SKIP LOCKED
|
|
599
|
+
), deleted AS (
|
|
600
|
+
DELETE FROM ${deliveries} AS delivery USING batch WHERE delivery.id = batch.id RETURNING delivery.event_id
|
|
601
|
+
)
|
|
602
|
+
SELECT event_id FROM deleted`;
|
|
603
|
+
}
|
|
604
|
+
};
|
|
605
|
+
|
|
606
|
+
// src/postgres-event-store.ts
|
|
607
|
+
var DEFAULT_PURGE_BATCH_SIZE = 1e3;
|
|
608
|
+
var PostgresEventStore = class {
|
|
609
|
+
#executor;
|
|
610
|
+
#statements;
|
|
611
|
+
#schemaStatements;
|
|
612
|
+
#setupLockKey;
|
|
613
|
+
#purgeBatchSize;
|
|
614
|
+
#ownedPool;
|
|
615
|
+
constructor(options) {
|
|
616
|
+
const sources = [options.pool, options.connectionString, options.executor].filter(
|
|
617
|
+
(source) => source !== void 0
|
|
618
|
+
);
|
|
619
|
+
if (sources.length !== 1) {
|
|
620
|
+
throw new core.ConfigurationError(
|
|
621
|
+
"PostgresEventStore needs exactly one of pool, connectionString or executor"
|
|
622
|
+
);
|
|
623
|
+
}
|
|
624
|
+
const purgeBatchSize = options.purgeBatchSize ?? DEFAULT_PURGE_BATCH_SIZE;
|
|
625
|
+
if (!Number.isInteger(purgeBatchSize) || purgeBatchSize < 1) {
|
|
626
|
+
throw new core.ConfigurationError(
|
|
627
|
+
`purgeBatchSize must be a positive integer, received ${String(purgeBatchSize)}`
|
|
628
|
+
);
|
|
629
|
+
}
|
|
630
|
+
const names = resolvePostgresNames(options);
|
|
631
|
+
this.#statements = new PostgresStatements(names);
|
|
632
|
+
this.#schemaStatements = createPostgresSchemaStatements(options);
|
|
633
|
+
this.#setupLockKey = this.#statements.setupLockKey;
|
|
634
|
+
this.#purgeBatchSize = purgeBatchSize;
|
|
635
|
+
if (options.executor !== void 0) {
|
|
636
|
+
this.#executor = options.executor;
|
|
637
|
+
} else {
|
|
638
|
+
const pool = options.pool ?? new pg.Pool({ ...options.poolConfig, connectionString: options.connectionString });
|
|
639
|
+
if (options.pool === void 0) {
|
|
640
|
+
const logger = options.logger ?? console;
|
|
641
|
+
pool.on("error", (error) => {
|
|
642
|
+
logger.error(
|
|
643
|
+
`[PostgresEventStore](constructor) Idle connection failed, the pool replaces it | error=${error.message}`
|
|
644
|
+
);
|
|
645
|
+
});
|
|
646
|
+
this.#ownedPool = pool;
|
|
647
|
+
}
|
|
648
|
+
this.#executor = new PgExecutor(pool);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
/**
|
|
652
|
+
* Creates the schema, tables and indexes if they do not exist. Safe to call
|
|
653
|
+
* from many processes at once: a transaction-scoped advisory lock serializes
|
|
654
|
+
* the DDL, so racing `CREATE TABLE IF NOT EXISTS` statements cannot collide.
|
|
655
|
+
* Skip it (`setupStore: false`) when a migration tool applies
|
|
656
|
+
* `createPostgresSchemaSql()` instead.
|
|
657
|
+
*/
|
|
658
|
+
async setup() {
|
|
659
|
+
await this.#executor.transaction(async (transaction) => {
|
|
660
|
+
await this.#executor.query(this.#statements.setupLock, [this.#setupLockKey], transaction);
|
|
661
|
+
for (const statement of this.#schemaStatements) {
|
|
662
|
+
await this.#executor.query(statement, [], transaction);
|
|
663
|
+
}
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
transaction(work) {
|
|
667
|
+
return this.#executor.transaction(work);
|
|
668
|
+
}
|
|
669
|
+
/**
|
|
670
|
+
* Writes the entries in `transaction`. Keyed events reserve their sequence
|
|
671
|
+
* numbers first (locking the key rows in sorted order, so publishers cannot
|
|
672
|
+
* deadlock inside one call), then events, then deliveries, each in a single
|
|
673
|
+
* multi-row statement ordered by primary key. Two transactions that append
|
|
674
|
+
* keys in opposite order across several calls can still deadlock (40P01);
|
|
675
|
+
* the database rolls one back and the caller retries.
|
|
676
|
+
* A duplicate event id still consumes a sequence number; gaps are harmless
|
|
677
|
+
* because only the order of sequences matters.
|
|
678
|
+
*/
|
|
679
|
+
async append(transaction, entries, now) {
|
|
680
|
+
const plan = planAppend(entries);
|
|
681
|
+
const createdAt = now.toISOString();
|
|
682
|
+
const sequences = await this.#reserveSequences(plan.events, transaction);
|
|
683
|
+
const insertedEventIds = new Set(
|
|
684
|
+
plan.events.length === 0 ? [] : (await this.#executor.query(
|
|
685
|
+
this.#statements.insertEvents,
|
|
686
|
+
[JSON.stringify(toEventRows(plan.events, sequences)), createdAt],
|
|
687
|
+
transaction
|
|
688
|
+
)).map((row) => row["id"])
|
|
689
|
+
);
|
|
690
|
+
const insertedDeliveries = plan.deliveries.length === 0 ? [] : await this.#executor.query(
|
|
691
|
+
this.#statements.insertDeliveries,
|
|
692
|
+
[JSON.stringify(plan.deliveries), createdAt],
|
|
693
|
+
transaction
|
|
694
|
+
);
|
|
695
|
+
const appendedEventIds = [];
|
|
696
|
+
const duplicateEventIds = [];
|
|
697
|
+
for (const { eventId, repeated } of plan.outcomes) {
|
|
698
|
+
(!repeated && insertedEventIds.has(eventId) ? appendedEventIds : duplicateEventIds).push(
|
|
699
|
+
eventId
|
|
700
|
+
);
|
|
701
|
+
}
|
|
702
|
+
return {
|
|
703
|
+
appendedEventIds,
|
|
704
|
+
duplicateEventIds,
|
|
705
|
+
appendedDeliveryCount: insertedDeliveries.length
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
/**
|
|
709
|
+
* Leases up to `limit` due deliveries with `FOR UPDATE SKIP LOCKED`. The fast
|
|
710
|
+
* statement examines the first due deliveries; only when they are mostly
|
|
711
|
+
* blocked followers of busy keys does a second statement look at the heads of
|
|
712
|
+
* all keys. See {@link PostgresStatements.claim} for why that keeps a
|
|
713
|
+
* (handler, key) strictly one-at-a-time and in order.
|
|
714
|
+
*/
|
|
715
|
+
async claim(request) {
|
|
716
|
+
if (request.handlers.length === 0 || !(request.limit > 0)) {
|
|
717
|
+
return [];
|
|
718
|
+
}
|
|
719
|
+
const parameters = (limit) => [
|
|
720
|
+
request.now.toISOString(),
|
|
721
|
+
JSON.stringify(request.handlers),
|
|
722
|
+
limit,
|
|
723
|
+
request.leaseOwner,
|
|
724
|
+
request.leaseExpiresAt.toISOString()
|
|
725
|
+
];
|
|
726
|
+
const fastRows = await this.#executor.query(this.#statements.claim, parameters(request.limit));
|
|
727
|
+
const leased = fastRows.filter((row) => row["delivery_id"] !== null);
|
|
728
|
+
if (leased.length < request.limit && fastRows[0]["window_saturated"] === true) {
|
|
729
|
+
leased.push(
|
|
730
|
+
...(await this.#executor.query(
|
|
731
|
+
this.#statements.claimBehindBlockedKeys,
|
|
732
|
+
parameters(request.limit - leased.length)
|
|
733
|
+
)).filter((row) => row["delivery_id"] !== null)
|
|
734
|
+
);
|
|
735
|
+
}
|
|
736
|
+
return leased.map((row) => mapClaimedDelivery(row, request.leaseExpiresAt));
|
|
737
|
+
}
|
|
738
|
+
extendLease(lease, leaseExpiresAt, now) {
|
|
739
|
+
return this.#changed(this.#statements.extendLease, [
|
|
740
|
+
lease.deliveryId,
|
|
741
|
+
lease.leaseToken,
|
|
742
|
+
leaseExpiresAt.toISOString(),
|
|
743
|
+
now.toISOString()
|
|
744
|
+
]);
|
|
745
|
+
}
|
|
746
|
+
complete(lease, now, transaction) {
|
|
747
|
+
return this.#changed(
|
|
748
|
+
this.#statements.complete,
|
|
749
|
+
[lease.deliveryId, lease.leaseToken, now.toISOString()],
|
|
750
|
+
transaction
|
|
751
|
+
);
|
|
752
|
+
}
|
|
753
|
+
reschedule(lease, availableAt, error, now) {
|
|
754
|
+
return this.#changed(this.#statements.reschedule, [
|
|
755
|
+
lease.deliveryId,
|
|
756
|
+
lease.leaseToken,
|
|
757
|
+
availableAt.toISOString(),
|
|
758
|
+
JSON.stringify(error),
|
|
759
|
+
now.toISOString()
|
|
760
|
+
]);
|
|
761
|
+
}
|
|
762
|
+
bury(lease, error, now) {
|
|
763
|
+
return this.#changed(this.#statements.bury, [
|
|
764
|
+
lease.deliveryId,
|
|
765
|
+
lease.leaseToken,
|
|
766
|
+
JSON.stringify(error),
|
|
767
|
+
now.toISOString()
|
|
768
|
+
]);
|
|
769
|
+
}
|
|
770
|
+
/** Returns a leased delivery to pending, due now, without counting the attempt. */
|
|
771
|
+
release(lease, now) {
|
|
772
|
+
return this.#changed(this.#statements.release, [
|
|
773
|
+
lease.deliveryId,
|
|
774
|
+
lease.leaseToken,
|
|
775
|
+
now.toISOString()
|
|
776
|
+
]);
|
|
777
|
+
}
|
|
778
|
+
async listDeliveries(query) {
|
|
779
|
+
const statement = this.#statements.listDeliveries(query);
|
|
780
|
+
const rows = await this.#executor.query(statement.sql, statement.parameters);
|
|
781
|
+
return rows.map(mapDeliveryRecord);
|
|
782
|
+
}
|
|
783
|
+
async requeue(deliveryIds, now) {
|
|
784
|
+
const rows = await this.#executor.query(this.#statements.requeue, [
|
|
785
|
+
JSON.stringify(deliveryIds),
|
|
786
|
+
now.toISOString()
|
|
787
|
+
]);
|
|
788
|
+
return readCount(rows[0], "count");
|
|
789
|
+
}
|
|
790
|
+
/**
|
|
791
|
+
* Deletes purgeable deliveries in batches of `purgeBatchSize`, one short
|
|
792
|
+
* transaction per batch, then the events those batches left without any
|
|
793
|
+
* delivery. Finally, events created before the earliest given cutoff that
|
|
794
|
+
* have no delivery at all are removed: they are what a publish leaves behind
|
|
795
|
+
* when its last delivery was purged in the same instant, or when no handler
|
|
796
|
+
* was subscribed. Events that a concurrent publish is attaching a delivery
|
|
797
|
+
* to are skipped and kept.
|
|
798
|
+
*/
|
|
799
|
+
async purge(request) {
|
|
800
|
+
const total = { deletedDeliveries: 0, deletedEvents: 0 };
|
|
801
|
+
const batches = [
|
|
802
|
+
[this.#statements.purgeCompletedBatch(), request.completedBefore],
|
|
803
|
+
[this.#statements.purgeDeadBatch(), request.deadBefore]
|
|
804
|
+
];
|
|
805
|
+
for (const [sql, cutoff] of batches) {
|
|
806
|
+
if (cutoff === void 0) {
|
|
807
|
+
continue;
|
|
808
|
+
}
|
|
809
|
+
let deletedInBatch = this.#purgeBatchSize;
|
|
810
|
+
while (deletedInBatch === this.#purgeBatchSize) {
|
|
811
|
+
const batch = await this.#purgeBatch(sql, cutoff);
|
|
812
|
+
deletedInBatch = batch.deletedDeliveries;
|
|
813
|
+
total.deletedDeliveries += batch.deletedDeliveries;
|
|
814
|
+
total.deletedEvents += batch.deletedEvents;
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
const orphanCutoff = [request.completedBefore, request.deadBefore].filter((cutoff) => cutoff !== void 0).sort((first, second) => first.getTime() - second.getTime())[0];
|
|
818
|
+
if (orphanCutoff !== void 0) {
|
|
819
|
+
total.deletedEvents += await this.#purgeOrphanEvents(orphanCutoff);
|
|
820
|
+
}
|
|
821
|
+
return total;
|
|
822
|
+
}
|
|
823
|
+
async statistics(now) {
|
|
824
|
+
const counts = {
|
|
825
|
+
pending: 0,
|
|
826
|
+
processing: 0,
|
|
827
|
+
completed: 0,
|
|
828
|
+
dead: 0
|
|
829
|
+
};
|
|
830
|
+
for (const row of await this.#executor.query(this.#statements.countByStatus, [])) {
|
|
831
|
+
counts[row["status"]] = readCount(row, "count");
|
|
832
|
+
}
|
|
833
|
+
const oldestDue = await this.#executor.query(this.#statements.oldestDue, [now.toISOString()]);
|
|
834
|
+
return { ...counts, oldestDueAt: mapOldestDueAt(oldestDue) };
|
|
835
|
+
}
|
|
836
|
+
/** Ends the pool this store created from `connectionString`. Pools and executors you passed in stay open. */
|
|
837
|
+
async close() {
|
|
838
|
+
const pool = this.#ownedPool;
|
|
839
|
+
this.#ownedPool = void 0;
|
|
840
|
+
await pool?.end();
|
|
841
|
+
}
|
|
842
|
+
async #changed(sql, parameters, transaction) {
|
|
843
|
+
const rows = await this.#executor.query(sql, parameters, transaction);
|
|
844
|
+
return readCount(rows[0], "count") > 0;
|
|
845
|
+
}
|
|
846
|
+
async #reserveSequences(events, transaction) {
|
|
847
|
+
const counts = /* @__PURE__ */ new Map();
|
|
848
|
+
for (const event of events) {
|
|
849
|
+
if (event.key !== null) {
|
|
850
|
+
counts.set(event.key, (counts.get(event.key) ?? 0) + 1);
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
const firstSequences = /* @__PURE__ */ new Map();
|
|
854
|
+
if (counts.size === 0) {
|
|
855
|
+
return firstSequences;
|
|
856
|
+
}
|
|
857
|
+
const requested = [...counts].map(([key, count]) => ({ key, count }));
|
|
858
|
+
const rows = await this.#executor.query(
|
|
859
|
+
this.#statements.reserveSequences,
|
|
860
|
+
[JSON.stringify(requested)],
|
|
861
|
+
transaction
|
|
862
|
+
);
|
|
863
|
+
for (const row of rows) {
|
|
864
|
+
const key = row["key"];
|
|
865
|
+
firstSequences.set(key, BigInt(row["sequence"]) - BigInt(counts.get(key)) + 1n);
|
|
866
|
+
}
|
|
867
|
+
return firstSequences;
|
|
868
|
+
}
|
|
869
|
+
async #purgeOrphanEvents(cutoff) {
|
|
870
|
+
let deletedEvents = 0;
|
|
871
|
+
let removedInBatch = this.#purgeBatchSize;
|
|
872
|
+
while (removedInBatch === this.#purgeBatchSize) {
|
|
873
|
+
removedInBatch = await this.#executor.transaction(async (transaction) => {
|
|
874
|
+
const locked = await this.#executor.query(
|
|
875
|
+
this.#statements.lockOrphanEventsBatch,
|
|
876
|
+
[cutoff.toISOString(), this.#purgeBatchSize],
|
|
877
|
+
transaction
|
|
878
|
+
);
|
|
879
|
+
const removed = await this.#executor.query(
|
|
880
|
+
this.#statements.deleteOrphanEvents,
|
|
881
|
+
[JSON.stringify(locked.map((row) => row["id"]))],
|
|
882
|
+
transaction
|
|
883
|
+
);
|
|
884
|
+
return readCount(removed[0], "count");
|
|
885
|
+
});
|
|
886
|
+
deletedEvents += removedInBatch;
|
|
887
|
+
}
|
|
888
|
+
return deletedEvents;
|
|
889
|
+
}
|
|
890
|
+
async #purgeBatch(sql, cutoff) {
|
|
891
|
+
return this.#executor.transaction(async (transaction) => {
|
|
892
|
+
const deleted = await this.#executor.query(
|
|
893
|
+
sql,
|
|
894
|
+
[cutoff.toISOString(), this.#purgeBatchSize],
|
|
895
|
+
transaction
|
|
896
|
+
);
|
|
897
|
+
if (deleted.length === 0) {
|
|
898
|
+
return { deletedDeliveries: 0, deletedEvents: 0 };
|
|
899
|
+
}
|
|
900
|
+
const eventIds = JSON.stringify([
|
|
901
|
+
...new Set(deleted.map((row) => row["event_id"]))
|
|
902
|
+
]);
|
|
903
|
+
const locked = await this.#executor.query(
|
|
904
|
+
this.#statements.lockEvents,
|
|
905
|
+
[eventIds],
|
|
906
|
+
transaction
|
|
907
|
+
);
|
|
908
|
+
const removed = await this.#executor.query(
|
|
909
|
+
this.#statements.deleteOrphanEvents,
|
|
910
|
+
[JSON.stringify(locked.map((row) => row["id"]))],
|
|
911
|
+
transaction
|
|
912
|
+
);
|
|
913
|
+
return { deletedDeliveries: deleted.length, deletedEvents: readCount(removed[0], "count") };
|
|
914
|
+
});
|
|
915
|
+
}
|
|
916
|
+
};
|
|
917
|
+
function planAppend(entries) {
|
|
918
|
+
const events = [];
|
|
919
|
+
const deliveries = [];
|
|
920
|
+
const outcomes = [];
|
|
921
|
+
const seenEventIds = /* @__PURE__ */ new Set();
|
|
922
|
+
const seenDeliveries = /* @__PURE__ */ new Set();
|
|
923
|
+
for (const { event, deliveries: newDeliveries } of entries) {
|
|
924
|
+
const repeated = seenEventIds.has(event.id);
|
|
925
|
+
seenEventIds.add(event.id);
|
|
926
|
+
outcomes.push({ eventId: event.id, repeated });
|
|
927
|
+
if (!repeated) {
|
|
928
|
+
events.push(event);
|
|
929
|
+
}
|
|
930
|
+
for (const delivery of newDeliveries) {
|
|
931
|
+
const identity = `${event.id}\0${delivery.handler}`;
|
|
932
|
+
if (!seenDeliveries.has(identity)) {
|
|
933
|
+
seenDeliveries.add(identity);
|
|
934
|
+
deliveries.push({
|
|
935
|
+
id: delivery.id,
|
|
936
|
+
event_id: event.id,
|
|
937
|
+
handler: delivery.handler,
|
|
938
|
+
available_at: delivery.availableAt.toISOString()
|
|
939
|
+
});
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
return { events, deliveries, outcomes };
|
|
944
|
+
}
|
|
945
|
+
function toEventRows(events, firstSequences) {
|
|
946
|
+
const nextSequences = new Map(firstSequences);
|
|
947
|
+
return events.map((event) => {
|
|
948
|
+
let sequence = null;
|
|
949
|
+
if (event.key !== null) {
|
|
950
|
+
const next = nextSequences.get(event.key);
|
|
951
|
+
nextSequences.set(event.key, next + 1n);
|
|
952
|
+
sequence = next.toString();
|
|
953
|
+
}
|
|
954
|
+
return {
|
|
955
|
+
id: event.id,
|
|
956
|
+
type: event.type,
|
|
957
|
+
source: event.source,
|
|
958
|
+
key: event.key,
|
|
959
|
+
sequence,
|
|
960
|
+
payload: event.payload,
|
|
961
|
+
headers: event.headers,
|
|
962
|
+
correlation_id: event.correlationId,
|
|
963
|
+
causation_id: event.causationId,
|
|
964
|
+
occurred_at: event.occurredAt.toISOString()
|
|
965
|
+
};
|
|
966
|
+
});
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
exports.PgExecutor = PgExecutor;
|
|
970
|
+
exports.PostgresEventStore = PostgresEventStore;
|
|
971
|
+
exports.createPostgresSchemaSql = createPostgresSchemaSql;
|
|
972
|
+
exports.createPostgresSchemaStatements = createPostgresSchemaStatements;
|
|
973
|
+
exports.resolvePostgresNames = resolvePostgresNames;
|
|
974
|
+
//# sourceMappingURL=index.cjs.map
|
|
975
|
+
//# sourceMappingURL=index.cjs.map
|