@relaybus/mongodb 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +251 -88
- package/dist/index.cjs +1957 -510
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +159 -20
- package/dist/index.d.ts +159 -20
- package/dist/index.js +1958 -511
- package/dist/index.js.map +1 -1
- package/package.json +22 -9
package/dist/index.js
CHANGED
|
@@ -1,73 +1,54 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createUuidV7, ConfigurationError, EventValidationError } from '@relaybus/core';
|
|
2
|
+
import { assertPositiveInteger } from '@relaybus/core/internal';
|
|
2
3
|
|
|
3
4
|
// src/mongo-event-store.ts
|
|
4
5
|
|
|
5
|
-
// src/
|
|
6
|
-
var
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
return known?.leaseHeld === true ? "live-lease" : void 0;
|
|
15
|
-
}
|
|
16
|
-
/** Records an unfinished delivery of the key with this sequence. */
|
|
17
|
-
noteOpen(orderingId, sequence) {
|
|
18
|
-
const known = this.#know(orderingId);
|
|
19
|
-
known.openSequence = Math.min(known.openSequence ?? sequence, sequence);
|
|
20
|
-
}
|
|
21
|
-
/** Records that this scan leased the delivery, which is now unfinished and holds a live lease. */
|
|
22
|
-
noteClaimed(orderingId, sequence) {
|
|
23
|
-
this.noteOpen(orderingId, sequence);
|
|
24
|
-
this.#know(orderingId).leaseHeld = true;
|
|
25
|
-
}
|
|
26
|
-
/** Records that a live lease of the key blocks every delivery of it. */
|
|
27
|
-
noteLeaseHeld(orderingId) {
|
|
28
|
-
this.#know(orderingId).leaseHeld = true;
|
|
29
|
-
}
|
|
30
|
-
/** Remembers a delivery that waited behind an unfinished predecessor. */
|
|
31
|
-
noteWaiting(orderingId, delivery) {
|
|
32
|
-
if (delivery.status === "pending") {
|
|
33
|
-
this.#know(orderingId).waiting.push(delivery._id);
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
/**
|
|
37
|
-
* Ids of the waiting deliveries of every key that accumulated at least
|
|
38
|
-
* `minimum` of them. Shorter queues stay in the claim index: moving them out
|
|
39
|
-
* and back would cost more than skipping them.
|
|
40
|
-
*/
|
|
41
|
-
waitingBacklogs(minimum) {
|
|
42
|
-
return [...this.#keys.values()].filter((known) => known.waiting.length >= minimum).flatMap((known) => known.waiting);
|
|
6
|
+
// src/lanes.ts
|
|
7
|
+
var LANE_COUNT = 256;
|
|
8
|
+
var FNV_OFFSET_BASIS = 2166136261;
|
|
9
|
+
var FNV_PRIME = 16777619;
|
|
10
|
+
var utf8 = new TextEncoder();
|
|
11
|
+
function laneOf(key, eventId) {
|
|
12
|
+
let hash = FNV_OFFSET_BASIS;
|
|
13
|
+
for (const byte of utf8.encode(key ?? eventId)) {
|
|
14
|
+
hash = Math.imul(hash ^ byte, FNV_PRIME);
|
|
43
15
|
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
16
|
+
return hash & LANE_COUNT - 1;
|
|
17
|
+
}
|
|
18
|
+
function formatLeaseToken(laneId, { epoch, claims }) {
|
|
19
|
+
return `${laneId}:${epoch}:${claims}`;
|
|
20
|
+
}
|
|
21
|
+
var LEASE_TOKEN_PATTERN = /^(\d{1,3}):(\d{1,15}):(\d{1,15})$/;
|
|
22
|
+
function parseLeaseToken(token) {
|
|
23
|
+
const match = LEASE_TOKEN_PATTERN.exec(token);
|
|
24
|
+
if (match === null || Number(match[1]) >= LANE_COUNT) {
|
|
25
|
+
return void 0;
|
|
51
26
|
}
|
|
52
|
-
};
|
|
53
|
-
function orderingIdentity(handler, key) {
|
|
54
|
-
return `${handler}\0${key}`;
|
|
27
|
+
return { laneId: Number(match[1]), epoch: Number(match[2]), claims: Number(match[3]) };
|
|
55
28
|
}
|
|
56
|
-
|
|
29
|
+
|
|
30
|
+
// src/documents.ts
|
|
31
|
+
function toEventDocument(event, {
|
|
32
|
+
sequence,
|
|
33
|
+
handlers,
|
|
34
|
+
createdAt
|
|
35
|
+
}) {
|
|
57
36
|
const payloadJson = serializeWellFormed(event.payload, "payload");
|
|
58
37
|
const headersJson = serializeWellFormed(event.headers, "headers");
|
|
59
38
|
return {
|
|
60
39
|
_id: event.id,
|
|
61
40
|
type: event.type,
|
|
62
41
|
source: event.source,
|
|
63
|
-
|
|
64
|
-
|
|
42
|
+
// Fields that are null or 0 are left out: every byte of a new document is written to the oplog too.
|
|
43
|
+
...event.key === null ? {} : { key: event.key },
|
|
44
|
+
...sequence === null ? {} : { sequence },
|
|
65
45
|
...payloadJson === void 0 ? {} : { payload: JSON.parse(payloadJson) },
|
|
66
46
|
headers: JSON.parse(headersJson),
|
|
67
|
-
correlationId: event.correlationId,
|
|
68
|
-
causationId: event.causationId,
|
|
47
|
+
...event.correlationId === null ? {} : { correlationId: event.correlationId },
|
|
48
|
+
...event.causationId === null ? {} : { causationId: event.causationId },
|
|
69
49
|
occurredAt: event.occurredAt,
|
|
70
|
-
|
|
50
|
+
handlers,
|
|
51
|
+
createdAt
|
|
71
52
|
};
|
|
72
53
|
}
|
|
73
54
|
var LONE_SURROGATE_ESCAPE = /(?<!\\)(?:\\\\)*\\ud[89a-f][0-9a-f]{2}/i;
|
|
@@ -88,48 +69,94 @@ function toEnvelope(document) {
|
|
|
88
69
|
id: document._id,
|
|
89
70
|
type: document.type,
|
|
90
71
|
source: document.source,
|
|
91
|
-
key: document.key,
|
|
72
|
+
key: document.key ?? null,
|
|
92
73
|
payload: document.payload,
|
|
93
74
|
headers: document.headers,
|
|
94
|
-
correlationId: document.correlationId,
|
|
95
|
-
causationId: document.causationId,
|
|
75
|
+
correlationId: document.correlationId ?? null,
|
|
76
|
+
causationId: document.causationId ?? null,
|
|
96
77
|
occurredAt: document.occurredAt
|
|
97
78
|
};
|
|
98
79
|
}
|
|
99
|
-
function toDeliveryDocument(delivery,
|
|
80
|
+
function toDeliveryDocument(delivery, {
|
|
81
|
+
event,
|
|
82
|
+
ordering,
|
|
83
|
+
now
|
|
84
|
+
}) {
|
|
100
85
|
return {
|
|
101
86
|
_id: delivery.id,
|
|
102
87
|
eventId: event.id,
|
|
103
88
|
eventType: event.type,
|
|
104
89
|
handler: delivery.handler,
|
|
105
90
|
...ordering.key === null || ordering.sequence === null ? {} : { key: ordering.key, sequence: ordering.sequence },
|
|
91
|
+
laneId: laneOf(ordering.key, event.id),
|
|
106
92
|
status: "pending",
|
|
107
93
|
lane: "ready",
|
|
108
94
|
attempts: 0,
|
|
95
|
+
// No updatedAt (it equals createdAt): every byte of a new document is written to the oplog too.
|
|
109
96
|
availableAt: delivery.availableAt,
|
|
110
97
|
dueAt: delivery.availableAt,
|
|
111
|
-
createdAt: now
|
|
112
|
-
updatedAt: now
|
|
98
|
+
createdAt: now
|
|
113
99
|
};
|
|
114
100
|
}
|
|
115
|
-
function toDeliveryRecord(document,
|
|
101
|
+
function toDeliveryRecord(document, {
|
|
102
|
+
event,
|
|
103
|
+
runningIn
|
|
104
|
+
}) {
|
|
116
105
|
return {
|
|
117
106
|
id: document._id,
|
|
118
107
|
handler: document.handler,
|
|
119
|
-
status: document.status,
|
|
120
|
-
attempts: document.attempts,
|
|
108
|
+
status: runningIn === void 0 ? document.status : "processing",
|
|
109
|
+
attempts: runningIn === void 0 ? document.attempts : document.attempts + 1,
|
|
121
110
|
availableAt: document.availableAt,
|
|
122
|
-
leaseOwner:
|
|
123
|
-
leaseExpiresAt:
|
|
111
|
+
leaseOwner: runningIn === void 0 ? null : runningIn.owner,
|
|
112
|
+
leaseExpiresAt: runningIn === void 0 ? null : runningIn.leaseExpiresAt,
|
|
124
113
|
lastError: document.lastError === void 0 ? null : toErrorInfo(document.lastError),
|
|
125
114
|
createdAt: document.createdAt,
|
|
126
|
-
updatedAt: document.updatedAt,
|
|
115
|
+
updatedAt: document.updatedAt ?? document.createdAt,
|
|
127
116
|
completedAt: document.completedAt ?? null,
|
|
128
117
|
event
|
|
129
118
|
};
|
|
130
119
|
}
|
|
120
|
+
function toCompletedDeliveryRecord(document, event) {
|
|
121
|
+
return {
|
|
122
|
+
id: document._id,
|
|
123
|
+
handler: document.handler,
|
|
124
|
+
status: "completed",
|
|
125
|
+
attempts: document.attempts,
|
|
126
|
+
availableAt: document.availableAt,
|
|
127
|
+
leaseOwner: null,
|
|
128
|
+
leaseExpiresAt: null,
|
|
129
|
+
lastError: null,
|
|
130
|
+
createdAt: document.createdAt,
|
|
131
|
+
updatedAt: document.completedAt,
|
|
132
|
+
completedAt: document.completedAt,
|
|
133
|
+
event
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
function toCompletedDeliveryDocument(document, completedAt) {
|
|
137
|
+
return {
|
|
138
|
+
_id: document._id,
|
|
139
|
+
eventId: document.eventId,
|
|
140
|
+
eventType: document.eventType,
|
|
141
|
+
handler: document.handler,
|
|
142
|
+
attempts: document.attempts + 1,
|
|
143
|
+
availableAt: document.availableAt,
|
|
144
|
+
createdAt: document.createdAt,
|
|
145
|
+
completedAt
|
|
146
|
+
};
|
|
147
|
+
}
|
|
131
148
|
function toErrorInfo(error) {
|
|
132
|
-
|
|
149
|
+
const info = {
|
|
150
|
+
name: error.name,
|
|
151
|
+
message: error.message
|
|
152
|
+
};
|
|
153
|
+
if (error.stack !== void 0) {
|
|
154
|
+
info.stack = error.stack;
|
|
155
|
+
}
|
|
156
|
+
if (error.code !== void 0) {
|
|
157
|
+
info.code = error.code;
|
|
158
|
+
}
|
|
159
|
+
return info;
|
|
133
160
|
}
|
|
134
161
|
var ILLEGAL_OPERATION = 20;
|
|
135
162
|
function isTransactionsUnsupportedError(error) {
|
|
@@ -141,184 +168,97 @@ function createTransactionsUnsupportedError(cause) {
|
|
|
141
168
|
{ cause }
|
|
142
169
|
);
|
|
143
170
|
}
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
function
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
171
|
+
|
|
172
|
+
// src/mongo-store-state.ts
|
|
173
|
+
async function runInTransaction(state, work) {
|
|
174
|
+
return runInTransactionWith(state, { work, transactionOptions: state.transactionOptions });
|
|
175
|
+
}
|
|
176
|
+
async function runInBudgetedTransaction(state, {
|
|
177
|
+
work,
|
|
178
|
+
timeoutMs
|
|
179
|
+
}) {
|
|
180
|
+
const budget = Math.ceil(timeoutMs);
|
|
181
|
+
return runInTransactionWith(state, {
|
|
182
|
+
work,
|
|
183
|
+
transactionOptions: { ...state.transactionOptions, maxCommitTimeMS: budget }
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
async function runInTransactionWith(state, {
|
|
187
|
+
work,
|
|
188
|
+
transactionOptions
|
|
189
|
+
}) {
|
|
190
|
+
try {
|
|
191
|
+
return await state.client.withSession(async (session) => {
|
|
192
|
+
let result;
|
|
193
|
+
await session.withTransaction(async () => {
|
|
194
|
+
result = await work(session);
|
|
195
|
+
}, transactionOptions);
|
|
196
|
+
return result;
|
|
197
|
+
});
|
|
198
|
+
} catch (error) {
|
|
199
|
+
if (isTransactionsUnsupportedError(error)) {
|
|
200
|
+
throw createTransactionsUnsupportedError(error);
|
|
201
|
+
}
|
|
202
|
+
throw error;
|
|
151
203
|
}
|
|
152
204
|
}
|
|
153
|
-
function
|
|
154
|
-
|
|
205
|
+
function leaseKey(deliveryId, leaseToken) {
|
|
206
|
+
return `${deliveryId}\0${leaseToken}`;
|
|
207
|
+
}
|
|
208
|
+
function assertInTransaction(session, operation) {
|
|
209
|
+
if (!session.inTransaction()) {
|
|
155
210
|
throw new ConfigurationError(
|
|
156
|
-
`
|
|
211
|
+
`MongoEventStore.${operation} needs a ClientSession inside a started transaction (session.withTransaction or startTransaction). Without one, MongoDB commits each write immediately and the write is no longer atomic with yours`
|
|
157
212
|
);
|
|
158
213
|
}
|
|
159
214
|
}
|
|
215
|
+
function atCount(count) {
|
|
216
|
+
return count === 0 ? { $not: { $gt: 0 } } : count;
|
|
217
|
+
}
|
|
160
218
|
|
|
161
|
-
// src/mongo-
|
|
162
|
-
var
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
};
|
|
167
|
-
var NAMESPACE_EXISTS = 48;
|
|
168
|
-
var DEFAULT_PURGE_BATCH_SIZE = 500;
|
|
169
|
-
var DEFAULT_LIST_LIMIT = 100;
|
|
170
|
-
var MINIMUM_CANDIDATE_BATCH_SIZE = 32;
|
|
171
|
-
var PARK_BACKLOG_THRESHOLD = 32;
|
|
172
|
-
var PARK_UPDATE_CHUNK_SIZE = 1e3;
|
|
173
|
-
var LEASE_FIELDS = { leaseOwner: "", leaseToken: "", leaseExpiresAt: "" };
|
|
174
|
-
var FINISHED_FIELDS = { dueAt: "", lane: "" };
|
|
175
|
-
var MongoEventStore = class {
|
|
176
|
-
#client;
|
|
177
|
-
#database;
|
|
178
|
-
#events;
|
|
179
|
-
#deliveries;
|
|
180
|
-
#keySequences;
|
|
181
|
-
#transactionOptions;
|
|
182
|
-
#purgeBatchSize;
|
|
183
|
-
#generateIdentifier;
|
|
184
|
-
constructor(options) {
|
|
185
|
-
const prefix = options.collectionPrefix ?? "relaybus_";
|
|
186
|
-
assertCollectionPrefix(prefix);
|
|
187
|
-
if (options.database !== void 0) {
|
|
188
|
-
assertDatabaseName(options.database);
|
|
189
|
-
}
|
|
190
|
-
const purgeBatchSize = options.purgeBatchSize ?? DEFAULT_PURGE_BATCH_SIZE;
|
|
191
|
-
if (!Number.isInteger(purgeBatchSize) || purgeBatchSize < 1) {
|
|
192
|
-
throw new ConfigurationError(
|
|
193
|
-
`purgeBatchSize must be a positive integer, received ${String(purgeBatchSize)}`
|
|
194
|
-
);
|
|
195
|
-
}
|
|
196
|
-
this.#client = options.client;
|
|
197
|
-
const database = options.client.db(options.database);
|
|
198
|
-
this.#database = database;
|
|
199
|
-
const collectionOptions = {
|
|
200
|
-
readPreference: "primary",
|
|
201
|
-
writeConcern: { w: "majority" }
|
|
202
|
-
};
|
|
203
|
-
this.#events = database.collection(`${prefix}events`, collectionOptions);
|
|
204
|
-
this.#deliveries = database.collection(`${prefix}deliveries`, collectionOptions);
|
|
205
|
-
this.#keySequences = database.collection(`${prefix}key_sequences`, collectionOptions);
|
|
206
|
-
this.#transactionOptions = options.transactionOptions ?? DEFAULT_TRANSACTION_OPTIONS;
|
|
207
|
-
this.#purgeBatchSize = purgeBatchSize;
|
|
208
|
-
this.#generateIdentifier = options.generateIdentifier ?? (() => createUuidV7());
|
|
209
|
-
}
|
|
210
|
-
/**
|
|
211
|
-
* Verifies the deployment supports transactions and creates the indexes.
|
|
212
|
-
* Safe to run repeatedly and from several processes.
|
|
213
|
-
*
|
|
214
|
-
* @throws ConfigurationError when the server is a standalone mongod.
|
|
215
|
-
*/
|
|
216
|
-
async setup() {
|
|
217
|
-
const hello = await this.#client.db("admin").command({ hello: 1 });
|
|
218
|
-
const supportsTransactions = typeof hello["setName"] === "string" || hello["msg"] === "isdbgrid";
|
|
219
|
-
if (!supportsTransactions) {
|
|
220
|
-
throw createTransactionsUnsupportedError(void 0);
|
|
221
|
-
}
|
|
222
|
-
for (const collection of [this.#events, this.#deliveries, this.#keySequences]) {
|
|
223
|
-
await this.#createCollectionIfMissing(collection.collectionName);
|
|
224
|
-
}
|
|
225
|
-
await this.#deliveries.createIndexes([
|
|
226
|
-
// Idempotent appends and lookups by event.
|
|
227
|
-
{ key: { eventId: 1, handler: 1 }, name: "event_handler", unique: true },
|
|
228
|
-
// Claim: due work of a handler in due order. Holds ready deliveries only.
|
|
229
|
-
{
|
|
230
|
-
key: { handler: 1, dueAt: 1, _id: 1 },
|
|
231
|
-
name: "claim",
|
|
232
|
-
partialFilterExpression: { lane: "ready" }
|
|
233
|
-
},
|
|
234
|
-
// Ordering: unfinished predecessors of a keyed delivery, parked or not.
|
|
235
|
-
{
|
|
236
|
-
key: { handler: 1, key: 1, sequence: 1 },
|
|
237
|
-
name: "ordering",
|
|
238
|
-
partialFilterExpression: { dueAt: { $exists: true }, sequence: { $exists: true } }
|
|
239
|
-
},
|
|
240
|
-
// Parked deliveries: the lowest parked delivery of each key, to move it back once it is next.
|
|
241
|
-
{
|
|
242
|
-
key: { handler: 1, key: 1, sequence: 1 },
|
|
243
|
-
name: "parked",
|
|
244
|
-
partialFilterExpression: { lane: "parked" }
|
|
245
|
-
},
|
|
246
|
-
// Live leases: a keyed delivery may not start while another of its key is running.
|
|
247
|
-
{
|
|
248
|
-
key: { handler: 1, key: 1, dueAt: 1 },
|
|
249
|
-
name: "running",
|
|
250
|
-
partialFilterExpression: { status: "processing", key: { $exists: true } }
|
|
251
|
-
},
|
|
252
|
-
// Statistics: oldest due delivery across handlers.
|
|
253
|
-
{ key: { dueAt: 1 }, name: "due", partialFilterExpression: { dueAt: { $exists: true } } },
|
|
254
|
-
// Purge of completed deliveries.
|
|
255
|
-
{
|
|
256
|
-
key: { completedAt: 1 },
|
|
257
|
-
name: "completed",
|
|
258
|
-
partialFilterExpression: { completedAt: { $exists: true } }
|
|
259
|
-
},
|
|
260
|
-
// Listing and status counts.
|
|
261
|
-
{ key: { status: 1, createdAt: 1 }, name: "status_created" },
|
|
262
|
-
// Purge of dead deliveries.
|
|
263
|
-
{ key: { status: 1, updatedAt: 1 }, name: "status_updated" },
|
|
264
|
-
{ key: { handler: 1, createdAt: 1 }, name: "handler_created" },
|
|
265
|
-
{ key: { eventType: 1, createdAt: 1 }, name: "type_created" }
|
|
266
|
-
]);
|
|
267
|
-
}
|
|
268
|
-
async #createCollectionIfMissing(name) {
|
|
269
|
-
try {
|
|
270
|
-
await this.#database.createCollection(name);
|
|
271
|
-
} catch (error) {
|
|
272
|
-
if (!(typeof error === "object" && error !== null && "code" in error && error.code === NAMESPACE_EXISTS)) {
|
|
273
|
-
throw error;
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
}
|
|
277
|
-
/**
|
|
278
|
-
* Runs `work` in a transaction on a new session and commits when it resolves.
|
|
279
|
-
*
|
|
280
|
-
* `work` can run more than once. The driver restarts the whole callback after
|
|
281
|
-
* a transient failure such as a write conflict, and re-sends only the commit
|
|
282
|
-
* after an unknown commit result. A restart is safe for everything written
|
|
283
|
-
* through the session, because the aborted attempt left nothing behind. It is
|
|
284
|
-
* not safe for side effects outside the session (HTTP calls, other databases),
|
|
285
|
-
* so keep those out of `work`; relaybus handlers are already at-least-once.
|
|
286
|
-
*
|
|
287
|
-
* @throws ConfigurationError when the deployment does not support transactions.
|
|
288
|
-
*/
|
|
289
|
-
async transaction(work) {
|
|
290
|
-
try {
|
|
291
|
-
return await this.#client.withSession(async (session) => {
|
|
292
|
-
let result;
|
|
293
|
-
await session.withTransaction(async () => {
|
|
294
|
-
result = await work(session);
|
|
295
|
-
}, this.#transactionOptions);
|
|
296
|
-
return result;
|
|
297
|
-
});
|
|
298
|
-
} catch (error) {
|
|
299
|
-
if (isTransactionsUnsupportedError(error)) {
|
|
300
|
-
throw createTransactionsUnsupportedError(error);
|
|
301
|
-
}
|
|
302
|
-
throw error;
|
|
303
|
-
}
|
|
219
|
+
// src/mongo-append.ts
|
|
220
|
+
var MongoAppend = class {
|
|
221
|
+
#state;
|
|
222
|
+
constructor(state) {
|
|
223
|
+
this.#state = state;
|
|
304
224
|
}
|
|
305
|
-
|
|
225
|
+
/** Implements {@link MongoEventStore.append}. */
|
|
226
|
+
async append(session, { entries, now }) {
|
|
306
227
|
assertInTransaction(session, "append");
|
|
307
228
|
const eventIds = [...new Set(entries.map((entry) => entry.event.id))];
|
|
308
229
|
const storedEvents = new Map(
|
|
309
|
-
(await this.#events.find(
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
230
|
+
(await this.#state.events.find(
|
|
231
|
+
{ _id: { $in: eventIds } },
|
|
232
|
+
{ session, projection: { key: 1, sequence: 1, handlers: 1 } }
|
|
233
|
+
).toArray()).map((stored) => [stored._id, stored])
|
|
313
234
|
);
|
|
235
|
+
const plan = await this.#planAppend({ session, entries, storedEvents, now });
|
|
236
|
+
await this.#writeAppend({ session, plan, storedEvents, now });
|
|
237
|
+
return {
|
|
238
|
+
appendedEventIds: plan.appendedEventIds,
|
|
239
|
+
duplicateEventIds: plan.duplicateEventIds,
|
|
240
|
+
appendedDeliveryCount: plan.newDeliveries.length
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Decides, in entry order, which events are new (taking their key sequence) and which deliveries
|
|
245
|
+
* each event still lacks.
|
|
246
|
+
*/
|
|
247
|
+
async #planAppend(input) {
|
|
248
|
+
const { session, entries, storedEvents, now } = input;
|
|
314
249
|
const appendedEventIds = [];
|
|
315
250
|
const duplicateEventIds = [];
|
|
316
251
|
const newEvents = [];
|
|
317
252
|
const newDeliveries = [];
|
|
318
|
-
const
|
|
253
|
+
const knownHandlers = /* @__PURE__ */ new Map();
|
|
254
|
+
const addedHandlers = /* @__PURE__ */ new Map();
|
|
319
255
|
const orderingByEventId = /* @__PURE__ */ new Map();
|
|
320
256
|
for (const [eventId, stored] of storedEvents) {
|
|
321
|
-
orderingByEventId.set(eventId, {
|
|
257
|
+
orderingByEventId.set(eventId, {
|
|
258
|
+
key: stored.key ?? null,
|
|
259
|
+
sequence: stored.sequence ?? null
|
|
260
|
+
});
|
|
261
|
+
knownHandlers.set(eventId, new Set(stored.handlers));
|
|
322
262
|
}
|
|
323
263
|
for (const { event, deliveries } of entries) {
|
|
324
264
|
let ordering = orderingByEventId.get(event.id);
|
|
@@ -326,44 +266,61 @@ var MongoEventStore = class {
|
|
|
326
266
|
const sequence = event.key === null ? null : await this.#nextSequence(event.key, session);
|
|
327
267
|
ordering = { key: event.key, sequence };
|
|
328
268
|
orderingByEventId.set(event.id, ordering);
|
|
329
|
-
|
|
269
|
+
knownHandlers.set(event.id, /* @__PURE__ */ new Set());
|
|
270
|
+
newEvents.push({ event, sequence });
|
|
330
271
|
appendedEventIds.push(event.id);
|
|
331
272
|
} else {
|
|
332
273
|
duplicateEventIds.push(event.id);
|
|
333
274
|
}
|
|
275
|
+
const handlers = knownHandlers.get(event.id);
|
|
334
276
|
for (const delivery of deliveries) {
|
|
335
|
-
|
|
336
|
-
if (storedDeliveryKeys.has(pairKey)) {
|
|
277
|
+
if (handlers.has(delivery.handler)) {
|
|
337
278
|
continue;
|
|
338
279
|
}
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
touchedEventIds.add(event.id);
|
|
343
|
-
}
|
|
280
|
+
handlers.add(delivery.handler);
|
|
281
|
+
addedHandlers.set(event.id, [...addedHandlers.get(event.id) ?? [], delivery.handler]);
|
|
282
|
+
newDeliveries.push(toDeliveryDocument(delivery, { event, ordering, now }));
|
|
344
283
|
}
|
|
345
284
|
}
|
|
285
|
+
return { appendedEventIds, duplicateEventIds, newEvents, newDeliveries, addedHandlers };
|
|
286
|
+
}
|
|
287
|
+
/** Writes the new events, the handlers added to existing events and the new deliveries, in that order. */
|
|
288
|
+
async #writeAppend(input) {
|
|
289
|
+
const { session, plan, storedEvents, now } = input;
|
|
290
|
+
const { newEvents, newDeliveries, addedHandlers } = plan;
|
|
346
291
|
if (newEvents.length > 0) {
|
|
347
|
-
await this.#events.bulkWrite(
|
|
348
|
-
newEvents.map((
|
|
292
|
+
await this.#state.events.bulkWrite(
|
|
293
|
+
newEvents.map(({ event, sequence }) => {
|
|
294
|
+
const document = toEventDocument(event, {
|
|
295
|
+
sequence,
|
|
296
|
+
handlers: addedHandlers.get(event.id) ?? [],
|
|
297
|
+
createdAt: now
|
|
298
|
+
});
|
|
299
|
+
return {
|
|
300
|
+
updateOne: {
|
|
301
|
+
filter: { _id: document._id },
|
|
302
|
+
update: { $setOnInsert: document },
|
|
303
|
+
upsert: true
|
|
304
|
+
}
|
|
305
|
+
};
|
|
306
|
+
}),
|
|
307
|
+
{ session, ordered: true }
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
const extendedEvents = [...addedHandlers].filter(([eventId]) => storedEvents.has(eventId));
|
|
311
|
+
if (extendedEvents.length > 0) {
|
|
312
|
+
await this.#state.events.bulkWrite(
|
|
313
|
+
extendedEvents.map(([eventId, handlers]) => ({
|
|
349
314
|
updateOne: {
|
|
350
|
-
filter: { _id:
|
|
351
|
-
update: { $
|
|
352
|
-
upsert: true
|
|
315
|
+
filter: { _id: eventId },
|
|
316
|
+
update: { $inc: { revision: 1 }, $push: { handlers: { $each: handlers } } }
|
|
353
317
|
}
|
|
354
318
|
})),
|
|
355
319
|
{ session, ordered: true }
|
|
356
320
|
);
|
|
357
321
|
}
|
|
358
|
-
if (touchedEventIds.size > 0) {
|
|
359
|
-
await this.#events.updateMany(
|
|
360
|
-
{ _id: { $in: [...touchedEventIds] } },
|
|
361
|
-
{ $inc: { revision: 1 } },
|
|
362
|
-
{ session }
|
|
363
|
-
);
|
|
364
|
-
}
|
|
365
322
|
if (newDeliveries.length > 0) {
|
|
366
|
-
await this.#deliveries.bulkWrite(
|
|
323
|
+
await this.#state.deliveries.bulkWrite(
|
|
367
324
|
newDeliveries.map((document) => ({
|
|
368
325
|
updateOne: {
|
|
369
326
|
filter: { eventId: document.eventId, handler: document.handler },
|
|
@@ -374,249 +331,1033 @@ var MongoEventStore = class {
|
|
|
374
331
|
{ session, ordered: true }
|
|
375
332
|
);
|
|
376
333
|
}
|
|
377
|
-
return { appendedEventIds, duplicateEventIds, appendedDeliveryCount: newDeliveries.length };
|
|
378
334
|
}
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
const scan = new ClaimScan();
|
|
386
|
-
const candidates = this.#deliveries.find(
|
|
387
|
-
{ handler: { $in: [...request.handlers] }, dueAt: { $lte: request.now }, lane: "ready" },
|
|
388
|
-
{
|
|
389
|
-
sort: { dueAt: 1, _id: 1 },
|
|
390
|
-
batchSize: Math.max(request.limit * 2, MINIMUM_CANDIDATE_BATCH_SIZE)
|
|
391
|
-
}
|
|
335
|
+
/** Atomically increments the counter of `key`; conflicting appends of the key serialize here. */
|
|
336
|
+
async #nextSequence(key, session) {
|
|
337
|
+
const counter = await this.#state.keySequences.findOneAndUpdate(
|
|
338
|
+
{ _id: key },
|
|
339
|
+
{ $inc: { value: 1 } },
|
|
340
|
+
{ upsert: true, returnDocument: "after", session }
|
|
392
341
|
);
|
|
393
|
-
|
|
394
|
-
for await (const candidate of candidates) {
|
|
395
|
-
const leased = await this.#claimCandidate(candidate, request, scan);
|
|
396
|
-
if (leased !== void 0) {
|
|
397
|
-
claimed.push(leased);
|
|
398
|
-
if (claimed.length >= request.limit) {
|
|
399
|
-
break;
|
|
400
|
-
}
|
|
401
|
-
}
|
|
402
|
-
}
|
|
403
|
-
} finally {
|
|
404
|
-
await candidates.close();
|
|
405
|
-
}
|
|
406
|
-
await this.#parkDeliveries(scan.waitingBacklogs(PARK_BACKLOG_THRESHOLD));
|
|
407
|
-
return this.#toClaimedDeliveries(claimed, request.leaseExpiresAt);
|
|
342
|
+
return counter.value;
|
|
408
343
|
}
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
// src/claim-scan.ts
|
|
347
|
+
var ClaimScan = class {
|
|
348
|
+
#keys = /* @__PURE__ */ new Map();
|
|
349
|
+
/** Whether an unfinished delivery this scan knows of comes before the delivery with `sequence`. */
|
|
350
|
+
waits(orderingId, sequence) {
|
|
351
|
+
const known = this.#keys.get(orderingId);
|
|
352
|
+
return known?.openSequence !== void 0 && sequence > known.openSequence;
|
|
413
353
|
}
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
}
|
|
418
|
-
return this.#updateLeased(lease, session, {
|
|
419
|
-
$set: { status: "completed", completedAt: now, updatedAt: now },
|
|
420
|
-
$unset: { ...LEASE_FIELDS, ...FINISHED_FIELDS }
|
|
421
|
-
});
|
|
354
|
+
/** Whether this scan already learned anything about the key. */
|
|
355
|
+
knows(orderingId) {
|
|
356
|
+
return this.#keys.has(orderingId);
|
|
422
357
|
}
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
availableAt,
|
|
428
|
-
dueAt: availableAt,
|
|
429
|
-
lastError: toErrorInfo(error),
|
|
430
|
-
updatedAt: now
|
|
431
|
-
},
|
|
432
|
-
$unset: LEASE_FIELDS
|
|
433
|
-
});
|
|
358
|
+
/** Records an unfinished delivery of the key with this sequence. */
|
|
359
|
+
noteOpen(orderingId, sequence) {
|
|
360
|
+
const known = this.#know(orderingId);
|
|
361
|
+
known.openSequence = Math.min(known.openSequence ?? sequence, sequence);
|
|
434
362
|
}
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
});
|
|
363
|
+
/** Remembers a pending delivery of lane `laneId` that waited behind an unfinished predecessor. */
|
|
364
|
+
noteWaiting(orderingId, { deliveryId, laneId }) {
|
|
365
|
+
const known = this.#know(orderingId);
|
|
366
|
+
known.waiting.push(deliveryId);
|
|
367
|
+
known.laneId = laneId;
|
|
441
368
|
}
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
369
|
+
/**
|
|
370
|
+
* Ids of the waiting deliveries of every key that accumulated at least
|
|
371
|
+
* `minimum` of them, or that lives in one of `handedBack`. Shorter queues
|
|
372
|
+
* of a lane that stays held remain in the claim index: moving them out and
|
|
373
|
+
* back would cost more than skipping them. In a lane handed back they are
|
|
374
|
+
* all its due work, and leaving them ready would make every later claim take
|
|
375
|
+
* the lane for nothing.
|
|
376
|
+
*/
|
|
377
|
+
waitingBacklogs(minimum, handedBack) {
|
|
378
|
+
return [...this.#keys.values()].filter((known) => known.waiting.length >= minimum || handedBack.has(known.laneId)).flatMap((known) => known.waiting);
|
|
447
379
|
}
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
const documents = await this.#deliveries.find(filter, { sort: { createdAt: 1, _id: 1 }, limit: query.limit ?? DEFAULT_LIST_LIMIT }).toArray();
|
|
456
|
-
const events = await this.#readEnvelopes(documents.map((document) => document.eventId));
|
|
457
|
-
return documents.map((document) => toDeliveryRecord(document, events.get(document.eventId)));
|
|
380
|
+
#know(orderingId) {
|
|
381
|
+
let known = this.#keys.get(orderingId);
|
|
382
|
+
if (known === void 0) {
|
|
383
|
+
known = { openSequence: void 0, waiting: [], laneId: void 0 };
|
|
384
|
+
this.#keys.set(orderingId, known);
|
|
385
|
+
}
|
|
386
|
+
return known;
|
|
458
387
|
}
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
388
|
+
};
|
|
389
|
+
function orderingIdentity(handler, key) {
|
|
390
|
+
return `${handler}\0${key}`;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// src/mongo-claim.ts
|
|
394
|
+
var PARK_BACKLOG_THRESHOLD = 32;
|
|
395
|
+
var INITIAL_LANES_PER_ROUND = 4;
|
|
396
|
+
var LANES_PER_CANDIDATE_QUERY = 128;
|
|
397
|
+
function scopeOf(held) {
|
|
398
|
+
return new Map(
|
|
399
|
+
held.map((lane) => [
|
|
400
|
+
lane._id,
|
|
462
401
|
{
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
availableAt: now,
|
|
468
|
-
dueAt: now,
|
|
469
|
-
updatedAt: now
|
|
470
|
-
}
|
|
402
|
+
laneId: lane._id,
|
|
403
|
+
epoch: lane.epoch,
|
|
404
|
+
listed: lane.inFlight,
|
|
405
|
+
revision: lane.revision ?? 0
|
|
471
406
|
}
|
|
472
|
-
)
|
|
473
|
-
|
|
407
|
+
])
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
var MongoClaims = class {
|
|
411
|
+
#state;
|
|
412
|
+
#laneLeases;
|
|
413
|
+
#ordering;
|
|
414
|
+
#queries;
|
|
415
|
+
constructor(state, collaborators) {
|
|
416
|
+
this.#state = state;
|
|
417
|
+
this.#laneLeases = collaborators.laneLeases;
|
|
418
|
+
this.#ordering = collaborators.ordering;
|
|
419
|
+
this.#queries = collaborators.queries;
|
|
474
420
|
}
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
if (request.
|
|
478
|
-
|
|
421
|
+
/** Implements {@link MongoEventStore.claim}. */
|
|
422
|
+
async claim(request) {
|
|
423
|
+
if (request.handlers.length === 0 || request.limit <= 0) {
|
|
424
|
+
return [];
|
|
479
425
|
}
|
|
480
|
-
|
|
481
|
-
|
|
426
|
+
const owner = request.leaseOwner;
|
|
427
|
+
const previous = this.#state.claimsInProgress.get(owner) ?? Promise.resolve();
|
|
428
|
+
const claimed = previous.then(() => this.#claimReconciled(request));
|
|
429
|
+
const settled = Promise.allSettled([claimed]);
|
|
430
|
+
this.#state.claimsInProgress.set(owner, settled);
|
|
431
|
+
void settled.then(() => {
|
|
432
|
+
if (this.#state.claimsInProgress.get(owner) === settled) {
|
|
433
|
+
this.#state.claimsInProgress.delete(owner);
|
|
434
|
+
}
|
|
435
|
+
});
|
|
436
|
+
return claimed;
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* Runs one claim and remembers what it hands out. When the claim fails at any
|
|
440
|
+
* point, also after its record reached the database without the reply
|
|
441
|
+
* reaching this process, the lanes this owner holds on this instance are
|
|
442
|
+
* reconciled: every listed delivery this instance has not handed out, or has
|
|
443
|
+
* handed out and seen settled, is taken out of them, so nothing stays listed
|
|
444
|
+
* as running that nobody runs.
|
|
445
|
+
*/
|
|
446
|
+
async #claimReconciled(request) {
|
|
447
|
+
const owner = request.leaseOwner;
|
|
448
|
+
if (this.#state.unreconciledOwners.has(owner)) {
|
|
449
|
+
await this.#reconcileLanes(owner);
|
|
450
|
+
this.#state.unreconciledOwners.delete(owner);
|
|
482
451
|
}
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
452
|
+
try {
|
|
453
|
+
const claimed = await this.#claimNow(request);
|
|
454
|
+
for (const delivery of claimed) {
|
|
455
|
+
const { laneId, epoch } = parseLeaseToken(delivery.leaseToken);
|
|
456
|
+
this.#state.handedOut.set(leaseKey(delivery.deliveryId, delivery.leaseToken), {
|
|
457
|
+
deliveryId: delivery.deliveryId,
|
|
458
|
+
owner: request.leaseOwner,
|
|
459
|
+
laneId,
|
|
460
|
+
epoch
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
return claimed;
|
|
464
|
+
} catch (error) {
|
|
465
|
+
const [reconciled] = await Promise.allSettled([this.#reconcileLanes(owner)]);
|
|
466
|
+
if (reconciled.status === "rejected") {
|
|
467
|
+
this.#state.unreconciledOwners.add(owner);
|
|
468
|
+
}
|
|
469
|
+
throw error;
|
|
489
470
|
}
|
|
490
|
-
return { deletedDeliveries, deletedEvents };
|
|
491
471
|
}
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
472
|
+
/**
|
|
473
|
+
* Takes every delivery this instance does not run out of the lanes `owner`
|
|
474
|
+
* holds on it: every listed delivery whose current claim (lane epoch and
|
|
475
|
+
* claim generation now) was not handed out by this instance, or was settled.
|
|
476
|
+
* One transaction, and the lanes are written first under their epoch and
|
|
477
|
+
* holder: a worker taking a lane over meanwhile conflicts with it, so its
|
|
478
|
+
* claim is never touched. The claim generation of every delivery taken out
|
|
479
|
+
* moves on, so no lease of those claims (a completion that failed and is
|
|
480
|
+
* retried later, say) can ever match a later claim of the same delivery.
|
|
481
|
+
* Registrations of this owner that are not the current claim of a listed
|
|
482
|
+
* delivery are dropped.
|
|
483
|
+
*/
|
|
484
|
+
async #reconcileLanes(owner) {
|
|
485
|
+
await runInTransaction(this.#state, async (session) => {
|
|
486
|
+
const held = await this.#state.lanes.find({ owner, holder: this.#state.instanceId }, { session }).toArray();
|
|
487
|
+
const listed = held.flatMap((lane) => lane.inFlight);
|
|
488
|
+
const claimsById = new Map(
|
|
489
|
+
(await this.#state.deliveries.find(
|
|
490
|
+
{ _id: { $in: listed }, status: "pending" },
|
|
491
|
+
{ session, projection: { claims: 1 } }
|
|
492
|
+
).toArray()).map((document) => [document._id, document.claims ?? 0])
|
|
493
|
+
);
|
|
494
|
+
const current = /* @__PURE__ */ new Set();
|
|
495
|
+
const unclaimed = held.map((lane) => ({
|
|
496
|
+
lane,
|
|
497
|
+
ids: lane.inFlight.filter((id) => {
|
|
498
|
+
const key = leaseKey(
|
|
499
|
+
id,
|
|
500
|
+
formatLeaseToken(lane._id, { epoch: lane.epoch, claims: claimsById.get(id) ?? 0 })
|
|
501
|
+
);
|
|
502
|
+
current.add(key);
|
|
503
|
+
return !this.#state.handedOut.has(key);
|
|
504
|
+
})
|
|
505
|
+
}));
|
|
506
|
+
for (const [key, entry] of this.#state.handedOut) {
|
|
507
|
+
if (entry.owner === owner && !current.has(key)) {
|
|
508
|
+
this.#state.handedOut.delete(key);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
if (held.length === 0) {
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
await this.#state.lanes.bulkWrite(
|
|
515
|
+
unclaimed.map(({ lane, ids }) => ({
|
|
516
|
+
updateOne: {
|
|
517
|
+
filter: { _id: lane._id, epoch: lane.epoch, holder: this.#state.instanceId },
|
|
518
|
+
update: { $pull: { inFlight: { $in: ids } }, $inc: { revision: 1 } }
|
|
519
|
+
}
|
|
520
|
+
})),
|
|
521
|
+
{ session, ordered: false }
|
|
522
|
+
);
|
|
523
|
+
await this.#state.deliveries.updateMany(
|
|
524
|
+
{ _id: { $in: unclaimed.flatMap((entry) => entry.ids) }, status: "pending" },
|
|
525
|
+
{ $inc: { claims: 1 } },
|
|
526
|
+
{ session }
|
|
527
|
+
);
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
async #claimNow(request) {
|
|
531
|
+
const [held] = await Promise.all([
|
|
532
|
+
this.#state.lanes.find({
|
|
533
|
+
owner: request.leaseOwner,
|
|
534
|
+
holder: this.#state.instanceId,
|
|
535
|
+
leaseExpiresAt: { $gt: request.now }
|
|
536
|
+
}).toArray(),
|
|
537
|
+
this.#ordering.unparkNextDeliveries(request.handlers)
|
|
538
|
+
]);
|
|
539
|
+
await this.#laneLeases.chargeLost(held);
|
|
540
|
+
const scope = scopeOf(held);
|
|
541
|
+
const running = this.#loadRunning(held);
|
|
542
|
+
const scan = new ClaimScan();
|
|
543
|
+
const picked = [];
|
|
544
|
+
await this.#pickAcrossLanes({ request, scope, scan, running, picked });
|
|
545
|
+
const events = await this.#queries.readEnvelopes(picked.map((document) => document.eventId));
|
|
546
|
+
const runningIds = (await running).ids;
|
|
547
|
+
this.#forgetSettled(held, { runningIds, owner: request.leaseOwner });
|
|
548
|
+
const { recorded, handedBack } = await this.#laneLeases.recordClaim(scope, {
|
|
549
|
+
picked,
|
|
550
|
+
runningIds,
|
|
551
|
+
request
|
|
552
|
+
});
|
|
553
|
+
const claimed = await this.#releaseOvertaken(
|
|
554
|
+
picked.filter((document) => recorded.has(document.laneId)),
|
|
555
|
+
scope
|
|
500
556
|
);
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
557
|
+
await this.#ordering.parkWaiting(scan.waitingBacklogs(PARK_BACKLOG_THRESHOLD, handedBack));
|
|
558
|
+
return claimed.map((document) => ({
|
|
559
|
+
deliveryId: document._id,
|
|
560
|
+
leaseToken: formatLeaseToken(document.laneId, {
|
|
561
|
+
epoch: scope.get(document.laneId).epoch,
|
|
562
|
+
claims: document.claims ?? 0
|
|
563
|
+
}),
|
|
564
|
+
handler: document.handler,
|
|
565
|
+
attempt: document.attempts + 1,
|
|
566
|
+
leaseExpiresAt: request.leaseExpiresAt,
|
|
567
|
+
event: events.get(document.eventId)
|
|
568
|
+
}));
|
|
569
|
+
}
|
|
570
|
+
/** The deliveries still running in the held lanes, with their (handler, key) identities. */
|
|
571
|
+
#loadRunning(held) {
|
|
572
|
+
return this.#queries.findRunning(held.flatMap((lane) => lane.inFlight)).then((documents) => ({
|
|
573
|
+
ids: new Set(documents.map((document) => document._id)),
|
|
574
|
+
keys: new Set(
|
|
575
|
+
documents.flatMap(
|
|
576
|
+
(document) => document.key === void 0 ? [] : [orderingIdentity(document.handler, document.key)]
|
|
577
|
+
)
|
|
578
|
+
)
|
|
579
|
+
}));
|
|
580
|
+
}
|
|
581
|
+
/**
|
|
582
|
+
* Picks from the lanes in `scope`, then takes free lanes with due work, a few more each round
|
|
583
|
+
* (4, 8, 16, ...), and picks from them, until the claim is full or no free lane is left.
|
|
584
|
+
*/
|
|
585
|
+
async #pickAcrossLanes(input) {
|
|
586
|
+
const { request, scope, scan, running, picked } = input;
|
|
587
|
+
const pickFrom = async (lanes) => {
|
|
588
|
+
for (let offset = 0; offset < lanes.length; offset += LANES_PER_CANDIDATE_QUERY) {
|
|
589
|
+
if (picked.length < request.limit) {
|
|
590
|
+
await this.#ordering.pickCandidates({
|
|
591
|
+
lanes: lanes.slice(offset, offset + LANES_PER_CANDIDATE_QUERY),
|
|
592
|
+
request,
|
|
593
|
+
scan,
|
|
594
|
+
running,
|
|
595
|
+
picked
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
}
|
|
507
599
|
};
|
|
600
|
+
await pickFrom([...scope.values()]);
|
|
601
|
+
const passed = new Set(scope.keys());
|
|
602
|
+
for (let laneCount = INITIAL_LANES_PER_ROUND; picked.length < request.limit; laneCount *= 2) {
|
|
603
|
+
const free = await this.#laneLeases.findFreeLanes(request, {
|
|
604
|
+
laneCount,
|
|
605
|
+
passed: [...passed]
|
|
606
|
+
});
|
|
607
|
+
if (free.length === 0) {
|
|
608
|
+
break;
|
|
609
|
+
}
|
|
610
|
+
for (const laneId of free) {
|
|
611
|
+
passed.add(laneId);
|
|
612
|
+
}
|
|
613
|
+
const taken = await this.#laneLeases.takeLanes(free, request);
|
|
614
|
+
for (const lane of taken) {
|
|
615
|
+
scope.set(lane.laneId, lane);
|
|
616
|
+
}
|
|
617
|
+
await pickFrom(taken);
|
|
618
|
+
}
|
|
508
619
|
}
|
|
509
|
-
/**
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
620
|
+
/**
|
|
621
|
+
* Forgets the claims of `owner` that are listed in the held lanes but no longer pending:
|
|
622
|
+
* completed (a burial or retry would have unlisted it). Such a claim is settled, also when it was
|
|
623
|
+
* completed inside a caller's transaction that committed. Only this owner's registrations of the
|
|
624
|
+
* lane epochs it read: the same delivery may meanwhile have been requeued and handed out again,
|
|
625
|
+
* under another owner or lane epoch.
|
|
626
|
+
*/
|
|
627
|
+
#forgetSettled(held, { runningIds, owner }) {
|
|
628
|
+
const gone = new Set(
|
|
629
|
+
held.flatMap(
|
|
630
|
+
(lane) => lane.inFlight.filter((id) => !runningIds.has(id)).map((id) => `${id}\0${lane._id}:${lane.epoch}`)
|
|
631
|
+
)
|
|
515
632
|
);
|
|
516
|
-
|
|
633
|
+
for (const [key, entry] of this.#state.handedOut) {
|
|
634
|
+
if (entry.owner === owner && gone.has(`${entry.deliveryId}\0${entry.laneId}:${entry.epoch}`)) {
|
|
635
|
+
this.#state.handedOut.delete(key);
|
|
636
|
+
}
|
|
637
|
+
}
|
|
517
638
|
}
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
639
|
+
/**
|
|
640
|
+
* Gives back the recorded keyed picks that a delivery requeued during the
|
|
641
|
+
* claim now precedes, without charging an attempt, and returns the rest. The
|
|
642
|
+
* claim checked their predecessors before recording them; a dead letter
|
|
643
|
+
* requeued in between would otherwise wait behind its own successor. Once a
|
|
644
|
+
* pick is recorded, a later requeue waits for it like for any running delivery.
|
|
645
|
+
*/
|
|
646
|
+
async #releaseOvertaken(claimed, scope) {
|
|
647
|
+
const keyed = claimed.filter((document) => document.key !== void 0);
|
|
648
|
+
if (keyed.length === 0) {
|
|
649
|
+
return [...claimed];
|
|
522
650
|
}
|
|
523
|
-
const
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
651
|
+
const blockers = await this.#state.deliveries.find(
|
|
652
|
+
{
|
|
653
|
+
$or: keyed.map((document) => ({
|
|
654
|
+
handler: document.handler,
|
|
655
|
+
key: document.key,
|
|
656
|
+
sequence: { $lt: document.sequence },
|
|
657
|
+
dueAt: { $exists: true }
|
|
658
|
+
}))
|
|
659
|
+
},
|
|
660
|
+
{ projection: { handler: 1, key: 1 } }
|
|
661
|
+
).toArray();
|
|
662
|
+
const blocked = new Set(
|
|
663
|
+
blockers.map((blocker) => orderingIdentity(blocker.handler, blocker.key))
|
|
664
|
+
);
|
|
665
|
+
const overtaken = new Set(
|
|
666
|
+
keyed.filter((document) => blocked.has(orderingIdentity(document.handler, document.key))).map((document) => document._id)
|
|
667
|
+
);
|
|
668
|
+
if (overtaken.size === 0) {
|
|
669
|
+
return [...claimed];
|
|
528
670
|
}
|
|
529
|
-
|
|
530
|
-
|
|
671
|
+
await this.#releasePicks(
|
|
672
|
+
claimed.filter((document) => overtaken.has(document._id)),
|
|
673
|
+
scope
|
|
674
|
+
);
|
|
675
|
+
return claimed.filter((document) => !overtaken.has(document._id));
|
|
676
|
+
}
|
|
677
|
+
/** Takes recorded picks out of their lanes' in-flight lists again, under the claim's epochs and record revisions. */
|
|
678
|
+
async #releasePicks(documents, scope) {
|
|
679
|
+
const idsByLane = /* @__PURE__ */ new Map();
|
|
680
|
+
for (const document of documents) {
|
|
681
|
+
idsByLane.set(document.laneId, [...idsByLane.get(document.laneId) ?? [], document._id]);
|
|
531
682
|
}
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
683
|
+
await Promise.all(
|
|
684
|
+
[...idsByLane].map(
|
|
685
|
+
([laneId, ids]) => this.#state.lanes.updateOne(
|
|
686
|
+
// Under the revision the record left: a pull that reaches the server late, after a
|
|
687
|
+
// reconciliation and a newer claim of the same delivery, matches nothing.
|
|
688
|
+
{
|
|
689
|
+
_id: laneId,
|
|
690
|
+
epoch: scope.get(laneId).epoch,
|
|
691
|
+
revision: scope.get(laneId).revision + 1
|
|
692
|
+
},
|
|
693
|
+
{ $pull: { inFlight: { $in: ids } } }
|
|
694
|
+
)
|
|
695
|
+
)
|
|
696
|
+
);
|
|
697
|
+
}
|
|
698
|
+
};
|
|
699
|
+
|
|
700
|
+
// src/mongo-completion.ts
|
|
701
|
+
var FINISHED_FIELDS = { dueAt: "", lane: "" };
|
|
702
|
+
var MongoCompletion = class {
|
|
703
|
+
#state;
|
|
704
|
+
constructor(state) {
|
|
705
|
+
this.#state = state;
|
|
706
|
+
}
|
|
707
|
+
/** Implements {@link MongoEventStore.extendLease}. */
|
|
708
|
+
async extendLease(lease, leaseExpiresAt) {
|
|
709
|
+
const held = parseLeaseToken(lease.leaseToken);
|
|
710
|
+
if (held === void 0) {
|
|
711
|
+
return false;
|
|
537
712
|
}
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
713
|
+
const deliveryId = String(lease.deliveryId);
|
|
714
|
+
return runInTransaction(this.#state, async (session) => {
|
|
715
|
+
const running = await this.#state.deliveries.findOne(ofClaim({ ...held, deliveryId }), {
|
|
716
|
+
session,
|
|
717
|
+
projection: { _id: 1 }
|
|
718
|
+
});
|
|
719
|
+
if (running === null) {
|
|
720
|
+
return false;
|
|
721
|
+
}
|
|
722
|
+
const renewed = await this.#state.lanes.updateOne(
|
|
723
|
+
{ _id: held.laneId, epoch: held.epoch, inFlight: deliveryId },
|
|
724
|
+
{ $max: { leaseExpiresAt } },
|
|
725
|
+
{ session }
|
|
726
|
+
);
|
|
727
|
+
return renewed.matchedCount === 1;
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
/** Implements {@link MongoEventStore.complete}. */
|
|
731
|
+
async complete(lease, { now, session }) {
|
|
732
|
+
if (session !== void 0) {
|
|
733
|
+
assertInTransaction(session, "complete");
|
|
541
734
|
}
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
735
|
+
return (await this.#completeLeases([lease], { now, session }))[0];
|
|
736
|
+
}
|
|
737
|
+
/** Implements {@link MongoEventStore.completeMany}. */
|
|
738
|
+
completeMany(leases, now) {
|
|
739
|
+
return this.#completeLeases(leases, { now, session: void 0 });
|
|
740
|
+
}
|
|
741
|
+
/** Implements {@link MongoEventStore.reschedule}. */
|
|
742
|
+
async reschedule(lease, {
|
|
743
|
+
availableAt,
|
|
744
|
+
error,
|
|
745
|
+
now
|
|
746
|
+
}) {
|
|
747
|
+
return this.#leaveFlight(lease, {
|
|
748
|
+
$set: { availableAt, dueAt: availableAt, lastError: toErrorInfo(error), updatedAt: now },
|
|
749
|
+
$inc: { attempts: 1, claims: 1 }
|
|
750
|
+
});
|
|
751
|
+
}
|
|
752
|
+
/** Implements {@link MongoEventStore.release}. */
|
|
753
|
+
async release(lease, now) {
|
|
754
|
+
return this.#leaveFlight(lease, {
|
|
755
|
+
$set: { availableAt: now, dueAt: now, updatedAt: now },
|
|
756
|
+
$inc: { claims: 1 }
|
|
757
|
+
});
|
|
758
|
+
}
|
|
759
|
+
/** Implements {@link MongoEventStore.bury}. */
|
|
760
|
+
async bury(lease, { error, now }) {
|
|
761
|
+
return this.#leaveFlight(lease, {
|
|
762
|
+
$set: { status: "dead", lastError: toErrorInfo(error), updatedAt: now },
|
|
763
|
+
$unset: FINISHED_FIELDS,
|
|
764
|
+
$inc: { attempts: 1, claims: 1 }
|
|
765
|
+
});
|
|
766
|
+
}
|
|
767
|
+
/**
|
|
768
|
+
* Runs `operation`, which ends the claims of `leases` (completion, retry,
|
|
769
|
+
* release, burial), and once it returned forgets exactly those leases as
|
|
770
|
+
* handed out. When it throws, they stay registered: the caller may retry
|
|
771
|
+
* with the same lease, which must still find its claim.
|
|
772
|
+
*/
|
|
773
|
+
async #settle(leases, operation) {
|
|
774
|
+
const result = await operation();
|
|
775
|
+
for (const lease of leases) {
|
|
776
|
+
this.#state.handedOut.delete(leaseKey(String(lease.deliveryId), String(lease.leaseToken)));
|
|
545
777
|
}
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
778
|
+
return result;
|
|
779
|
+
}
|
|
780
|
+
/**
|
|
781
|
+
* Deletes the delivery of every lease whose lane still has the lease's epoch
|
|
782
|
+
* and lists the delivery, in one transaction (the caller's, when given).
|
|
783
|
+
* Returns one flag per lease, in order; a lease listed twice completes once.
|
|
784
|
+
*/
|
|
785
|
+
async #completeLeases(leases, { now, session }) {
|
|
786
|
+
return session === void 0 ? this.#settle(leases, () => this.#completeIssued(leases, { now, session: void 0 })) : this.#completeIssued(leases, { now, session });
|
|
787
|
+
}
|
|
788
|
+
async #completeIssued(leases, { now, session }) {
|
|
789
|
+
const parsed = leases.map((lease) => parseLeaseToken(lease.leaseToken));
|
|
790
|
+
const issued = leases.flatMap(
|
|
791
|
+
(lease, index) => parsed[index] === void 0 ? [] : [{ deliveryId: String(lease.deliveryId), ...parsed[index] }]
|
|
792
|
+
);
|
|
793
|
+
let completed = /* @__PURE__ */ new Set();
|
|
794
|
+
if (issued.length > 0) {
|
|
795
|
+
if (session !== void 0) {
|
|
796
|
+
completed = await this.#deleteCompleted(issued, { now, session });
|
|
797
|
+
} else if (this.#state.completionHistory) {
|
|
798
|
+
completed = await runInTransaction(
|
|
799
|
+
this.#state,
|
|
800
|
+
(own) => this.#deleteCompleted(issued, { now, session: own })
|
|
801
|
+
);
|
|
802
|
+
} else {
|
|
803
|
+
completed = await this.#deleteFenced(issued, now);
|
|
804
|
+
}
|
|
551
805
|
}
|
|
552
|
-
|
|
553
|
-
|
|
806
|
+
return leases.map(
|
|
807
|
+
(lease, index) => parsed[index] !== void 0 && completed.delete(leaseIdentity({ deliveryId: String(lease.deliveryId), ...parsed[index] }))
|
|
808
|
+
);
|
|
809
|
+
}
|
|
810
|
+
/**
|
|
811
|
+
* Completion without a transaction, used when no history record has to be
|
|
812
|
+
* written with the deletion. The deliveries' claim generations are read
|
|
813
|
+
* first, then the lanes, and a delivery is deleted only if it still has the
|
|
814
|
+
* lease's generation. A takeover moves the generation of every delivery it
|
|
815
|
+
* takes from the previous owner on before it reads anything, and so do a
|
|
816
|
+
* retry, a release and a burial, so:
|
|
817
|
+
* - a takeover before the delivery read shows up in the lane read (new epoch);
|
|
818
|
+
* - a takeover between the two reads shows up in the lane read as well;
|
|
819
|
+
* - a takeover, retry, release or burial after the lane read changed the
|
|
820
|
+
* generation, so the delete matches nothing.
|
|
821
|
+
* A completion and a takeover (or the end of the claim) therefore never both succeed. Two concurrent
|
|
822
|
+
* completions of the same lease may both report true (the dispatcher never
|
|
823
|
+
* issues them); one call with a lease listed twice reports it once.
|
|
824
|
+
*/
|
|
825
|
+
async #deleteFenced(issued, now) {
|
|
826
|
+
const ids = [...new Set(issued.map((lease) => lease.deliveryId))];
|
|
827
|
+
const documents = await this.#state.deliveries.find({ _id: { $in: ids }, status: "pending" }, { projection: { claims: 1, eventId: 1 } }).toArray();
|
|
828
|
+
const lanes = new Map(
|
|
829
|
+
(await this.#state.lanes.find(
|
|
830
|
+
{ _id: { $in: [...new Set(issued.map((lease) => lease.laneId))] } },
|
|
831
|
+
{ projection: { epoch: 1, inFlight: 1 } }
|
|
832
|
+
).toArray()).map((lane) => [lane._id, lane])
|
|
833
|
+
);
|
|
834
|
+
const fenced = fencedLeases(issued, { lanes, documents });
|
|
835
|
+
if (fenced.length === 0) {
|
|
836
|
+
return /* @__PURE__ */ new Set();
|
|
837
|
+
}
|
|
838
|
+
await this.#markEventsCompleted(fenced, { documents, now });
|
|
839
|
+
const deleted = await this.#state.deliveries.deleteMany({ $or: fenced.map(ofClaim) });
|
|
840
|
+
return deleted.deletedCount === fenced.length ? new Set(fenced.map((lease) => leaseIdentity(lease))) : /* @__PURE__ */ new Set();
|
|
841
|
+
}
|
|
842
|
+
/**
|
|
843
|
+
* The transactional part of a completion. The lanes are only read: the
|
|
844
|
+
* conflict with a takeover comes from the deleted delivery documents, which a
|
|
845
|
+
* takeover writes before it reads them (see `MongoLaneLeases.takeLanes`).
|
|
846
|
+
* So whichever of the two commits second either fails with a write conflict
|
|
847
|
+
* and is retried against the new state, or waits for the first.
|
|
848
|
+
*/
|
|
849
|
+
async #deleteCompleted(issued, { now, session }) {
|
|
850
|
+
const lanes = new Map(
|
|
851
|
+
(await this.#state.lanes.find(
|
|
852
|
+
{ _id: { $in: [...new Set(issued.map((lease) => lease.laneId))] } },
|
|
853
|
+
{ session, projection: { epoch: 1, inFlight: 1 } }
|
|
854
|
+
).toArray()).map((lane) => [lane._id, lane])
|
|
855
|
+
);
|
|
856
|
+
const documents = await this.#state.deliveries.find(
|
|
857
|
+
{ _id: { $in: [...new Set(issued.map((lease) => lease.deliveryId))] }, status: "pending" },
|
|
858
|
+
{ session }
|
|
859
|
+
).toArray();
|
|
860
|
+
const fenced = fencedLeases(issued, { lanes, documents });
|
|
861
|
+
if (fenced.length === 0) {
|
|
862
|
+
return /* @__PURE__ */ new Set();
|
|
863
|
+
}
|
|
864
|
+
await this.#markEventsCompleted(fenced, { documents, now, session });
|
|
865
|
+
await this.#state.deliveries.deleteMany({ $or: fenced.map(ofClaim) }, { session });
|
|
866
|
+
if (this.#state.completionHistory) {
|
|
867
|
+
const completedIds = new Set(fenced.map((lease) => lease.deliveryId));
|
|
868
|
+
await this.#state.completions.insertMany(
|
|
869
|
+
documents.filter((document) => completedIds.has(document._id)).map((document) => toCompletedDeliveryDocument(document, now)),
|
|
870
|
+
{ session }
|
|
871
|
+
);
|
|
872
|
+
}
|
|
873
|
+
return new Set(fenced.map((lease) => leaseIdentity(lease)));
|
|
874
|
+
}
|
|
875
|
+
/**
|
|
876
|
+
* Records `now` as the latest completion of the events of the fenced
|
|
877
|
+
* deliveries (`$max`, so an earlier clock never moves it back). Written
|
|
878
|
+
* before the deletion, it can only be later than the real last completion
|
|
879
|
+
* (when the deletion then fails), which keeps the event longer, never shorter.
|
|
880
|
+
* Inside a transaction it is part of it: the transaction may hold the event
|
|
881
|
+
* already (a transactional handler that appended to its own event), and a
|
|
882
|
+
* write from outside would wait for it forever. Deliveries of one event that
|
|
883
|
+
* complete in concurrent transactions (fan-out to transactional handlers)
|
|
884
|
+
* therefore conflict on the event document, and one of them retries.
|
|
885
|
+
*/
|
|
886
|
+
async #markEventsCompleted(fenced, {
|
|
887
|
+
documents,
|
|
888
|
+
now,
|
|
889
|
+
session
|
|
890
|
+
}) {
|
|
891
|
+
const completedIds = new Set(fenced.map((lease) => lease.deliveryId));
|
|
892
|
+
const eventIds = [
|
|
893
|
+
...new Set(
|
|
894
|
+
documents.filter((document) => completedIds.has(document._id)).map((document) => document.eventId)
|
|
895
|
+
)
|
|
896
|
+
];
|
|
897
|
+
await this.#state.events.updateMany(
|
|
898
|
+
{ _id: { $in: eventIds } },
|
|
899
|
+
{ $max: { completedAt: now } },
|
|
900
|
+
session === void 0 ? {} : { session }
|
|
901
|
+
);
|
|
554
902
|
}
|
|
555
903
|
/**
|
|
556
|
-
*
|
|
557
|
-
*
|
|
558
|
-
*
|
|
904
|
+
* Applies `update` to a running delivery and takes it out of its lane's
|
|
905
|
+
* in-flight list, if the lane still has the lease's epoch. One transaction:
|
|
906
|
+
* the lane write fences the change against a takeover.
|
|
559
907
|
*/
|
|
560
|
-
async #lease
|
|
561
|
-
const
|
|
908
|
+
async #leaveFlight(lease, update) {
|
|
909
|
+
const held = parseLeaseToken(lease.leaseToken);
|
|
910
|
+
if (held === void 0) {
|
|
911
|
+
return false;
|
|
912
|
+
}
|
|
913
|
+
const deliveryId = String(lease.deliveryId);
|
|
914
|
+
const ofThisClaim = ofClaim({ ...held, deliveryId });
|
|
915
|
+
return this.#settle(
|
|
916
|
+
[lease],
|
|
917
|
+
() => this.#leaveFlightNow(held, { deliveryId, ofThisClaim, update })
|
|
918
|
+
);
|
|
919
|
+
}
|
|
920
|
+
async #leaveFlightNow(held, {
|
|
921
|
+
deliveryId,
|
|
922
|
+
ofThisClaim,
|
|
923
|
+
update
|
|
924
|
+
}) {
|
|
925
|
+
return runInTransaction(this.#state, async (session) => {
|
|
926
|
+
if (await this.#state.deliveries.findOne(ofThisClaim, { session, projection: { _id: 1 } }) === null) {
|
|
927
|
+
return false;
|
|
928
|
+
}
|
|
929
|
+
const fence = await this.#state.lanes.updateOne(
|
|
930
|
+
{ _id: held.laneId, epoch: held.epoch, inFlight: deliveryId },
|
|
931
|
+
{ $pull: { inFlight: deliveryId } },
|
|
932
|
+
{ session }
|
|
933
|
+
);
|
|
934
|
+
if (fence.matchedCount === 0) {
|
|
935
|
+
return false;
|
|
936
|
+
}
|
|
937
|
+
const changed = await this.#state.deliveries.updateOne(ofThisClaim, update, { session });
|
|
938
|
+
return changed.matchedCount === 1;
|
|
939
|
+
});
|
|
940
|
+
}
|
|
941
|
+
};
|
|
942
|
+
function fencedLeases(issued, {
|
|
943
|
+
lanes,
|
|
944
|
+
documents
|
|
945
|
+
}) {
|
|
946
|
+
const claimsById = new Map(documents.map((document) => [document._id, document.claims ?? 0]));
|
|
947
|
+
const fenced = /* @__PURE__ */ new Map();
|
|
948
|
+
for (const lease of issued) {
|
|
949
|
+
const lane = lanes.get(lease.laneId);
|
|
950
|
+
if (claimsById.get(lease.deliveryId) === lease.claims && lane.epoch === lease.epoch && lane.inFlight.includes(lease.deliveryId)) {
|
|
951
|
+
fenced.set(leaseIdentity(lease), lease);
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
return [...fenced.values()];
|
|
955
|
+
}
|
|
956
|
+
function ofClaim(lease) {
|
|
957
|
+
return { _id: lease.deliveryId, status: "pending", claims: atCount(lease.claims) };
|
|
958
|
+
}
|
|
959
|
+
function leaseIdentity(lease) {
|
|
960
|
+
return `${lease.deliveryId}\0${formatLeaseToken(lease.laneId, { epoch: lease.epoch, claims: lease.claims })}`;
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
// src/mongo-lane-leases.ts
|
|
964
|
+
var MongoLaneLeases = class {
|
|
965
|
+
#state;
|
|
966
|
+
#queries;
|
|
967
|
+
constructor(state, collaborators) {
|
|
968
|
+
this.#state = state;
|
|
969
|
+
this.#queries = collaborators.queries;
|
|
970
|
+
}
|
|
971
|
+
/**
|
|
972
|
+
* Up to `laneCount` lanes nobody holds under a live lease that have a due,
|
|
973
|
+
* ready delivery of the requested handlers, the lane with the oldest such
|
|
974
|
+
* delivery first, leaving out `passed`. One round trip: the oldest delivery
|
|
975
|
+
* of each lane is one index probe on the server. A lane whose due deliveries
|
|
976
|
+
* all wait behind an unfinished predecessor is taken at most once: the claim
|
|
977
|
+
* that hands it back parks them (see `ClaimScan.waitingBacklogs`).
|
|
978
|
+
*/
|
|
979
|
+
async findFreeLanes(request, { laneCount, passed }) {
|
|
980
|
+
const lanes = await this.#state.lanes.aggregate([
|
|
562
981
|
{
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
dueAt: candidate.dueAt
|
|
982
|
+
$match: {
|
|
983
|
+
_id: { $nin: passed },
|
|
984
|
+
$or: [{ leaseExpiresAt: null }, { leaseExpiresAt: { $lte: request.now } }]
|
|
985
|
+
}
|
|
568
986
|
},
|
|
569
987
|
{
|
|
570
|
-
$
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
988
|
+
$lookup: {
|
|
989
|
+
from: this.#state.deliveries.collectionName,
|
|
990
|
+
localField: "_id",
|
|
991
|
+
foreignField: "laneId",
|
|
992
|
+
pipeline: [
|
|
993
|
+
{
|
|
994
|
+
$match: {
|
|
995
|
+
handler: { $in: [...request.handlers] },
|
|
996
|
+
lane: "ready",
|
|
997
|
+
dueAt: { $lte: request.now }
|
|
998
|
+
}
|
|
999
|
+
},
|
|
1000
|
+
{ $sort: { dueAt: 1, _id: 1 } },
|
|
1001
|
+
{ $limit: 1 },
|
|
1002
|
+
{ $project: { dueAt: 1 } }
|
|
1003
|
+
],
|
|
1004
|
+
as: "oldest"
|
|
1005
|
+
}
|
|
579
1006
|
},
|
|
580
|
-
{
|
|
1007
|
+
{ $unwind: "$oldest" },
|
|
1008
|
+
{ $sort: { "oldest.dueAt": 1, "oldest._id": 1 } },
|
|
1009
|
+
{ $limit: laneCount },
|
|
1010
|
+
{ $project: { _id: 1 } }
|
|
1011
|
+
]).toArray();
|
|
1012
|
+
return lanes.map((lane) => lane._id);
|
|
1013
|
+
}
|
|
1014
|
+
/**
|
|
1015
|
+
* Takes the lanes with one conditional write each, run in parallel; a lane
|
|
1016
|
+
* taken by someone else meanwhile is skipped. The same write moves the
|
|
1017
|
+
* previous owner's in-flight list to `lost`, which is charged before any
|
|
1018
|
+
* delivery of the lanes is read (see {@link MongoLaneLeases.chargeLost}).
|
|
1019
|
+
*/
|
|
1020
|
+
async takeLanes(laneIds, request) {
|
|
1021
|
+
const after = await Promise.all(
|
|
1022
|
+
laneIds.map(
|
|
1023
|
+
(laneId) => this.#state.lanes.findOneAndUpdate(
|
|
1024
|
+
{
|
|
1025
|
+
_id: laneId,
|
|
1026
|
+
$or: [{ leaseExpiresAt: null }, { leaseExpiresAt: { $lte: request.now } }]
|
|
1027
|
+
},
|
|
1028
|
+
[
|
|
1029
|
+
{
|
|
1030
|
+
$set: {
|
|
1031
|
+
epoch: { $add: ["$epoch", 1] },
|
|
1032
|
+
owner: { $literal: request.leaseOwner },
|
|
1033
|
+
holder: { $literal: this.#state.instanceId },
|
|
1034
|
+
leaseExpiresAt: { $literal: request.leaseExpiresAt },
|
|
1035
|
+
// Each lost delivery keeps the epoch it was claimed under: the key of its charge.
|
|
1036
|
+
lost: {
|
|
1037
|
+
$setUnion: [
|
|
1038
|
+
{ $ifNull: ["$lost", []] },
|
|
1039
|
+
{ $map: { input: "$inFlight", in: { id: "$$this", epoch: "$epoch" } } }
|
|
1040
|
+
]
|
|
1041
|
+
},
|
|
1042
|
+
inFlight: { $literal: [] }
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
],
|
|
1046
|
+
{ returnDocument: "after" }
|
|
1047
|
+
)
|
|
1048
|
+
)
|
|
581
1049
|
);
|
|
582
|
-
|
|
1050
|
+
const taken = after.filter((lane) => lane !== null);
|
|
1051
|
+
await this.chargeLost(taken);
|
|
1052
|
+
return taken.map((lane) => ({
|
|
1053
|
+
laneId: lane._id,
|
|
1054
|
+
epoch: lane.epoch,
|
|
1055
|
+
listed: lane.inFlight,
|
|
1056
|
+
revision: lane.revision ?? 0
|
|
1057
|
+
}));
|
|
583
1058
|
}
|
|
584
|
-
/**
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
1059
|
+
/**
|
|
1060
|
+
* Charges one attempt to every pending delivery listed in the `lost` lists of
|
|
1061
|
+
* `lanes` (deliveries whose lane was taken while they ran), then clears those
|
|
1062
|
+
* lists under the lanes' current epochs. The charge is a write on each lost
|
|
1063
|
+
* delivery that comes before any read of the lane's deliveries: a completion
|
|
1064
|
+
* of the previous owner deletes the same documents, so the two conflict and
|
|
1065
|
+
* cannot both succeed. The charge also moves the claim generation on, which
|
|
1066
|
+
* fences every lease of the lost claim, and records the lost claim
|
|
1067
|
+
* (`lostAt` = lane and epoch it was claimed under): a holder that charges the
|
|
1068
|
+
* list again after a failure before the clear skips deliveries already
|
|
1069
|
+
* charged for that claim.
|
|
1070
|
+
*/
|
|
1071
|
+
async chargeLost(lanes) {
|
|
1072
|
+
const losing = lanes.filter((lane) => lane.lost.length > 0);
|
|
1073
|
+
if (losing.length === 0) {
|
|
1074
|
+
return;
|
|
1075
|
+
}
|
|
1076
|
+
const lost = losing.flatMap((lane) => lane.lost).sort((left, right) => left.epoch - right.epoch);
|
|
1077
|
+
await this.#state.deliveries.bulkWrite(
|
|
1078
|
+
lost.map(({ id, epoch }) => ({
|
|
1079
|
+
updateOne: {
|
|
1080
|
+
filter: { _id: id, status: "pending", lostAt: { $not: { $gte: epoch } } },
|
|
1081
|
+
update: { $inc: { attempts: 1, claims: 1 }, $set: { lostAt: epoch } }
|
|
1082
|
+
}
|
|
1083
|
+
})),
|
|
1084
|
+
{ ordered: true }
|
|
1085
|
+
);
|
|
1086
|
+
await this.#state.lanes.bulkWrite(
|
|
1087
|
+
losing.map((lane) => ({
|
|
1088
|
+
updateOne: { filter: { _id: lane._id, epoch: lane.epoch }, update: { $set: { lost: [] } } }
|
|
1089
|
+
})),
|
|
1090
|
+
{ ordered: false }
|
|
1091
|
+
);
|
|
1092
|
+
}
|
|
1093
|
+
/**
|
|
1094
|
+
* Records the picks in their lanes, one conditional write per lane and one
|
|
1095
|
+
* round trip for all of them, and frees the lanes that yielded nothing and run
|
|
1096
|
+
* nothing. A picked lane lists its running deliveries plus the new ones (ids
|
|
1097
|
+
* whose documents are gone are pruned) and its lease reaches
|
|
1098
|
+
* `request.leaseExpiresAt`. Returns the lanes whose picks were recorded: every
|
|
1099
|
+
* write is conditional on the claim's epoch, owner and store instance, so a
|
|
1100
|
+
* lane that changed hands, or was handed back by `relinquish`, in the meantime
|
|
1101
|
+
* keeps its deliveries for whoever takes it next.
|
|
1102
|
+
*/
|
|
1103
|
+
async recordClaim(scope, {
|
|
1104
|
+
picked,
|
|
1105
|
+
runningIds,
|
|
1106
|
+
request
|
|
1107
|
+
}) {
|
|
1108
|
+
const picksByLane = /* @__PURE__ */ new Map();
|
|
1109
|
+
const handedBack = /* @__PURE__ */ new Set();
|
|
1110
|
+
for (const document of picked) {
|
|
1111
|
+
picksByLane.set(document.laneId, [...picksByLane.get(document.laneId) ?? [], document._id]);
|
|
1112
|
+
}
|
|
1113
|
+
const operations = [];
|
|
1114
|
+
for (const lane of scope.values()) {
|
|
1115
|
+
const gone = lane.listed.filter((id) => !runningIds.has(id));
|
|
1116
|
+
const ids = picksByLane.get(lane.laneId);
|
|
1117
|
+
if (ids !== void 0) {
|
|
1118
|
+
operations.push(
|
|
1119
|
+
recordInFlight({ lane, ids, gone, request, instanceId: this.#state.instanceId })
|
|
1120
|
+
);
|
|
1121
|
+
} else if (gone.length === lane.listed.length) {
|
|
1122
|
+
operations.push(
|
|
1123
|
+
freeLane(lane.laneId, { epoch: lane.epoch, revision: lane.revision, finished: gone })
|
|
1124
|
+
);
|
|
1125
|
+
handedBack.add(lane.laneId);
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
if (operations.length === 0) {
|
|
1129
|
+
return { recorded: /* @__PURE__ */ new Set(), handedBack };
|
|
1130
|
+
}
|
|
1131
|
+
const result = await this.#state.lanes.bulkWrite(operations, { ordered: false });
|
|
1132
|
+
if (result.matchedCount === operations.length) {
|
|
1133
|
+
return { recorded: new Set(picksByLane.keys()), handedBack };
|
|
1134
|
+
}
|
|
1135
|
+
return {
|
|
1136
|
+
recorded: await this.#readRecorded(scope, { laneIds: [...picksByLane.keys()], request }),
|
|
1137
|
+
handedBack
|
|
591
1138
|
};
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
1139
|
+
}
|
|
1140
|
+
/** The lanes of `laneIds` whose record of this claim applied, read back after a partial bulk write. */
|
|
1141
|
+
async #readRecorded(scope, { laneIds, request }) {
|
|
1142
|
+
const current = await this.#state.lanes.find(
|
|
1143
|
+
{ _id: { $in: [...laneIds] } },
|
|
1144
|
+
{ projection: { epoch: 1, owner: 1, holder: 1, revision: 1 } }
|
|
1145
|
+
).toArray();
|
|
1146
|
+
return new Set(
|
|
1147
|
+
current.filter(
|
|
1148
|
+
(lane) => lane.epoch === scope.get(lane._id).epoch && lane.revision === scope.get(lane._id).revision + 1 && lane.owner === request.leaseOwner && lane.holder === this.#state.instanceId
|
|
1149
|
+
).map((lane) => lane._id)
|
|
1150
|
+
);
|
|
1151
|
+
}
|
|
1152
|
+
/** Implements {@link MongoEventStore.relinquish}. */
|
|
1153
|
+
async relinquish(leaseOwner) {
|
|
1154
|
+
const held = await this.#state.lanes.find({ owner: String(leaseOwner), holder: this.#state.instanceId }).toArray();
|
|
1155
|
+
const runningIds = new Set(
|
|
1156
|
+
(await this.#queries.findRunning(held.flatMap((lane) => lane.inFlight))).map(
|
|
1157
|
+
(document) => document._id
|
|
1158
|
+
)
|
|
1159
|
+
);
|
|
1160
|
+
const idle = held.filter((lane) => lane.inFlight.every((id) => !runningIds.has(id)));
|
|
1161
|
+
if (idle.length > 0) {
|
|
1162
|
+
await this.#state.lanes.bulkWrite(
|
|
1163
|
+
idle.map(
|
|
1164
|
+
(lane) => freeLane(lane._id, {
|
|
1165
|
+
epoch: lane.epoch,
|
|
1166
|
+
revision: lane.revision ?? 0,
|
|
1167
|
+
finished: lane.inFlight
|
|
1168
|
+
})
|
|
1169
|
+
),
|
|
1170
|
+
{ ordered: false }
|
|
1171
|
+
);
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
};
|
|
1175
|
+
function recordInFlight(input) {
|
|
1176
|
+
const { lane, ids, gone, request, instanceId } = input;
|
|
1177
|
+
return {
|
|
1178
|
+
updateOne: {
|
|
1179
|
+
filter: {
|
|
1180
|
+
_id: lane.laneId,
|
|
1181
|
+
epoch: lane.epoch,
|
|
1182
|
+
owner: request.leaseOwner,
|
|
1183
|
+
holder: instanceId,
|
|
1184
|
+
// A record that reaches the server late (after its claim failed and reconciled)
|
|
1185
|
+
// finds a newer revision and changes nothing.
|
|
1186
|
+
revision: atCount(lane.revision)
|
|
1187
|
+
},
|
|
1188
|
+
update: [
|
|
1189
|
+
{
|
|
1190
|
+
$set: {
|
|
1191
|
+
inFlight: {
|
|
1192
|
+
$concatArrays: [
|
|
1193
|
+
{
|
|
1194
|
+
$filter: {
|
|
1195
|
+
input: "$inFlight",
|
|
1196
|
+
cond: { $not: [{ $in: ["$$this", { $literal: gone }] }] }
|
|
1197
|
+
}
|
|
1198
|
+
},
|
|
1199
|
+
{ $literal: ids }
|
|
1200
|
+
]
|
|
1201
|
+
},
|
|
1202
|
+
leaseExpiresAt: { $max: ["$leaseExpiresAt", request.leaseExpiresAt] },
|
|
1203
|
+
revision: { $add: [{ $ifNull: ["$revision", 0] }, 1] }
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
]
|
|
1207
|
+
}
|
|
1208
|
+
};
|
|
1209
|
+
}
|
|
1210
|
+
function freeLane(laneId, {
|
|
1211
|
+
epoch,
|
|
1212
|
+
revision,
|
|
1213
|
+
finished
|
|
1214
|
+
}) {
|
|
1215
|
+
return {
|
|
1216
|
+
updateOne: {
|
|
1217
|
+
filter: {
|
|
1218
|
+
_id: laneId,
|
|
1219
|
+
epoch,
|
|
1220
|
+
revision: atCount(revision),
|
|
1221
|
+
inFlight: { $not: { $elemMatch: { $nin: [...finished] } } }
|
|
1222
|
+
},
|
|
1223
|
+
update: {
|
|
1224
|
+
$set: { owner: null, holder: null, leaseExpiresAt: null, inFlight: [] },
|
|
1225
|
+
$inc: { revision: 1 }
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
};
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
// src/mongo-ordering.ts
|
|
1232
|
+
var MINIMUM_CANDIDATE_BATCH_SIZE = 32;
|
|
1233
|
+
var PARK_UPDATE_CHUNK_SIZE = 1e3;
|
|
1234
|
+
var MongoOrdering = class {
|
|
1235
|
+
#state;
|
|
1236
|
+
constructor(state) {
|
|
1237
|
+
this.#state = state;
|
|
1238
|
+
}
|
|
1239
|
+
/**
|
|
1240
|
+
* Adds the claimable candidates of `lanes` to `picked`, in due order, until
|
|
1241
|
+
* `request.limit` is reached. A delivery is a candidate when it is ready, due
|
|
1242
|
+
* and not in flight in its lane. A keyed candidate is claimable only when
|
|
1243
|
+
* (a) no earlier delivery of its handler and key is pending (a running one is
|
|
1244
|
+
* still a pending document) and (b) no other delivery of them runs. Running
|
|
1245
|
+
* deliveries of a key live in the key's lane, which this owner holds, so the
|
|
1246
|
+
* running keys (loaded once from the held lanes) and this claim's own picks
|
|
1247
|
+
* are all of them.
|
|
1248
|
+
*
|
|
1249
|
+
* Candidates are read in pages with a limit, so no cursor is left open, and
|
|
1250
|
+
* the predecessors of the keys of a page are looked up in parallel, one
|
|
1251
|
+
* lookup per key.
|
|
1252
|
+
*/
|
|
1253
|
+
async pickCandidates(input) {
|
|
1254
|
+
const { lanes, request, scan, running, picked } = input;
|
|
1255
|
+
const pageSize = Math.max((request.limit - picked.length) * 2, MINIMUM_CANDIDATE_BATCH_SIZE);
|
|
1256
|
+
const filter = {
|
|
1257
|
+
laneId: { $in: lanes.map((lane) => lane.laneId) },
|
|
1258
|
+
handler: { $in: [...request.handlers] },
|
|
1259
|
+
lane: "ready",
|
|
1260
|
+
dueAt: { $lte: request.now },
|
|
1261
|
+
_id: { $nin: lanes.flatMap((lane) => lane.listed) }
|
|
1262
|
+
};
|
|
1263
|
+
let after;
|
|
1264
|
+
for (; ; ) {
|
|
1265
|
+
const [page, { keys }] = await Promise.all([
|
|
1266
|
+
this.#state.deliveries.find(
|
|
1267
|
+
after === void 0 ? filter : {
|
|
1268
|
+
$and: [
|
|
1269
|
+
filter,
|
|
1270
|
+
{
|
|
1271
|
+
$or: [
|
|
1272
|
+
{ dueAt: { $gt: after.dueAt } },
|
|
1273
|
+
{ dueAt: after.dueAt, _id: { $gt: after._id } }
|
|
1274
|
+
]
|
|
1275
|
+
}
|
|
1276
|
+
]
|
|
1277
|
+
},
|
|
1278
|
+
{ sort: { dueAt: 1, _id: 1 }, limit: pageSize }
|
|
1279
|
+
).toArray(),
|
|
1280
|
+
running
|
|
1281
|
+
]);
|
|
1282
|
+
if (await this.#pickFromPage(page, { request, scan, picked, keys })) {
|
|
1283
|
+
return;
|
|
1284
|
+
}
|
|
1285
|
+
if (page.length < pageSize) {
|
|
1286
|
+
return;
|
|
1287
|
+
}
|
|
1288
|
+
after = page.at(-1);
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
/**
|
|
1292
|
+
* Adds the claimable candidates of `page` to `picked`. Looks up predecessors only for as many
|
|
1293
|
+
* candidates as the claim still needs, then more. True once the claim has `request.limit` picks.
|
|
1294
|
+
*/
|
|
1295
|
+
async #pickFromPage(page, claim) {
|
|
1296
|
+
const { request, scan, picked, keys } = claim;
|
|
1297
|
+
for (let offset = 0; offset < page.length; ) {
|
|
1298
|
+
const slice = page.slice(offset, offset + request.limit - picked.length);
|
|
1299
|
+
offset += slice.length;
|
|
1300
|
+
await this.#notePredecessors(slice, scan);
|
|
1301
|
+
for (const candidate of slice) {
|
|
1302
|
+
if (isClaimable(candidate, { scan, blockedKeys: keys })) {
|
|
1303
|
+
picked.push(candidate);
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
if (picked.length >= request.limit) {
|
|
1307
|
+
return true;
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
return false;
|
|
1311
|
+
}
|
|
1312
|
+
/**
|
|
1313
|
+
* Looks up, in parallel and once per key, whether an unfinished delivery
|
|
1314
|
+
* precedes the earliest candidate of each keyed (handler, key) of `page` that
|
|
1315
|
+
* this claim knows nothing about yet, and tells `scan`. The earliest candidate
|
|
1316
|
+
* is itself unfinished, so every later candidate of its key waits at least for it.
|
|
1317
|
+
*/
|
|
1318
|
+
async #notePredecessors(page, scan) {
|
|
1319
|
+
const earliest = /* @__PURE__ */ new Map();
|
|
1320
|
+
for (const candidate of page) {
|
|
1321
|
+
if (candidate.key !== void 0) {
|
|
1322
|
+
const orderingId = orderingIdentity(candidate.handler, candidate.key);
|
|
1323
|
+
const known = earliest.get(orderingId);
|
|
1324
|
+
if (!scan.knows(orderingId) && (known?.sequence ?? Infinity) > candidate.sequence) {
|
|
1325
|
+
earliest.set(orderingId, candidate);
|
|
600
1326
|
}
|
|
601
|
-
}
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
await Promise.all(
|
|
1330
|
+
[...earliest].map(async ([orderingId, candidate]) => {
|
|
1331
|
+
const predecessor = await this.#findUnfinishedPredecessor(candidate.handler, {
|
|
1332
|
+
key: candidate.key,
|
|
1333
|
+
sequence: candidate.sequence
|
|
1334
|
+
});
|
|
1335
|
+
scan.noteOpen(orderingId, predecessor ?? candidate.sequence);
|
|
1336
|
+
})
|
|
602
1337
|
);
|
|
603
1338
|
}
|
|
1339
|
+
/**
|
|
1340
|
+
* Parks the waiting backlogs a claim found. Best effort: parking only saves
|
|
1341
|
+
* later claims work, so a failure is logged and the claim still returns its
|
|
1342
|
+
* batch; the next claim that meets the backlog parks it.
|
|
1343
|
+
*/
|
|
1344
|
+
async parkWaiting(deliveryIds) {
|
|
1345
|
+
try {
|
|
1346
|
+
await this.#parkDeliveries(deliveryIds);
|
|
1347
|
+
} catch (error) {
|
|
1348
|
+
this.#state.logger.warn(
|
|
1349
|
+
`[MongoOrdering](parkWaiting) Parking waiting deliveries failed, a later claim parks them | count=${deliveryIds.length} | error=${String(error)}`
|
|
1350
|
+
);
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
604
1353
|
/** Sequence of the lowest unfinished delivery of the handler and key before `sequence`, if any. */
|
|
605
|
-
async #findUnfinishedPredecessor(handler, key, sequence) {
|
|
606
|
-
const predecessor = await this.#deliveries.findOne(
|
|
1354
|
+
async #findUnfinishedPredecessor(handler, { key, sequence }) {
|
|
1355
|
+
const predecessor = await this.#state.deliveries.findOne(
|
|
607
1356
|
{ handler, key, sequence: { $lt: sequence }, dueAt: { $exists: true } },
|
|
608
1357
|
{ sort: { sequence: 1 }, projection: { sequence: 1 } }
|
|
609
1358
|
);
|
|
610
1359
|
return predecessor?.sequence;
|
|
611
1360
|
}
|
|
612
|
-
/** Whether some delivery of the handler and key runs under a lease that has not expired. */
|
|
613
|
-
async #hasLiveLease(handler, key, now) {
|
|
614
|
-
const running = await this.#deliveries.findOne(
|
|
615
|
-
{ handler, key, status: "processing", dueAt: { $gt: now } },
|
|
616
|
-
{ projection: { _id: 1 } }
|
|
617
|
-
);
|
|
618
|
-
return running !== null;
|
|
619
|
-
}
|
|
620
1361
|
/**
|
|
621
1362
|
* Parks pending deliveries that waited behind an unfinished predecessor, which
|
|
622
1363
|
* removes them from the claim index until their turn. Parking is a hint, not a
|
|
@@ -625,7 +1366,7 @@ var MongoEventStore = class {
|
|
|
625
1366
|
*/
|
|
626
1367
|
async #parkDeliveries(deliveryIds) {
|
|
627
1368
|
for (let offset = 0; offset < deliveryIds.length; offset += PARK_UPDATE_CHUNK_SIZE) {
|
|
628
|
-
await this.#deliveries.updateMany(
|
|
1369
|
+
await this.#state.deliveries.updateMany(
|
|
629
1370
|
{
|
|
630
1371
|
_id: { $in: deliveryIds.slice(offset, offset + PARK_UPDATE_CHUNK_SIZE) },
|
|
631
1372
|
status: "pending",
|
|
@@ -642,8 +1383,8 @@ var MongoEventStore = class {
|
|
|
642
1383
|
* predecessor finishing while its followers were being parked, can delay a key
|
|
643
1384
|
* until the next claim but never strand it.
|
|
644
1385
|
*/
|
|
645
|
-
async
|
|
646
|
-
const anyParked = await this.#deliveries.findOne(
|
|
1386
|
+
async unparkNextDeliveries(handlers) {
|
|
1387
|
+
const anyParked = await this.#state.deliveries.findOne(
|
|
647
1388
|
{ handler: { $in: [...handlers] }, lane: "parked" },
|
|
648
1389
|
// The sort lets the planner pick the small partial index over the handler's whole history.
|
|
649
1390
|
{ sort: { handler: 1, key: 1, sequence: 1 }, projection: { _id: 1 } }
|
|
@@ -651,103 +1392,809 @@ var MongoEventStore = class {
|
|
|
651
1392
|
if (anyParked === null) {
|
|
652
1393
|
return;
|
|
653
1394
|
}
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
1395
|
+
const lowest = (await Promise.all(
|
|
1396
|
+
[...new Set(handlers)].map(
|
|
1397
|
+
(handler) => this.#state.deliveries.aggregate([
|
|
1398
|
+
{ $match: { handler, lane: "parked" } },
|
|
1399
|
+
{ $sort: { handler: 1, key: 1, sequence: 1 } },
|
|
658
1400
|
{
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
1401
|
+
$group: { _id: "$key", id: { $first: "$_id" }, sequence: { $first: "$sequence" } }
|
|
1402
|
+
}
|
|
1403
|
+
]).toArray().then((groups) => groups.map((group) => ({ ...group, handler })))
|
|
1404
|
+
)
|
|
1405
|
+
)).flat();
|
|
1406
|
+
const next = await Promise.all(
|
|
1407
|
+
lowest.map(
|
|
1408
|
+
async (parked) => await this.#findUnfinishedPredecessor(parked.handler, {
|
|
1409
|
+
key: parked._id,
|
|
1410
|
+
sequence: parked.sequence
|
|
1411
|
+
}) === void 0 ? [parked.id] : []
|
|
1412
|
+
)
|
|
1413
|
+
);
|
|
1414
|
+
const ready = next.flat();
|
|
1415
|
+
if (ready.length > 0) {
|
|
1416
|
+
await this.#state.deliveries.updateMany(
|
|
1417
|
+
{ _id: { $in: ready }, lane: "parked" },
|
|
1418
|
+
{ $set: { lane: "ready" } }
|
|
1419
|
+
);
|
|
676
1420
|
}
|
|
677
1421
|
}
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
handler: document.handler,
|
|
684
|
-
attempt: document.attempts,
|
|
685
|
-
leaseExpiresAt,
|
|
686
|
-
event: events.get(document.eventId)
|
|
687
|
-
}));
|
|
1422
|
+
};
|
|
1423
|
+
function isClaimable(candidate, { scan, blockedKeys }) {
|
|
1424
|
+
const { key, sequence } = candidate;
|
|
1425
|
+
if (key === void 0) {
|
|
1426
|
+
return true;
|
|
688
1427
|
}
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
1428
|
+
const orderingId = orderingIdentity(candidate.handler, key);
|
|
1429
|
+
if (scan.waits(orderingId, sequence)) {
|
|
1430
|
+
scan.noteWaiting(orderingId, { deliveryId: candidate._id, laneId: candidate.laneId });
|
|
1431
|
+
return false;
|
|
692
1432
|
}
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
1433
|
+
if (blockedKeys.has(orderingId)) {
|
|
1434
|
+
return false;
|
|
1435
|
+
}
|
|
1436
|
+
blockedKeys.add(orderingId);
|
|
1437
|
+
return true;
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
// src/mongo-purge.ts
|
|
1441
|
+
var MongoPurge = class {
|
|
1442
|
+
#state;
|
|
1443
|
+
constructor(state) {
|
|
1444
|
+
this.#state = state;
|
|
1445
|
+
}
|
|
1446
|
+
/** Implements {@link MongoEventStore.purge}. */
|
|
1447
|
+
async purge(request) {
|
|
1448
|
+
const total = { deletedDeliveries: 0, deletedEvents: 0 };
|
|
1449
|
+
const steps = [];
|
|
1450
|
+
const { completedBefore, deadBefore } = request;
|
|
1451
|
+
if (completedBefore !== void 0) {
|
|
1452
|
+
steps.push(
|
|
1453
|
+
() => this.#purgeMatching(this.#purgeable(this.#state.deliveries.collectionName), {
|
|
1454
|
+
filter: { status: "completed", completedAt: { $lt: completedBefore } },
|
|
1455
|
+
completedBefore
|
|
1456
|
+
})
|
|
1457
|
+
);
|
|
1458
|
+
if (this.#state.completionHistory) {
|
|
1459
|
+
steps.push(
|
|
1460
|
+
() => this.#purgeMatching(this.#purgeable(this.#state.completions.collectionName), {
|
|
1461
|
+
filter: { completedAt: { $lt: completedBefore } },
|
|
1462
|
+
completedBefore
|
|
1463
|
+
})
|
|
1464
|
+
);
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
if (deadBefore !== void 0) {
|
|
1468
|
+
steps.push(
|
|
1469
|
+
() => this.#purgeMatching(this.#purgeable(this.#state.deliveries.collectionName), {
|
|
1470
|
+
filter: { status: "dead", updatedAt: { $lt: deadBefore } },
|
|
1471
|
+
completedBefore
|
|
1472
|
+
})
|
|
1473
|
+
);
|
|
1474
|
+
}
|
|
1475
|
+
for (const step of steps) {
|
|
1476
|
+
const result = await step();
|
|
1477
|
+
total.deletedDeliveries += result.deletedDeliveries;
|
|
1478
|
+
total.deletedEvents += result.deletedEvents;
|
|
1479
|
+
}
|
|
1480
|
+
const orphanCutoff = [completedBefore, deadBefore].filter((cutoff) => cutoff !== void 0).sort((first, second) => first.getTime() - second.getTime())[0];
|
|
1481
|
+
if (orphanCutoff !== void 0) {
|
|
1482
|
+
total.deletedEvents += await this.#purgeOrphanEvents(orphanCutoff, completedBefore);
|
|
1483
|
+
}
|
|
1484
|
+
return total;
|
|
700
1485
|
}
|
|
701
1486
|
/**
|
|
702
|
-
* Deletes batches of matching deliveries
|
|
703
|
-
* references any more, each batch
|
|
704
|
-
*
|
|
705
|
-
*
|
|
706
|
-
*
|
|
707
|
-
*
|
|
1487
|
+
* Deletes batches of matching documents (deliveries, or completion history
|
|
1488
|
+
* records) together with the events nothing references any more, each batch
|
|
1489
|
+
* in one transaction, so a crash can never leave an event without deliveries
|
|
1490
|
+
* behind. An append adding a delivery to the same event writes the event
|
|
1491
|
+
* document, so the two conflict and one restarts instead of leaving a delivery
|
|
1492
|
+
* without event. A delivery requeued since the batch was read conflicts with
|
|
1493
|
+
* its deletion and is read again.
|
|
708
1494
|
*/
|
|
709
|
-
async #purgeMatching(
|
|
1495
|
+
async #purgeMatching(collection, {
|
|
1496
|
+
filter,
|
|
1497
|
+
completedBefore
|
|
1498
|
+
}) {
|
|
710
1499
|
let deletedDeliveries = 0;
|
|
711
1500
|
let deletedEvents = 0;
|
|
712
1501
|
for (; ; ) {
|
|
713
|
-
const step = await this
|
|
714
|
-
const batch = await
|
|
1502
|
+
const step = await runInTransaction(this.#state, async (session) => {
|
|
1503
|
+
const batch = await collection.find(filter, { session, projection: { eventId: 1 }, limit: this.#state.purgeBatchSize }).toArray();
|
|
1504
|
+
if (batch.length === 0) {
|
|
1505
|
+
return { batchSize: 0, deletedDeliveries: 0, deletedEvents: 0 };
|
|
1506
|
+
}
|
|
715
1507
|
const eventIds = [...new Set(batch.map((document) => document.eventId))];
|
|
716
|
-
const
|
|
1508
|
+
const deleted = await collection.deleteMany(
|
|
717
1509
|
{ _id: { $in: batch.map((document) => document._id) } },
|
|
718
1510
|
{ session }
|
|
719
1511
|
);
|
|
720
|
-
const stillReferenced =
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
1512
|
+
const stillReferenced = await this.#referencedEventIds(eventIds, session);
|
|
1513
|
+
const events = await this.#state.events.deleteMany(
|
|
1514
|
+
{
|
|
1515
|
+
_id: { $in: eventIds.filter((eventId) => !stillReferenced.has(eventId)) },
|
|
1516
|
+
// An event a delivery of which completed stays as deduplication record until
|
|
1517
|
+
// `completedBefore` passes its last completion, whatever this batch purged.
|
|
1518
|
+
$or: [
|
|
1519
|
+
{ completedAt: { $exists: false } },
|
|
1520
|
+
...completedBefore === void 0 ? [] : [{ completedAt: { $lt: completedBefore } }]
|
|
1521
|
+
]
|
|
1522
|
+
},
|
|
725
1523
|
{ session }
|
|
726
1524
|
);
|
|
727
1525
|
return {
|
|
728
1526
|
batchSize: batch.length,
|
|
729
|
-
deletedDeliveries:
|
|
1527
|
+
deletedDeliveries: deleted.deletedCount,
|
|
730
1528
|
deletedEvents: events.deletedCount
|
|
731
1529
|
};
|
|
732
1530
|
});
|
|
733
1531
|
deletedDeliveries += step.deletedDeliveries;
|
|
734
1532
|
deletedEvents += step.deletedEvents;
|
|
735
|
-
if (step.batchSize < this.#purgeBatchSize) {
|
|
1533
|
+
if (step.batchSize < this.#state.purgeBatchSize) {
|
|
736
1534
|
return { deletedDeliveries, deletedEvents };
|
|
737
1535
|
}
|
|
738
1536
|
}
|
|
739
1537
|
}
|
|
1538
|
+
/** A view of a collection that purge deletes from: deliveries or completion history records. */
|
|
1539
|
+
#purgeable(name) {
|
|
1540
|
+
return this.#state.database.collection(name, {
|
|
1541
|
+
readPreference: "primary",
|
|
1542
|
+
writeConcern: { w: "majority" }
|
|
1543
|
+
});
|
|
1544
|
+
}
|
|
1545
|
+
/** Those of `eventIds` that a delivery or a completion history record references. */
|
|
1546
|
+
async #referencedEventIds(eventIds, session) {
|
|
1547
|
+
const filter = { eventId: { $in: [...eventIds] } };
|
|
1548
|
+
const options = { session, projection: { eventId: 1 } };
|
|
1549
|
+
const references = [
|
|
1550
|
+
...await this.#state.deliveries.find(filter, options).toArray(),
|
|
1551
|
+
...this.#state.completionHistory ? await this.#state.completions.find(filter, options).toArray() : []
|
|
1552
|
+
];
|
|
1553
|
+
return new Set(references.map((reference) => reference.eventId));
|
|
1554
|
+
}
|
|
1555
|
+
/**
|
|
1556
|
+
* Deletes, in batches, events created before `cutoff` that no delivery (or
|
|
1557
|
+
* completion history record) references. Each batch is found and deleted in
|
|
1558
|
+
* one transaction: an append adding a delivery to one of them writes the
|
|
1559
|
+
* event, so it conflicts with the deletion, which then restarts and sees the
|
|
1560
|
+
* delivery. Returns how many events were deleted.
|
|
1561
|
+
*/
|
|
1562
|
+
async #purgeOrphanEvents(cutoff, completedBefore) {
|
|
1563
|
+
const references = [
|
|
1564
|
+
this.#state.deliveries.collectionName,
|
|
1565
|
+
...this.#state.completionHistory ? [this.#state.completions.collectionName] : []
|
|
1566
|
+
];
|
|
1567
|
+
let deletedEvents = 0;
|
|
1568
|
+
for (; ; ) {
|
|
1569
|
+
const step = await runInTransaction(this.#state, async (session) => {
|
|
1570
|
+
const orphans = await this.#state.events.aggregate(
|
|
1571
|
+
[
|
|
1572
|
+
{
|
|
1573
|
+
$match: {
|
|
1574
|
+
$or: [
|
|
1575
|
+
// Never completed (no subscribers, or every delivery dead): by age.
|
|
1576
|
+
{ completedAt: { $exists: false }, createdAt: { $lt: cutoff } },
|
|
1577
|
+
// Completed: by the time of its last completion, and only under the completed cutoff.
|
|
1578
|
+
...completedBefore === void 0 ? [] : [{ completedAt: { $lt: completedBefore } }]
|
|
1579
|
+
]
|
|
1580
|
+
}
|
|
1581
|
+
},
|
|
1582
|
+
{ $sort: { createdAt: 1, _id: 1 } },
|
|
1583
|
+
...references.flatMap((from) => [
|
|
1584
|
+
{
|
|
1585
|
+
$lookup: {
|
|
1586
|
+
from,
|
|
1587
|
+
localField: "_id",
|
|
1588
|
+
foreignField: "eventId",
|
|
1589
|
+
pipeline: [{ $limit: 1 }, { $project: { _id: 1 } }],
|
|
1590
|
+
as: "references"
|
|
1591
|
+
}
|
|
1592
|
+
},
|
|
1593
|
+
{ $match: { references: { $size: 0 } } }
|
|
1594
|
+
]),
|
|
1595
|
+
{ $limit: this.#state.purgeBatchSize },
|
|
1596
|
+
{ $project: { _id: 1 } }
|
|
1597
|
+
],
|
|
1598
|
+
{ session }
|
|
1599
|
+
).toArray();
|
|
1600
|
+
const deleted = await this.#state.events.deleteMany(
|
|
1601
|
+
{ _id: { $in: orphans.map((orphan) => orphan._id) } },
|
|
1602
|
+
{ session }
|
|
1603
|
+
);
|
|
1604
|
+
return { found: orphans.length, deleted: deleted.deletedCount };
|
|
1605
|
+
});
|
|
1606
|
+
deletedEvents += step.deleted;
|
|
1607
|
+
if (step.found < this.#state.purgeBatchSize) {
|
|
1608
|
+
return deletedEvents;
|
|
1609
|
+
}
|
|
1610
|
+
}
|
|
1611
|
+
}
|
|
1612
|
+
};
|
|
1613
|
+
|
|
1614
|
+
// src/mongo-queries.ts
|
|
1615
|
+
var DEFAULT_LIST_LIMIT = 100;
|
|
1616
|
+
var MongoQueries = class {
|
|
1617
|
+
#state;
|
|
1618
|
+
constructor(state) {
|
|
1619
|
+
this.#state = state;
|
|
1620
|
+
}
|
|
1621
|
+
/** Implements {@link MongoEventStore.listDeliveries}. */
|
|
1622
|
+
async listDeliveries(query) {
|
|
1623
|
+
const status = query.status === void 0 ? void 0 : String(query.status);
|
|
1624
|
+
const common = commonFilter(query);
|
|
1625
|
+
const limit = query.limit ?? DEFAULT_LIST_LIMIT;
|
|
1626
|
+
const lanes = await this.#state.lanes.find({ "inFlight.0": { $exists: true } }, { projection: { epoch: 0 } }).toArray();
|
|
1627
|
+
const listed = lanes.flatMap((lane) => lane.inFlight);
|
|
1628
|
+
const derivedStatuses = /* @__PURE__ */ new Map([
|
|
1629
|
+
["processing", { status: "pending", _id: { $in: listed } }],
|
|
1630
|
+
["pending", { status: "pending", _id: { $nin: listed } }]
|
|
1631
|
+
]);
|
|
1632
|
+
const filter = {
|
|
1633
|
+
...common,
|
|
1634
|
+
...status === void 0 ? {} : derivedStatuses.get(status) ?? { status }
|
|
1635
|
+
};
|
|
1636
|
+
const sort = { createdAt: 1, _id: 1 };
|
|
1637
|
+
const documents = await this.#state.deliveries.find(filter, { sort, limit }).toArray();
|
|
1638
|
+
const history = this.#state.completionHistory && (status === void 0 || status === "completed") ? await this.#state.completions.find(common, { sort, limit }).toArray() : [];
|
|
1639
|
+
const events = await this.readEnvelopes([
|
|
1640
|
+
...documents.map((document) => document.eventId),
|
|
1641
|
+
...history.map((document) => document.eventId)
|
|
1642
|
+
]);
|
|
1643
|
+
const laneById = new Map(lanes.map((lane) => [lane._id, lane]));
|
|
1644
|
+
const records = [
|
|
1645
|
+
...documents.map((document) => {
|
|
1646
|
+
const lane = laneById.get(document.laneId);
|
|
1647
|
+
const runs = document.status === "pending" && lane?.inFlight.includes(document._id) === true;
|
|
1648
|
+
return toDeliveryRecord(document, {
|
|
1649
|
+
event: events.get(document.eventId),
|
|
1650
|
+
runningIn: runs ? lane : void 0
|
|
1651
|
+
});
|
|
1652
|
+
}),
|
|
1653
|
+
...history.map(
|
|
1654
|
+
(document) => toCompletedDeliveryRecord(document, events.get(document.eventId))
|
|
1655
|
+
)
|
|
1656
|
+
];
|
|
1657
|
+
return records.sort(
|
|
1658
|
+
(left, right) => left.createdAt.getTime() - right.createdAt.getTime() || compareText(left.id, right.id)
|
|
1659
|
+
).slice(0, limit);
|
|
1660
|
+
}
|
|
1661
|
+
/** Implements {@link MongoEventStore.requeue}. */
|
|
1662
|
+
async requeue(deliveryIds, now) {
|
|
1663
|
+
const result = await this.#state.deliveries.updateMany(
|
|
1664
|
+
{ _id: { $in: deliveryIds.map(String) }, status: "dead" },
|
|
1665
|
+
{
|
|
1666
|
+
$set: {
|
|
1667
|
+
status: "pending",
|
|
1668
|
+
lane: "ready",
|
|
1669
|
+
attempts: 0,
|
|
1670
|
+
availableAt: now,
|
|
1671
|
+
dueAt: now,
|
|
1672
|
+
updatedAt: now
|
|
1673
|
+
}
|
|
1674
|
+
}
|
|
1675
|
+
);
|
|
1676
|
+
return result.modifiedCount;
|
|
1677
|
+
}
|
|
1678
|
+
/** Implements {@link MongoEventStore.statistics}. */
|
|
1679
|
+
async statistics(now) {
|
|
1680
|
+
const lanes = await this.#state.lanes.find({ "inFlight.0": { $exists: true } }, { projection: { inFlight: 1, leaseExpiresAt: 1 } }).toArray();
|
|
1681
|
+
const [stored, dead, legacyCompleted, history, running] = await Promise.all([
|
|
1682
|
+
this.#state.deliveries.countDocuments({ status: "pending" }),
|
|
1683
|
+
this.#state.deliveries.countDocuments({ status: "dead" }),
|
|
1684
|
+
this.#state.deliveries.countDocuments({ status: "completed" }),
|
|
1685
|
+
this.#state.completionHistory ? this.#state.completions.countDocuments() : Promise.resolve(0),
|
|
1686
|
+
this.findRunning(lanes.flatMap((lane) => lane.inFlight))
|
|
1687
|
+
]);
|
|
1688
|
+
const runningIds = new Set(running.map((document) => document._id));
|
|
1689
|
+
const waiting = await this.#state.deliveries.findOne(
|
|
1690
|
+
{ dueAt: { $lte: now }, _id: { $nin: [...runningIds] } },
|
|
1691
|
+
{ sort: { dueAt: 1 }, projection: { dueAt: 1 } }
|
|
1692
|
+
);
|
|
1693
|
+
const dueTimes = lanes.filter(
|
|
1694
|
+
(lane) => lane.leaseExpiresAt !== null && lane.leaseExpiresAt <= now && lane.inFlight.some((id) => runningIds.has(id))
|
|
1695
|
+
).map((lane) => lane.leaseExpiresAt.getTime());
|
|
1696
|
+
if (waiting !== null) {
|
|
1697
|
+
dueTimes.push(waiting.dueAt.getTime());
|
|
1698
|
+
}
|
|
1699
|
+
return {
|
|
1700
|
+
// The counts are separate reads; a completion between them must not make this negative.
|
|
1701
|
+
pending: Math.max(0, stored - running.length),
|
|
1702
|
+
processing: running.length,
|
|
1703
|
+
completed: legacyCompleted + history,
|
|
1704
|
+
dead,
|
|
1705
|
+
oldestDueAt: dueTimes.length === 0 ? null : new Date(Math.min(...dueTimes))
|
|
1706
|
+
};
|
|
1707
|
+
}
|
|
1708
|
+
/** The listed deliveries that run: those whose document still exists as pending. */
|
|
1709
|
+
async findRunning(listed) {
|
|
1710
|
+
if (listed.length === 0) {
|
|
1711
|
+
return [];
|
|
1712
|
+
}
|
|
1713
|
+
return this.#state.deliveries.find(
|
|
1714
|
+
{ _id: { $in: [...listed] }, status: "pending" },
|
|
1715
|
+
{ projection: { handler: 1, key: 1, laneId: 1 } }
|
|
1716
|
+
).toArray();
|
|
1717
|
+
}
|
|
1718
|
+
async readEnvelopes(eventIds) {
|
|
1719
|
+
const documents = await this.#state.events.find({ _id: { $in: [...new Set(eventIds)] } }).toArray();
|
|
1720
|
+
return new Map(documents.map((document) => [document._id, toEnvelope(document)]));
|
|
1721
|
+
}
|
|
740
1722
|
};
|
|
741
|
-
function
|
|
742
|
-
return
|
|
1723
|
+
function compareText(left, right) {
|
|
1724
|
+
return Number(left > right) - Number(left < right);
|
|
743
1725
|
}
|
|
744
|
-
function
|
|
745
|
-
|
|
1726
|
+
function commonFilter(query) {
|
|
1727
|
+
return {
|
|
1728
|
+
...query.handler === void 0 ? {} : { handler: String(query.handler) },
|
|
1729
|
+
...query.eventType === void 0 ? {} : { eventType: String(query.eventType) },
|
|
1730
|
+
...query.eventId === void 0 ? {} : { eventId: String(query.eventId) }
|
|
1731
|
+
};
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
// src/mongo-schema.ts
|
|
1735
|
+
var NAMESPACE_EXISTS = 48;
|
|
1736
|
+
var INDEX_NOT_FOUND = 27;
|
|
1737
|
+
var UPGRADE_BATCH_SIZE = 1e3;
|
|
1738
|
+
var CLAIM_INDEX_KEY = { laneId: 1, dueAt: 1, _id: 1, handler: 1 };
|
|
1739
|
+
var OBSOLETE_INDEXES = ["running"];
|
|
1740
|
+
var LEASE_FIELDS = { leaseOwner: "", leaseToken: "", leaseExpiresAt: "" };
|
|
1741
|
+
var MongoSchema = class {
|
|
1742
|
+
#state;
|
|
1743
|
+
constructor(state) {
|
|
1744
|
+
this.#state = state;
|
|
1745
|
+
}
|
|
1746
|
+
/** Implements {@link MongoEventStore.setup}. */
|
|
1747
|
+
async setup() {
|
|
1748
|
+
const hello = await this.#state.client.db("admin").command({ hello: 1 });
|
|
1749
|
+
const supportsTransactions = typeof hello["setName"] === "string" || hello["msg"] === "isdbgrid";
|
|
1750
|
+
if (!supportsTransactions) {
|
|
1751
|
+
throw createTransactionsUnsupportedError(void 0);
|
|
1752
|
+
}
|
|
1753
|
+
const collectionNames = [
|
|
1754
|
+
this.#state.events.collectionName,
|
|
1755
|
+
this.#state.deliveries.collectionName,
|
|
1756
|
+
this.#state.keySequences.collectionName,
|
|
1757
|
+
this.#state.lanes.collectionName,
|
|
1758
|
+
...this.#state.completionHistory ? [this.#state.completions.collectionName] : []
|
|
1759
|
+
];
|
|
1760
|
+
for (const name of collectionNames) {
|
|
1761
|
+
await this.#createCollectionIfMissing(name);
|
|
1762
|
+
}
|
|
1763
|
+
if (await this.#state.lanes.countDocuments() < LANE_COUNT) {
|
|
1764
|
+
await this.#upgradeFromVersion010();
|
|
1765
|
+
await this.#seedLanes();
|
|
1766
|
+
}
|
|
1767
|
+
await this.#dropObsoleteIndexes();
|
|
1768
|
+
await this.#createIndexes();
|
|
1769
|
+
}
|
|
1770
|
+
/** Creates the indexes of every collection; existing indexes are left as they are. */
|
|
1771
|
+
async #createIndexes() {
|
|
1772
|
+
await this.#state.deliveries.createIndexes([
|
|
1773
|
+
// Idempotent appends and lookups by event.
|
|
1774
|
+
{ key: { eventId: 1, handler: 1 }, name: "event_handler", unique: true },
|
|
1775
|
+
// Claim: due work of a lane in due order. Holds ready deliveries only. The handler is
|
|
1776
|
+
// last, so a query over many lanes and handlers stays a merge of one scan per lane.
|
|
1777
|
+
{ key: CLAIM_INDEX_KEY, name: "claim", partialFilterExpression: { lane: "ready" } },
|
|
1778
|
+
// Ordering: unfinished predecessors of a keyed delivery, parked or not.
|
|
1779
|
+
{
|
|
1780
|
+
key: { handler: 1, key: 1, sequence: 1 },
|
|
1781
|
+
name: "ordering",
|
|
1782
|
+
partialFilterExpression: { dueAt: { $exists: true }, sequence: { $exists: true } }
|
|
1783
|
+
},
|
|
1784
|
+
// Parked deliveries: the lowest parked delivery of each key, to move it back once it is next.
|
|
1785
|
+
{
|
|
1786
|
+
key: { handler: 1, key: 1, sequence: 1 },
|
|
1787
|
+
name: "parked",
|
|
1788
|
+
partialFilterExpression: { lane: "parked" }
|
|
1789
|
+
},
|
|
1790
|
+
// Statistics: oldest due delivery across handlers.
|
|
1791
|
+
{ key: { dueAt: 1 }, name: "due", partialFilterExpression: { dueAt: { $exists: true } } },
|
|
1792
|
+
// Purge of completed deliveries left by 0.1.0.
|
|
1793
|
+
{
|
|
1794
|
+
key: { completedAt: 1 },
|
|
1795
|
+
name: "completed",
|
|
1796
|
+
partialFilterExpression: { completedAt: { $exists: true } }
|
|
1797
|
+
},
|
|
1798
|
+
// Listing and status counts.
|
|
1799
|
+
{ key: { status: 1, createdAt: 1 }, name: "status_created" },
|
|
1800
|
+
// Purge of dead deliveries.
|
|
1801
|
+
{ key: { status: 1, updatedAt: 1 }, name: "status_updated" },
|
|
1802
|
+
{ key: { handler: 1, createdAt: 1 }, name: "handler_created" },
|
|
1803
|
+
{ key: { eventType: 1, createdAt: 1 }, name: "type_created" }
|
|
1804
|
+
]);
|
|
1805
|
+
await this.#state.events.createIndexes([
|
|
1806
|
+
{ key: { createdAt: 1, _id: 1 }, name: "created" },
|
|
1807
|
+
// Purge of events whose deliveries all completed: they are kept until `completedBefore` passes this.
|
|
1808
|
+
{
|
|
1809
|
+
key: { completedAt: 1, _id: 1 },
|
|
1810
|
+
name: "completed",
|
|
1811
|
+
partialFilterExpression: { completedAt: { $exists: true } }
|
|
1812
|
+
}
|
|
1813
|
+
]);
|
|
1814
|
+
if (this.#state.completionHistory) {
|
|
1815
|
+
await this.#state.completions.createIndexes([
|
|
1816
|
+
{ key: { completedAt: 1, _id: 1 }, name: "completed" },
|
|
1817
|
+
{ key: { eventId: 1 }, name: "event" },
|
|
1818
|
+
{ key: { createdAt: 1, _id: 1 }, name: "created" }
|
|
1819
|
+
]);
|
|
1820
|
+
}
|
|
1821
|
+
}
|
|
1822
|
+
async #createCollectionIfMissing(name) {
|
|
1823
|
+
try {
|
|
1824
|
+
await this.#state.database.createCollection(name);
|
|
1825
|
+
} catch (error) {
|
|
1826
|
+
if (!hasErrorCode(error, NAMESPACE_EXISTS)) {
|
|
1827
|
+
throw error;
|
|
1828
|
+
}
|
|
1829
|
+
}
|
|
1830
|
+
}
|
|
1831
|
+
/**
|
|
1832
|
+
* Converts the documents of a 0.1.0 database; every step is idempotent, so a
|
|
1833
|
+
* run that crashed is simply repeated. Deliveries that were `processing`
|
|
1834
|
+
* become `pending`, due at once (their attempt stays counted, as a lost lease
|
|
1835
|
+
* would count it). The per-delivery lease fields go, every delivery gets its
|
|
1836
|
+
* lane, and every event learns which handlers it already has deliveries for
|
|
1837
|
+
* and when it was created. Deliveries completed under 0.1.0 stay as
|
|
1838
|
+
* `completed` documents until a purge removes them.
|
|
1839
|
+
*/
|
|
1840
|
+
async #upgradeFromVersion010() {
|
|
1841
|
+
const legacy = this.#state.database.collection(this.#state.deliveries.collectionName);
|
|
1842
|
+
await legacy.updateMany({ status: "processing" }, [
|
|
1843
|
+
{ $set: { status: "pending", lane: "ready", dueAt: "$availableAt" } }
|
|
1844
|
+
]);
|
|
1845
|
+
await legacy.updateMany(
|
|
1846
|
+
{
|
|
1847
|
+
$or: [
|
|
1848
|
+
{ leaseOwner: { $exists: true } },
|
|
1849
|
+
{ leaseToken: { $exists: true } },
|
|
1850
|
+
{ leaseExpiresAt: { $exists: true } }
|
|
1851
|
+
]
|
|
1852
|
+
},
|
|
1853
|
+
{ $unset: LEASE_FIELDS }
|
|
1854
|
+
);
|
|
1855
|
+
await inBatches(
|
|
1856
|
+
this.#state.deliveries.find(
|
|
1857
|
+
{ laneId: { $exists: false } },
|
|
1858
|
+
{ projection: { eventId: 1, key: 1 }, batchSize: UPGRADE_BATCH_SIZE }
|
|
1859
|
+
),
|
|
1860
|
+
async (documents) => {
|
|
1861
|
+
await this.#state.deliveries.bulkWrite(
|
|
1862
|
+
documents.map((document) => ({
|
|
1863
|
+
updateOne: {
|
|
1864
|
+
filter: { _id: document._id },
|
|
1865
|
+
update: { $set: { laneId: laneOf(document.key ?? null, document.eventId) } }
|
|
1866
|
+
}
|
|
1867
|
+
})),
|
|
1868
|
+
{ ordered: false }
|
|
1869
|
+
);
|
|
1870
|
+
}
|
|
1871
|
+
);
|
|
1872
|
+
await this.#upgradeEventsFromVersion010();
|
|
1873
|
+
}
|
|
1874
|
+
/** Tells every 0.1.0 event which handlers it already has deliveries for and when it was created. */
|
|
1875
|
+
async #upgradeEventsFromVersion010() {
|
|
1876
|
+
await inBatches(
|
|
1877
|
+
this.#state.events.find(
|
|
1878
|
+
{ handlers: { $exists: false } },
|
|
1879
|
+
{ projection: { occurredAt: 1 }, batchSize: UPGRADE_BATCH_SIZE }
|
|
1880
|
+
),
|
|
1881
|
+
async (events) => {
|
|
1882
|
+
const known = new Map(
|
|
1883
|
+
(await this.#state.deliveries.aggregate([
|
|
1884
|
+
{ $match: { eventId: { $in: events.map((event) => event._id) } } },
|
|
1885
|
+
{
|
|
1886
|
+
$group: {
|
|
1887
|
+
_id: "$eventId",
|
|
1888
|
+
handlers: { $addToSet: "$handler" },
|
|
1889
|
+
createdAt: { $min: "$createdAt" }
|
|
1890
|
+
}
|
|
1891
|
+
}
|
|
1892
|
+
]).toArray()).map((group) => [group._id, group])
|
|
1893
|
+
);
|
|
1894
|
+
await this.#state.events.bulkWrite(
|
|
1895
|
+
events.map((event) => {
|
|
1896
|
+
const group = known.get(event._id);
|
|
1897
|
+
return {
|
|
1898
|
+
updateOne: {
|
|
1899
|
+
// Only an event nobody converted yet: another process may have finished and subscribed a handler.
|
|
1900
|
+
filter: { _id: event._id, handlers: { $exists: false } },
|
|
1901
|
+
update: {
|
|
1902
|
+
$set: group === void 0 ? { handlers: [], createdAt: event.occurredAt } : { handlers: group.handlers, createdAt: group.createdAt }
|
|
1903
|
+
}
|
|
1904
|
+
}
|
|
1905
|
+
};
|
|
1906
|
+
}),
|
|
1907
|
+
{ ordered: false }
|
|
1908
|
+
);
|
|
1909
|
+
}
|
|
1910
|
+
);
|
|
1911
|
+
}
|
|
1912
|
+
/** Creates the lane documents that do not exist yet. */
|
|
1913
|
+
async #seedLanes() {
|
|
1914
|
+
await this.#state.lanes.bulkWrite(
|
|
1915
|
+
Array.from({ length: LANE_COUNT }, (_, laneId) => ({
|
|
1916
|
+
updateOne: {
|
|
1917
|
+
filter: { _id: laneId },
|
|
1918
|
+
update: {
|
|
1919
|
+
$setOnInsert: {
|
|
1920
|
+
epoch: 0,
|
|
1921
|
+
owner: null,
|
|
1922
|
+
holder: null,
|
|
1923
|
+
leaseExpiresAt: null,
|
|
1924
|
+
inFlight: [],
|
|
1925
|
+
lost: []
|
|
1926
|
+
}
|
|
1927
|
+
},
|
|
1928
|
+
upsert: true
|
|
1929
|
+
}
|
|
1930
|
+
})),
|
|
1931
|
+
{ ordered: false }
|
|
1932
|
+
);
|
|
1933
|
+
}
|
|
1934
|
+
/** Drops the 0.1.0 indexes that no query uses, and a `claim` index with the 0.1.0 key. */
|
|
1935
|
+
async #dropObsoleteIndexes() {
|
|
1936
|
+
for (const index of await this.#state.deliveries.indexes()) {
|
|
1937
|
+
if (isObsoleteIndex(index)) {
|
|
1938
|
+
await this.#dropIndexIfPresent(index.name);
|
|
1939
|
+
}
|
|
1940
|
+
}
|
|
1941
|
+
}
|
|
1942
|
+
async #dropIndexIfPresent(name) {
|
|
1943
|
+
try {
|
|
1944
|
+
await this.#state.deliveries.dropIndex(name);
|
|
1945
|
+
} catch (error) {
|
|
1946
|
+
if (!hasErrorCode(error, INDEX_NOT_FOUND)) {
|
|
1947
|
+
throw error;
|
|
1948
|
+
}
|
|
1949
|
+
}
|
|
1950
|
+
}
|
|
1951
|
+
};
|
|
1952
|
+
function isObsoleteIndex(index) {
|
|
1953
|
+
return OBSOLETE_INDEXES.includes(index.name) || index.name === "claim" && Object.keys(index.key).join() !== Object.keys(CLAIM_INDEX_KEY).join();
|
|
1954
|
+
}
|
|
1955
|
+
async function inBatches(cursor, work) {
|
|
1956
|
+
let batch = [];
|
|
1957
|
+
for await (const document of cursor) {
|
|
1958
|
+
batch.push(document);
|
|
1959
|
+
if (batch.length === UPGRADE_BATCH_SIZE) {
|
|
1960
|
+
await work(batch);
|
|
1961
|
+
batch = [];
|
|
1962
|
+
}
|
|
1963
|
+
}
|
|
1964
|
+
if (batch.length > 0) {
|
|
1965
|
+
await work(batch);
|
|
1966
|
+
}
|
|
1967
|
+
}
|
|
1968
|
+
function hasErrorCode(error, code) {
|
|
1969
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === code;
|
|
1970
|
+
}
|
|
1971
|
+
var COLLECTION_PREFIX_PATTERN = /^[A-Za-z_][A-Za-z0-9_]{0,39}$/;
|
|
1972
|
+
var DATABASE_NAME_PATTERN = /^[A-Za-z0-9_-]{1,63}$/;
|
|
1973
|
+
function assertCollectionPrefix(prefix) {
|
|
1974
|
+
if (!COLLECTION_PREFIX_PATTERN.test(prefix)) {
|
|
746
1975
|
throw new ConfigurationError(
|
|
747
|
-
`
|
|
1976
|
+
`collectionPrefix '${prefix}' must be 1-40 characters of letters, digits or '_', starting with a letter or '_'`
|
|
748
1977
|
);
|
|
749
1978
|
}
|
|
750
1979
|
}
|
|
1980
|
+
function assertDatabaseName(name) {
|
|
1981
|
+
if (!DATABASE_NAME_PATTERN.test(name)) {
|
|
1982
|
+
throw new ConfigurationError(
|
|
1983
|
+
`database '${name}' must be 1-63 characters of letters, digits, '_' or '-'`
|
|
1984
|
+
);
|
|
1985
|
+
}
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1988
|
+
// src/mongo-event-store.ts
|
|
1989
|
+
var DEFAULT_TRANSACTION_OPTIONS = {
|
|
1990
|
+
readConcern: { level: "snapshot" },
|
|
1991
|
+
writeConcern: { w: "majority" },
|
|
1992
|
+
readPreference: "primary"
|
|
1993
|
+
};
|
|
1994
|
+
var DEFAULT_PURGE_BATCH_SIZE = 500;
|
|
1995
|
+
var MongoEventStore = class {
|
|
1996
|
+
#state;
|
|
1997
|
+
#schema;
|
|
1998
|
+
#appender;
|
|
1999
|
+
#claims;
|
|
2000
|
+
#laneLeases;
|
|
2001
|
+
#completion;
|
|
2002
|
+
#queries;
|
|
2003
|
+
#purger;
|
|
2004
|
+
constructor(options) {
|
|
2005
|
+
const prefix = options.collectionPrefix ?? "relaybus_";
|
|
2006
|
+
assertCollectionPrefix(prefix);
|
|
2007
|
+
if (options.database !== void 0) {
|
|
2008
|
+
assertDatabaseName(options.database);
|
|
2009
|
+
}
|
|
2010
|
+
const purgeBatchSize = assertPositiveInteger(
|
|
2011
|
+
options.purgeBatchSize ?? DEFAULT_PURGE_BATCH_SIZE,
|
|
2012
|
+
"purgeBatchSize"
|
|
2013
|
+
);
|
|
2014
|
+
const database = options.client.db(options.database);
|
|
2015
|
+
const collectionOptions = {
|
|
2016
|
+
readPreference: "primary",
|
|
2017
|
+
writeConcern: { w: "majority" }
|
|
2018
|
+
};
|
|
2019
|
+
const state = {
|
|
2020
|
+
client: options.client,
|
|
2021
|
+
database,
|
|
2022
|
+
events: database.collection(`${prefix}events`, collectionOptions),
|
|
2023
|
+
deliveries: database.collection(`${prefix}deliveries`, collectionOptions),
|
|
2024
|
+
keySequences: database.collection(`${prefix}key_sequences`, collectionOptions),
|
|
2025
|
+
lanes: database.collection(`${prefix}lanes`, collectionOptions),
|
|
2026
|
+
completions: database.collection(`${prefix}completed_deliveries`, collectionOptions),
|
|
2027
|
+
completionHistory: options.completionHistory ?? false,
|
|
2028
|
+
transactionOptions: options.transactionOptions ?? DEFAULT_TRANSACTION_OPTIONS,
|
|
2029
|
+
purgeBatchSize,
|
|
2030
|
+
logger: options.logger ?? console,
|
|
2031
|
+
instanceId: createUuidV7(),
|
|
2032
|
+
claimsInProgress: /* @__PURE__ */ new Map(),
|
|
2033
|
+
handedOut: /* @__PURE__ */ new Map(),
|
|
2034
|
+
unreconciledOwners: /* @__PURE__ */ new Set()
|
|
2035
|
+
};
|
|
2036
|
+
this.#state = state;
|
|
2037
|
+
const queries = new MongoQueries(state);
|
|
2038
|
+
const ordering = new MongoOrdering(state);
|
|
2039
|
+
const laneLeases = new MongoLaneLeases(state, { queries });
|
|
2040
|
+
this.#schema = new MongoSchema(state);
|
|
2041
|
+
this.#appender = new MongoAppend(state);
|
|
2042
|
+
this.#claims = new MongoClaims(state, { laneLeases, ordering, queries });
|
|
2043
|
+
this.#laneLeases = laneLeases;
|
|
2044
|
+
this.#completion = new MongoCompletion(state);
|
|
2045
|
+
this.#queries = queries;
|
|
2046
|
+
this.#purger = new MongoPurge(state);
|
|
2047
|
+
}
|
|
2048
|
+
/**
|
|
2049
|
+
* Verifies the deployment supports transactions, creates the collections and
|
|
2050
|
+
* indexes and seeds the lane documents. Upgrades a 0.1.0 database in place
|
|
2051
|
+
* first (see `MongoSchema.#upgradeFromVersion010`); the seeded lanes
|
|
2052
|
+
* mark the upgrade as done, so later runs skip it. Safe to run repeatedly and
|
|
2053
|
+
* from several processes. Stop 0.1.0 workers before the first run.
|
|
2054
|
+
*
|
|
2055
|
+
* @throws ConfigurationError when the server is a standalone mongod.
|
|
2056
|
+
*/
|
|
2057
|
+
setup() {
|
|
2058
|
+
return this.#schema.setup();
|
|
2059
|
+
}
|
|
2060
|
+
/**
|
|
2061
|
+
* Runs `work` in a transaction on a new session and commits when it resolves.
|
|
2062
|
+
*
|
|
2063
|
+
* `work` can run more than once. The driver restarts the whole callback after
|
|
2064
|
+
* a transient failure such as a write conflict, and re-sends only the commit
|
|
2065
|
+
* after an unknown commit result. A restart is safe for everything written
|
|
2066
|
+
* through the session, because the aborted attempt left nothing behind. It is
|
|
2067
|
+
* not safe for side effects outside the session (HTTP calls, other databases),
|
|
2068
|
+
* so keep those out of `work`; relaybus handlers are already at-least-once.
|
|
2069
|
+
*
|
|
2070
|
+
* With `options.timeoutMs` (from `bus.transaction`) the server bounds the commit with
|
|
2071
|
+
* `maxCommitTimeMS`; the bus aborts a transaction that ran past the budget before its commit.
|
|
2072
|
+
*
|
|
2073
|
+
* @throws ConfigurationError when the deployment does not support transactions.
|
|
2074
|
+
*/
|
|
2075
|
+
transaction(work, options) {
|
|
2076
|
+
return options === void 0 ? runInTransaction(this.#state, work) : runInBudgetedTransaction(this.#state, { work, timeoutMs: options.timeoutMs });
|
|
2077
|
+
}
|
|
2078
|
+
/**
|
|
2079
|
+
* Writes the entries in `session`. An existing event gets a delivery only for
|
|
2080
|
+
* handlers it does not list yet; the added handlers are appended to its list
|
|
2081
|
+
* together with a `revision` increment, a real write that makes a concurrent
|
|
2082
|
+
* purge of the event conflict with this transaction.
|
|
2083
|
+
*/
|
|
2084
|
+
// eslint-disable-next-line relaybus/max-two-params -- EventStore port signature
|
|
2085
|
+
append(session, entries, now) {
|
|
2086
|
+
return this.#appender.append(session, { entries, now });
|
|
2087
|
+
}
|
|
2088
|
+
/**
|
|
2089
|
+
* Claims up to `limit` due deliveries. It claims from the lanes this owner
|
|
2090
|
+
* (and this store instance) holds under a live lease first, then takes free
|
|
2091
|
+
* lanes with due work, a few at a time (4, 8, 16, ...) and the lane with the
|
|
2092
|
+
* oldest due delivery first, so a worker holds only as many lanes as it
|
|
2093
|
+
* needs. It then records the batch with one write per lane and hands back the
|
|
2094
|
+
* lanes that yielded nothing and run nothing. No delivery document is
|
|
2095
|
+
* written, except for the attempt charged to deliveries of a lane taken over
|
|
2096
|
+
* and for parking (see `MongoOrdering.#parkDeliveries`).
|
|
2097
|
+
*
|
|
2098
|
+
* Taking a lane is one conditional `findOneAndUpdate` that also moves the
|
|
2099
|
+
* previous owner's in-flight list to the lane's `lost` list; a lane another
|
|
2100
|
+
* claimer took first is skipped, never waited for. Lost deliveries are charged
|
|
2101
|
+
* one attempt before anything is read from the lane, and `lost` is cleared
|
|
2102
|
+
* only after that. Whoever holds the lane next charges a `lost` list it finds
|
|
2103
|
+
* first, so a claim that fails anywhere after the take loses no charge; the
|
|
2104
|
+
* charge of a lost claim is recorded on the delivery, so charging the list
|
|
2105
|
+
* again after a failure before the clear charges nothing twice.
|
|
2106
|
+
* Nothing a failed claim did leaves a delivery listed as running.
|
|
2107
|
+
*/
|
|
2108
|
+
claim(request) {
|
|
2109
|
+
return this.#claims.claim(request);
|
|
2110
|
+
}
|
|
2111
|
+
/**
|
|
2112
|
+
* Extends the lease of the lane a running delivery belongs to. The lane lease
|
|
2113
|
+
* never shrinks, so a repeated renewal with the same value still holds. An
|
|
2114
|
+
* expired lease nobody took over is still renewed, so `_now` is not needed.
|
|
2115
|
+
*/
|
|
2116
|
+
// eslint-disable-next-line relaybus/max-two-params -- EventStore port signature
|
|
2117
|
+
extendLease(lease, leaseExpiresAt, _now) {
|
|
2118
|
+
return this.#completion.extendLease(lease, leaseExpiresAt);
|
|
2119
|
+
}
|
|
2120
|
+
/**
|
|
2121
|
+
* Deletes the delivery (and records it in the completion history, when kept).
|
|
2122
|
+
* With `session` the deletion commits with the caller's transaction; without,
|
|
2123
|
+
* it runs in a transaction of its own. Returns false when the lease was lost.
|
|
2124
|
+
*/
|
|
2125
|
+
// eslint-disable-next-line relaybus/max-two-params -- EventStore port signature
|
|
2126
|
+
complete(lease, now, session) {
|
|
2127
|
+
return this.#completion.complete(lease, { now, session });
|
|
2128
|
+
}
|
|
2129
|
+
/**
|
|
2130
|
+
* Completes many leases with a fixed number of round trips, whatever lanes
|
|
2131
|
+
* they belong to: three commands without a completion history, one
|
|
2132
|
+
* transaction with it (see `MongoCompletion.#deleteFenced`).
|
|
2133
|
+
* Returns one flag per lease in input order; a lease listed twice completes
|
|
2134
|
+
* once, and a token this store cannot have issued is refused.
|
|
2135
|
+
*/
|
|
2136
|
+
completeMany(leases, now) {
|
|
2137
|
+
return this.#completion.completeMany(leases, now);
|
|
2138
|
+
}
|
|
2139
|
+
/** Returns a running delivery to pending, due at `availableAt`, and counts the attempt that ended. */
|
|
2140
|
+
// eslint-disable-next-line relaybus/max-two-params -- EventStore port signature
|
|
2141
|
+
reschedule(lease, availableAt, error, now) {
|
|
2142
|
+
return this.#completion.reschedule(lease, { availableAt, error, now });
|
|
2143
|
+
}
|
|
2144
|
+
/** Gives a running delivery back without counting the attempt: pending, due `now`. */
|
|
2145
|
+
release(lease, now) {
|
|
2146
|
+
return this.#completion.release(lease, now);
|
|
2147
|
+
}
|
|
2148
|
+
/** Moves a running delivery to dead and counts the attempt that ended. */
|
|
2149
|
+
// eslint-disable-next-line relaybus/max-two-params -- EventStore port signature
|
|
2150
|
+
bury(lease, error, now) {
|
|
2151
|
+
return this.#completion.bury(lease, { error, now });
|
|
2152
|
+
}
|
|
2153
|
+
/**
|
|
2154
|
+
* Frees every lane `leaseOwner` holds that runs no delivery, so other workers
|
|
2155
|
+
* need not wait for the lease to expire. Lanes with running deliveries stay leased.
|
|
2156
|
+
*/
|
|
2157
|
+
relinquish(leaseOwner) {
|
|
2158
|
+
return this.#laneLeases.relinquish(leaseOwner);
|
|
2159
|
+
}
|
|
2160
|
+
/**
|
|
2161
|
+
* Lists deliveries in creation order. A delivery is `processing` while its
|
|
2162
|
+
* document exists as `pending` and its lane lists it; its attempt count then
|
|
2163
|
+
* includes the running attempt, and owner and expiry are those of the lane
|
|
2164
|
+
* lease. Completion history records (when kept) are listed as `completed`.
|
|
2165
|
+
*/
|
|
2166
|
+
listDeliveries(query) {
|
|
2167
|
+
return this.#queries.listDeliveries(query);
|
|
2168
|
+
}
|
|
2169
|
+
/** Moves dead deliveries back to pending with a fresh attempt budget. */
|
|
2170
|
+
requeue(deliveryIds, now) {
|
|
2171
|
+
return this.#queries.requeue(deliveryIds, now);
|
|
2172
|
+
}
|
|
2173
|
+
/**
|
|
2174
|
+
* Deletes, in batches of `purgeBatchSize`, dead deliveries last updated before
|
|
2175
|
+
* `deadBefore`, and completion history records (and `completed` documents left
|
|
2176
|
+
* by 0.1.0) completed before `completedBefore`, each batch in one transaction
|
|
2177
|
+
* together with the events it left without any reference. Finally, events
|
|
2178
|
+
* nothing references are deleted: one with a completed delivery once its last
|
|
2179
|
+
* completion is before `completedBefore` (never by `deadBefore` alone), one
|
|
2180
|
+
* that never had a completion (no subscribers, or only dead letters) once it
|
|
2181
|
+
* was created before the earliest given cutoff. The batch step applies the
|
|
2182
|
+
* same rule to events whose last completion is recorded.
|
|
2183
|
+
*/
|
|
2184
|
+
purge(request) {
|
|
2185
|
+
return this.#purger.purge(request);
|
|
2186
|
+
}
|
|
2187
|
+
/**
|
|
2188
|
+
* Counts by derived status. `pending` excludes running deliveries; `completed`
|
|
2189
|
+
* counts history records and 0.1.0 `completed` documents. `oldestDueAt` is the
|
|
2190
|
+
* oldest instant at which a delivery became claimable: the due time of a
|
|
2191
|
+
* pending delivery nobody runs, or the lease expiry of a lane that still runs
|
|
2192
|
+
* deliveries.
|
|
2193
|
+
*/
|
|
2194
|
+
statistics(now) {
|
|
2195
|
+
return this.#queries.statistics(now);
|
|
2196
|
+
}
|
|
2197
|
+
};
|
|
751
2198
|
|
|
752
2199
|
export { MongoEventStore };
|
|
753
2200
|
//# sourceMappingURL=index.js.map
|