@powerhousedao/reactor 6.2.3-dev.32 → 6.2.3-dev.33
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{build-worker-executor-Bvoba-AV.js → build-worker-executor-BonPsKdx.js} +2 -2
- package/dist/{build-worker-executor-Bvoba-AV.js.map → build-worker-executor-BonPsKdx.js.map} +1 -1
- package/dist/{document-indexer-BVTGbEB0.js → document-indexer-I6i_YzsR.js} +759 -130
- package/dist/document-indexer-I6i_YzsR.js.map +1 -0
- package/dist/entry.js +2 -2
- package/dist/{forwarding-logger-CnuVDVze.js → forwarding-logger-BkwlvD9W.js} +484 -110
- package/dist/forwarding-logger-BkwlvD9W.js.map +1 -0
- package/dist/index.d.ts +2318 -1790
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1122 -140
- package/dist/index.js.map +1 -1
- package/dist/projection-entry.js +54 -6
- package/dist/projection-entry.js.map +1 -1
- package/dist/{projection-shard-manager-BpcqDtnQ.js → projection-shard-manager-CJIdjsd8.js} +23 -3
- package/dist/projection-shard-manager-CJIdjsd8.js.map +1 -0
- package/dist/{worker-handle-C2rLUHwv.js → worker-handle-DjbEQkq-.js} +2 -2
- package/dist/{worker-handle-C2rLUHwv.js.map → worker-handle-DjbEQkq-.js.map} +1 -1
- package/package.json +5 -4
- package/dist/document-indexer-BVTGbEB0.js.map +0 -1
- package/dist/forwarding-logger-CnuVDVze.js.map +0 -1
- package/dist/projection-shard-manager-BpcqDtnQ.js.map +0 -1
|
@@ -1,7 +1,278 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { C as SettledWatermark, at as DocumentNotFoundError, mt as ReactorEventTypes, vt as yieldToMain, w as createKyselyWatermarkProbe } from "./forwarding-logger-BkwlvD9W.js";
|
|
2
2
|
import { createAuthState, isDenied } from "@powerhousedao/shared/document-model";
|
|
3
3
|
import { v4 } from "uuid";
|
|
4
4
|
import { childLogger } from "document-model";
|
|
5
|
+
//#region src/catch-up/scheduler.ts
|
|
6
|
+
const noHooks = {
|
|
7
|
+
onSwept: () => {},
|
|
8
|
+
describeSessions: () => Promise.resolve([])
|
|
9
|
+
};
|
|
10
|
+
function isCatchUpConsumer(value) {
|
|
11
|
+
if (typeof value !== "object" || value === null) return false;
|
|
12
|
+
const candidate = value;
|
|
13
|
+
return typeof candidate.sweep === "function" && typeof candidate.consumerId === "string" && typeof candidate.appliedThrough === "number";
|
|
14
|
+
}
|
|
15
|
+
/** Periodically sweeps every consumer up to the settled watermark. */
|
|
16
|
+
var CatchUpScheduler = class {
|
|
17
|
+
watermark;
|
|
18
|
+
operationIndex;
|
|
19
|
+
config;
|
|
20
|
+
logger;
|
|
21
|
+
hooks;
|
|
22
|
+
fixed = [];
|
|
23
|
+
sources = [];
|
|
24
|
+
sourced = /* @__PURE__ */ new Map();
|
|
25
|
+
timer;
|
|
26
|
+
running;
|
|
27
|
+
started = false;
|
|
28
|
+
stopped = false;
|
|
29
|
+
warnedStallSince;
|
|
30
|
+
statusSources = [];
|
|
31
|
+
constructor(watermark, operationIndex, config, logger, hooks = noHooks) {
|
|
32
|
+
this.watermark = watermark;
|
|
33
|
+
this.operationIndex = operationIndex;
|
|
34
|
+
this.config = config;
|
|
35
|
+
this.logger = logger;
|
|
36
|
+
this.hooks = hooks;
|
|
37
|
+
}
|
|
38
|
+
get settledWatermark() {
|
|
39
|
+
return this.watermark;
|
|
40
|
+
}
|
|
41
|
+
addConsumer(consumer, thread) {
|
|
42
|
+
if (this.fixed.some((entry) => entry.consumer === consumer)) return;
|
|
43
|
+
this.fixed.push({
|
|
44
|
+
consumer,
|
|
45
|
+
thread,
|
|
46
|
+
lastAdvanceUtcMs: Date.now(),
|
|
47
|
+
warnedHeld: false
|
|
48
|
+
});
|
|
49
|
+
this.ensureTimer();
|
|
50
|
+
}
|
|
51
|
+
/** Consumers read on every tick, so models registered later are swept too. */
|
|
52
|
+
addSource(consumers, thread) {
|
|
53
|
+
this.sources.push({
|
|
54
|
+
consumers,
|
|
55
|
+
thread
|
|
56
|
+
});
|
|
57
|
+
this.ensureTimer();
|
|
58
|
+
}
|
|
59
|
+
/** Consumers another thread sweeps, reported in status(). */
|
|
60
|
+
addStatusSource(source) {
|
|
61
|
+
this.statusSources.push(source);
|
|
62
|
+
}
|
|
63
|
+
start() {
|
|
64
|
+
this.started = true;
|
|
65
|
+
this.ensureTimer();
|
|
66
|
+
}
|
|
67
|
+
async stop() {
|
|
68
|
+
this.stopped = true;
|
|
69
|
+
if (this.timer !== void 0) {
|
|
70
|
+
clearInterval(this.timer);
|
|
71
|
+
this.timer = void 0;
|
|
72
|
+
}
|
|
73
|
+
if (this.running !== void 0) await this.running.catch(() => []);
|
|
74
|
+
}
|
|
75
|
+
async sweepNow() {
|
|
76
|
+
if (this.running !== void 0) await this.running.catch(() => []);
|
|
77
|
+
return this.run();
|
|
78
|
+
}
|
|
79
|
+
status() {
|
|
80
|
+
return {
|
|
81
|
+
watermark: this.watermark.status(),
|
|
82
|
+
consumers: this.entries().map((entry) => ({
|
|
83
|
+
consumerId: entry.consumer.consumerId,
|
|
84
|
+
thread: entry.thread,
|
|
85
|
+
appliedThrough: entry.consumer.appliedThrough,
|
|
86
|
+
trackedAbove: entry.consumer.trackedAbove,
|
|
87
|
+
...entry.blockedAt !== void 0 ? { blockedAt: entry.blockedAt } : {},
|
|
88
|
+
lastAdvanceUtcMs: entry.lastAdvanceUtcMs
|
|
89
|
+
})).concat(this.statusSources.flatMap((source) => source()))
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
ensureTimer() {
|
|
93
|
+
if (!this.started || this.stopped || this.timer !== void 0) return;
|
|
94
|
+
if (this.fixed.length === 0 && this.sources.length === 0) return;
|
|
95
|
+
const timer = setInterval(() => {
|
|
96
|
+
if (this.running !== void 0) return;
|
|
97
|
+
this.run().catch((error) => {
|
|
98
|
+
this.logger.error("catch-up tick failed: @error", error);
|
|
99
|
+
});
|
|
100
|
+
}, this.config.intervalMs);
|
|
101
|
+
if (typeof timer === "object" && "unref" in timer) timer.unref();
|
|
102
|
+
this.timer = timer;
|
|
103
|
+
}
|
|
104
|
+
run() {
|
|
105
|
+
const run = this.tick().finally(() => {
|
|
106
|
+
if (this.running === run) this.running = void 0;
|
|
107
|
+
});
|
|
108
|
+
this.running = run;
|
|
109
|
+
return run;
|
|
110
|
+
}
|
|
111
|
+
async tick() {
|
|
112
|
+
const settled = await this.watermark.refresh();
|
|
113
|
+
await this.warnIfWatermarkHeld();
|
|
114
|
+
const entries = this.entries();
|
|
115
|
+
if (entries.length === 0) return [];
|
|
116
|
+
let lowest = Number.POSITIVE_INFINITY;
|
|
117
|
+
for (const entry of entries) lowest = Math.min(lowest, entry.consumer.appliedThrough);
|
|
118
|
+
const shared = await this.readPage(lowest, settled);
|
|
119
|
+
const results = [];
|
|
120
|
+
for (const entry of entries) {
|
|
121
|
+
if (this.stopped) break;
|
|
122
|
+
const result = await this.sweepOne(entry, shared, settled);
|
|
123
|
+
if (result !== void 0) results.push(result);
|
|
124
|
+
}
|
|
125
|
+
return results;
|
|
126
|
+
}
|
|
127
|
+
entries() {
|
|
128
|
+
const entries = [...this.fixed];
|
|
129
|
+
const seen = new Set(entries.map((entry) => entry.consumer));
|
|
130
|
+
for (const source of this.sources) for (const consumer of source.consumers()) {
|
|
131
|
+
if (seen.has(consumer)) continue;
|
|
132
|
+
seen.add(consumer);
|
|
133
|
+
let entry = this.sourced.get(consumer);
|
|
134
|
+
if (entry === void 0) {
|
|
135
|
+
entry = {
|
|
136
|
+
consumer,
|
|
137
|
+
thread: source.thread,
|
|
138
|
+
lastAdvanceUtcMs: Date.now(),
|
|
139
|
+
warnedHeld: false
|
|
140
|
+
};
|
|
141
|
+
this.sourced.set(consumer, entry);
|
|
142
|
+
}
|
|
143
|
+
entries.push(entry);
|
|
144
|
+
}
|
|
145
|
+
return entries;
|
|
146
|
+
}
|
|
147
|
+
async sweepOne(entry, shared, settled) {
|
|
148
|
+
const applied = entry.consumer.appliedThrough;
|
|
149
|
+
let page = shared;
|
|
150
|
+
if (applied >= shared.bound && shared.bound < settled) try {
|
|
151
|
+
page = await this.readPage(applied, settled);
|
|
152
|
+
} catch (error) {
|
|
153
|
+
this.logger.error("@consumer catch-up could not read present ordinals: @error", entry.consumer.consumerId, error);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
let result;
|
|
157
|
+
try {
|
|
158
|
+
result = await entry.consumer.sweep(page.bound, page.present);
|
|
159
|
+
} catch (error) {
|
|
160
|
+
this.logger.error("@consumer catch-up sweep failed: @error", entry.consumer.consumerId, error);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
if (result.to > result.from) {
|
|
164
|
+
entry.lastAdvanceUtcMs = Date.now();
|
|
165
|
+
entry.warnedHeld = false;
|
|
166
|
+
}
|
|
167
|
+
entry.blockedAt = result.blockedAt;
|
|
168
|
+
this.warnIfConsumerHeld(entry, settled);
|
|
169
|
+
if (result.to > result.from || result.replayed > 0 || result.blockedAt !== void 0) this.hooks.onSwept(result, entry.thread);
|
|
170
|
+
return result;
|
|
171
|
+
}
|
|
172
|
+
async warnIfWatermarkHeld() {
|
|
173
|
+
const status = this.watermark.status();
|
|
174
|
+
const since = status.stalledSinceUtcMs;
|
|
175
|
+
if (since === void 0) {
|
|
176
|
+
this.warnedStallSince = void 0;
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
const heldMs = Date.now() - since;
|
|
180
|
+
if (heldMs < this.config.stuckWarnMs || this.warnedStallSince === since) return;
|
|
181
|
+
this.warnedStallSince = since;
|
|
182
|
+
const sessions = await this.hooks.describeSessions(status.waitingOn).catch(() => []);
|
|
183
|
+
this.logger.warn("settled watermark held at @settled (head @head) for @ms ms, waiting on xid @xids: @sessions", status.settledThrough, status.head, heldMs, status.waitingOn.join(", "), sessions.map((session) => `pid ${session.pid} ${session.applicationName} ${session.state} since ${session.xactStart ?? "?"}`).join("; "));
|
|
184
|
+
}
|
|
185
|
+
warnIfConsumerHeld(entry, settled) {
|
|
186
|
+
const applied = entry.consumer.appliedThrough;
|
|
187
|
+
if (applied >= settled || entry.warnedHeld) return;
|
|
188
|
+
const heldMs = Date.now() - entry.lastAdvanceUtcMs;
|
|
189
|
+
if (heldMs < this.config.stuckWarnMs) return;
|
|
190
|
+
entry.warnedHeld = true;
|
|
191
|
+
this.logger.warn("@consumer cursor held at @applied for @ms ms (settled @settled)", entry.consumer.consumerId, applied, heldMs, settled);
|
|
192
|
+
}
|
|
193
|
+
async readPage(after, settled) {
|
|
194
|
+
const limit = this.config.sweepPageSize;
|
|
195
|
+
const present = await this.operationIndex.getOrdinalsInRange(after, settled, limit);
|
|
196
|
+
return {
|
|
197
|
+
bound: present.length >= limit ? present[present.length - 1] : settled,
|
|
198
|
+
present
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
//#endregion
|
|
203
|
+
//#region src/catch-up/types.ts
|
|
204
|
+
const defaultCatchUpConfig = {
|
|
205
|
+
intervalMs: 2e3,
|
|
206
|
+
stuckWarnMs: 6e4,
|
|
207
|
+
maxTrackedAboveCursor: 1e5,
|
|
208
|
+
sweepPageSize: 1e4
|
|
209
|
+
};
|
|
210
|
+
//#endregion
|
|
211
|
+
//#region src/catch-up/contiguous-cursor.ts
|
|
212
|
+
/** In memory, per consumer; tracks ordinals above appliedThrough only. */
|
|
213
|
+
var ContiguousCursor = class {
|
|
214
|
+
through;
|
|
215
|
+
limit;
|
|
216
|
+
claimed = /* @__PURE__ */ new Set();
|
|
217
|
+
applied = /* @__PURE__ */ new Set();
|
|
218
|
+
constructor(through, limit) {
|
|
219
|
+
this.through = through;
|
|
220
|
+
this.limit = limit;
|
|
221
|
+
}
|
|
222
|
+
setLimit(limit) {
|
|
223
|
+
this.limit = limit;
|
|
224
|
+
}
|
|
225
|
+
/** Every present ordinal at or below this has been applied. */
|
|
226
|
+
get appliedThrough() {
|
|
227
|
+
return this.through;
|
|
228
|
+
}
|
|
229
|
+
get trackedAbove() {
|
|
230
|
+
return this.claimed.size + this.applied.size;
|
|
231
|
+
}
|
|
232
|
+
/** Ordinals the caller must apply; the rest are already covered. */
|
|
233
|
+
claim(ordinals) {
|
|
234
|
+
const mine = /* @__PURE__ */ new Set();
|
|
235
|
+
for (const ordinal of ordinals) {
|
|
236
|
+
if (ordinal <= this.through) continue;
|
|
237
|
+
if (this.claimed.has(ordinal) || this.applied.has(ordinal)) continue;
|
|
238
|
+
this.claimed.add(ordinal);
|
|
239
|
+
mine.add(ordinal);
|
|
240
|
+
}
|
|
241
|
+
return mine;
|
|
242
|
+
}
|
|
243
|
+
/** Releases claims; committed ones join the applied set. */
|
|
244
|
+
settle(ordinals, committed) {
|
|
245
|
+
for (const ordinal of ordinals) {
|
|
246
|
+
if (!this.claimed.delete(ordinal)) continue;
|
|
247
|
+
if (committed && ordinal > this.through) this.applied.add(ordinal);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
/** Past the limit, clears the applied set; the next sweep re-applies it. */
|
|
251
|
+
enforceLimit() {
|
|
252
|
+
if (this.claimed.size + this.applied.size > this.limit) this.applied.clear();
|
|
253
|
+
}
|
|
254
|
+
/** Present ordinals above the cursor that no path has claimed or applied. */
|
|
255
|
+
missing(present) {
|
|
256
|
+
return present.filter((ordinal) => ordinal > this.through && !this.claimed.has(ordinal) && !this.applied.has(ordinal));
|
|
257
|
+
}
|
|
258
|
+
/** `settled`, held below the lowest claimed or unapplied ordinal. */
|
|
259
|
+
target(settled, present) {
|
|
260
|
+
let to = settled;
|
|
261
|
+
for (const ordinal of this.claimed) if (ordinal <= to) to = ordinal - 1;
|
|
262
|
+
for (const ordinal of present) {
|
|
263
|
+
if (ordinal <= this.through || ordinal > to) continue;
|
|
264
|
+
if (!this.applied.has(ordinal)) to = ordinal - 1;
|
|
265
|
+
}
|
|
266
|
+
return Math.max(to, this.through);
|
|
267
|
+
}
|
|
268
|
+
advance(to) {
|
|
269
|
+
if (to <= this.through) return;
|
|
270
|
+
this.through = to;
|
|
271
|
+
for (const ordinal of this.applied) if (ordinal <= to) this.applied.delete(ordinal);
|
|
272
|
+
for (const ordinal of this.claimed) if (ordinal <= to) this.claimed.delete(ordinal);
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
//#endregion
|
|
5
276
|
//#region src/read-models/base-read-model.ts
|
|
6
277
|
/** Small enough that a chunk's transaction rarely outlasts the yield deadline. */
|
|
7
278
|
const DEFAULT_COMMIT_CHUNK_SIZE = 50;
|
|
@@ -30,26 +301,63 @@ function normalizeIndexingConfig(config) {
|
|
|
30
301
|
commitChunkSize: Math.max(1, Math.min(Math.floor(config.commitChunkSize), Number.MAX_SAFE_INTEGER))
|
|
31
302
|
};
|
|
32
303
|
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
304
|
+
function ordinalOf(item) {
|
|
305
|
+
return item.context.ordinal;
|
|
306
|
+
}
|
|
307
|
+
function isTracked(ordinal) {
|
|
308
|
+
return Number.isFinite(ordinal) && ordinal > 0;
|
|
309
|
+
}
|
|
310
|
+
function streamKeyOf(stream) {
|
|
311
|
+
return `${stream.documentId}\u0000${stream.scope}\u0000${stream.branch}`;
|
|
312
|
+
}
|
|
313
|
+
function groupByStream(items) {
|
|
314
|
+
const groups = /* @__PURE__ */ new Map();
|
|
315
|
+
for (const item of items) {
|
|
316
|
+
const { documentId, scope, branch } = item.context;
|
|
317
|
+
const key = streamKeyOf(item.context);
|
|
318
|
+
let group = groups.get(key);
|
|
319
|
+
if (group === void 0) {
|
|
320
|
+
group = {
|
|
321
|
+
documentId,
|
|
322
|
+
scope,
|
|
323
|
+
branch,
|
|
324
|
+
lowest: ordinalOf(item),
|
|
325
|
+
late: []
|
|
326
|
+
};
|
|
327
|
+
groups.set(key, group);
|
|
328
|
+
}
|
|
329
|
+
group.lowest = Math.min(group.lowest, ordinalOf(item));
|
|
330
|
+
group.late.push(item);
|
|
331
|
+
}
|
|
332
|
+
return [...groups.values()].sort((a, b) => a.lowest - b.lowest);
|
|
333
|
+
}
|
|
334
|
+
function mergeByOrdinal(...lists) {
|
|
335
|
+
const byOrdinal = /* @__PURE__ */ new Map();
|
|
336
|
+
for (const list of lists) for (const item of list) byOrdinal.set(ordinalOf(item), item);
|
|
337
|
+
return [...byOrdinal.values()].sort((a, b) => ordinalOf(a) - ordinalOf(b));
|
|
338
|
+
}
|
|
339
|
+
function errorMessage(error) {
|
|
340
|
+
return error instanceof Error ? error.message : String(error);
|
|
341
|
+
}
|
|
342
|
+
/** A read model whose cursor only sweeps and boot replay advance. */
|
|
38
343
|
var BaseReadModel = class {
|
|
39
344
|
db;
|
|
40
345
|
operationIndex;
|
|
41
346
|
writeCache;
|
|
42
347
|
consistencyTracker;
|
|
43
348
|
config;
|
|
44
|
-
lastOrdinal = 0;
|
|
45
349
|
name;
|
|
46
350
|
indexing;
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
351
|
+
catchUpLogger;
|
|
352
|
+
cursor;
|
|
353
|
+
persisted = 0;
|
|
354
|
+
watermark;
|
|
355
|
+
maxTrackedAboveCursor = defaultCatchUpConfig.maxTrackedAboveCursor;
|
|
356
|
+
loggedFailure;
|
|
357
|
+
failedItem;
|
|
358
|
+
initialized = false;
|
|
359
|
+
liveInFlight = /* @__PURE__ */ new Map();
|
|
360
|
+
sweptListeners = /* @__PURE__ */ new Set();
|
|
53
361
|
constructor(db, operationIndex, writeCache, consistencyTracker, config) {
|
|
54
362
|
this.db = db;
|
|
55
363
|
this.operationIndex = operationIndex;
|
|
@@ -57,52 +365,131 @@ var BaseReadModel = class {
|
|
|
57
365
|
this.consistencyTracker = consistencyTracker;
|
|
58
366
|
this.config = config;
|
|
59
367
|
this.name = config.readModelId;
|
|
368
|
+
this.catchUpLogger = childLogger([
|
|
369
|
+
"reactor",
|
|
370
|
+
"read-model",
|
|
371
|
+
config.readModelId
|
|
372
|
+
]);
|
|
60
373
|
this.indexing = normalizeIndexingConfig(config.indexing ?? defaultReadModelIndexingConfig);
|
|
374
|
+
this.cursor = new ContiguousCursor(0, this.maxTrackedAboveCursor);
|
|
61
375
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
376
|
+
get consumerId() {
|
|
377
|
+
return this.config.readModelId;
|
|
378
|
+
}
|
|
379
|
+
get appliedThrough() {
|
|
380
|
+
return this.cursor.appliedThrough;
|
|
381
|
+
}
|
|
382
|
+
get trackedAbove() {
|
|
383
|
+
return this.cursor.trackedAbove;
|
|
384
|
+
}
|
|
385
|
+
/** Shares a thread's watermark; otherwise boot probes through this db. */
|
|
386
|
+
attachCatchUp(watermark, maxTrackedAboveCursor) {
|
|
387
|
+
this.watermark = watermark;
|
|
388
|
+
this.maxTrackedAboveCursor = maxTrackedAboveCursor;
|
|
389
|
+
this.cursor.setLimit(maxTrackedAboveCursor);
|
|
390
|
+
}
|
|
391
|
+
/** Notified with the coordinates each sweep applied. */
|
|
392
|
+
onSwept(listener) {
|
|
393
|
+
this.sweptListeners.add(listener);
|
|
394
|
+
return () => {
|
|
395
|
+
this.sweptListeners.delete(listener);
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
/** A chunk that throws ends the replay; sweeps continue from below it. */
|
|
65
399
|
async init() {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
400
|
+
if (this.initialized) {
|
|
401
|
+
await this.replayFromCursor();
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
let stored = await this.loadState();
|
|
405
|
+
if (stored === void 0) {
|
|
406
|
+
if (this.config.startFrom === "head") {
|
|
407
|
+
const watermark = this.settledWatermark();
|
|
408
|
+
const settled = await watermark.refresh();
|
|
409
|
+
const head = Math.max(settled, watermark.status().head);
|
|
410
|
+
await this.initializeState(head);
|
|
411
|
+
this.resetCursor(head);
|
|
412
|
+
this.initialized = true;
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
await this.initializeState(0);
|
|
416
|
+
stored = 0;
|
|
75
417
|
}
|
|
418
|
+
this.resetCursor(stored);
|
|
419
|
+
this.initialized = true;
|
|
420
|
+
await this.replayFromCursor();
|
|
76
421
|
}
|
|
77
|
-
/**
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
*/
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
let
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
422
|
+
/** The live path: never moves the cursor. */
|
|
423
|
+
indexOperations(items) {
|
|
424
|
+
if (items.length === 0) return Promise.resolve();
|
|
425
|
+
return this.reserveOperations(items).apply();
|
|
426
|
+
}
|
|
427
|
+
/** Holds a queued batch as live, so a sweep neither takes nor passes it. */
|
|
428
|
+
reserveOperations(items) {
|
|
429
|
+
const cursor = this.cursor;
|
|
430
|
+
const owned = this.claimLive(items);
|
|
431
|
+
const unmark = this.markLive(owned);
|
|
432
|
+
let done = false;
|
|
433
|
+
return {
|
|
434
|
+
apply: () => {
|
|
435
|
+
if (done) return Promise.resolve();
|
|
436
|
+
done = true;
|
|
437
|
+
if (cursor !== this.cursor) {
|
|
438
|
+
unmark();
|
|
439
|
+
return this.indexOperations(items);
|
|
440
|
+
}
|
|
441
|
+
if (owned.length === 0) {
|
|
442
|
+
unmark();
|
|
443
|
+
return Promise.resolve();
|
|
444
|
+
}
|
|
445
|
+
return this.applyChunked(owned, unmark);
|
|
446
|
+
},
|
|
447
|
+
release: () => {
|
|
448
|
+
if (done) return;
|
|
449
|
+
done = true;
|
|
450
|
+
cursor.settle(owned.map(ordinalOf), false);
|
|
451
|
+
unmark();
|
|
92
452
|
}
|
|
93
|
-
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
async sweep(settledThrough, present, signal) {
|
|
456
|
+
const startedAt = performance.now();
|
|
457
|
+
const from = this.cursor.appliedThrough;
|
|
458
|
+
const range = present.filter((ordinal) => ordinal > from && ordinal <= settledThrough);
|
|
459
|
+
const mine = this.cursor.claim(this.cursor.missing(range));
|
|
460
|
+
let replayed = 0;
|
|
461
|
+
let reapplied = 0;
|
|
462
|
+
let blockedAt;
|
|
463
|
+
if (mine.size > 0) {
|
|
464
|
+
let late;
|
|
94
465
|
try {
|
|
95
|
-
await this.
|
|
466
|
+
late = await this.operationIndex.getByOrdinals([...mine], signal);
|
|
96
467
|
} catch (error) {
|
|
97
|
-
this.
|
|
98
|
-
await this.recordCommittedPrefix(items.slice(0, committed));
|
|
468
|
+
this.cursor.settle(mine, false);
|
|
99
469
|
throw error;
|
|
100
470
|
}
|
|
101
|
-
|
|
471
|
+
const found = new Set(late.map(ordinalOf));
|
|
472
|
+
this.cursor.settle([...mine].filter((ordinal) => !found.has(ordinal)), true);
|
|
473
|
+
for (const group of groupByStream(late)) {
|
|
474
|
+
const outcome = await this.sweepStream(group, signal);
|
|
475
|
+
replayed += outcome.replayed;
|
|
476
|
+
reapplied += outcome.reapplied;
|
|
477
|
+
blockedAt ??= outcome.blockedAt;
|
|
478
|
+
}
|
|
479
|
+
if (replayed > 0) this.catchUpLogger.warn("@consumer applied @n operations its live path never received: ordinals @first..@last", this.consumerId, replayed, Math.min(...found), Math.max(...found));
|
|
102
480
|
}
|
|
103
|
-
this.
|
|
104
|
-
await this.
|
|
105
|
-
this.
|
|
481
|
+
const to = this.cursor.target(settledThrough, range);
|
|
482
|
+
if (to > from) await this.moveCursor(to);
|
|
483
|
+
this.cursor.enforceLimit();
|
|
484
|
+
return {
|
|
485
|
+
consumerId: this.consumerId,
|
|
486
|
+
from,
|
|
487
|
+
to: this.cursor.appliedThrough,
|
|
488
|
+
durationMs: performance.now() - startedAt,
|
|
489
|
+
replayed,
|
|
490
|
+
reapplied,
|
|
491
|
+
...blockedAt !== void 0 ? { blockedAt } : {}
|
|
492
|
+
};
|
|
106
493
|
}
|
|
107
494
|
/**
|
|
108
495
|
* Waits for the read model to reach the specified consistency level.
|
|
@@ -111,10 +498,14 @@ var BaseReadModel = class {
|
|
|
111
498
|
if (token.coordinates.length === 0) return;
|
|
112
499
|
await this.consistencyTracker.waitFor(token.coordinates, timeoutMs, signal);
|
|
113
500
|
}
|
|
501
|
+
/** The cursor, for subclasses: every present ordinal at or below it is applied. */
|
|
502
|
+
get lastOrdinal() {
|
|
503
|
+
return this.cursor.appliedThrough;
|
|
504
|
+
}
|
|
505
|
+
/** Called after the cursor advances. */
|
|
506
|
+
onCursorAdvanced(appliedThrough) {}
|
|
114
507
|
async commitOperations(items) {}
|
|
115
|
-
/**
|
|
116
|
-
* Rebuilds document state for each operation using the write cache.
|
|
117
|
-
*/
|
|
508
|
+
/** Rebuilds resultingState as the executor writes it: scopes plus header. */
|
|
118
509
|
async rebuildStateForOperations(operations) {
|
|
119
510
|
const result = [];
|
|
120
511
|
for (const op of operations) {
|
|
@@ -125,7 +516,10 @@ var BaseReadModel = class {
|
|
|
125
516
|
operation: op.operation,
|
|
126
517
|
context: {
|
|
127
518
|
...op.context,
|
|
128
|
-
resultingState: JSON.stringify(
|
|
519
|
+
resultingState: JSON.stringify({
|
|
520
|
+
...document.state,
|
|
521
|
+
header: document.header
|
|
522
|
+
})
|
|
129
523
|
}
|
|
130
524
|
});
|
|
131
525
|
}
|
|
@@ -141,29 +535,19 @@ var BaseReadModel = class {
|
|
|
141
535
|
/**
|
|
142
536
|
* Initializes the ViewState row for this read model.
|
|
143
537
|
*/
|
|
144
|
-
async initializeState() {
|
|
538
|
+
async initializeState(lastOrdinal = 0) {
|
|
145
539
|
await this.db.insertInto("ViewState").values({
|
|
146
540
|
readModelId: this.config.readModelId,
|
|
147
|
-
lastOrdinal
|
|
148
|
-
}).execute();
|
|
149
|
-
}
|
|
150
|
-
/**
|
|
151
|
-
* Saves the last processed ordinal to the ViewState table.
|
|
152
|
-
*/
|
|
153
|
-
async saveState(trx, items) {
|
|
154
|
-
let maxOrdinal = 0;
|
|
155
|
-
for (const item of items) maxOrdinal = Math.max(maxOrdinal, item.context.ordinal);
|
|
156
|
-
const next = Math.max(this.lastOrdinal, maxOrdinal);
|
|
157
|
-
this.lastOrdinal = next;
|
|
158
|
-
await trx.updateTable("ViewState").set({
|
|
159
|
-
lastOrdinal: next,
|
|
160
|
-
lastOperationTimestamp: /* @__PURE__ */ new Date()
|
|
161
|
-
}).where("readModelId", "=", this.config.readModelId).execute();
|
|
541
|
+
lastOrdinal
|
|
542
|
+
}).onConflict((oc) => oc.column("readModelId").doNothing()).execute();
|
|
162
543
|
}
|
|
163
544
|
/**
|
|
164
545
|
* Updates the consistency tracker with the processed operations.
|
|
165
546
|
*/
|
|
166
547
|
updateConsistencyTracker(items) {
|
|
548
|
+
this.consistencyTracker.update(this.coordinatesOf(items));
|
|
549
|
+
}
|
|
550
|
+
coordinatesOf(items) {
|
|
167
551
|
const coordinates = [];
|
|
168
552
|
for (let i = 0; i < items.length; i++) {
|
|
169
553
|
const item = items[i];
|
|
@@ -174,64 +558,297 @@ var BaseReadModel = class {
|
|
|
174
558
|
operationIndex: item.operation.index
|
|
175
559
|
});
|
|
176
560
|
}
|
|
177
|
-
|
|
561
|
+
return coordinates;
|
|
178
562
|
}
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
563
|
+
settledWatermark() {
|
|
564
|
+
this.watermark ??= new SettledWatermark(createKyselyWatermarkProbe(this.db), this.catchUpLogger);
|
|
565
|
+
return this.watermark;
|
|
566
|
+
}
|
|
567
|
+
resetCursor(appliedThrough) {
|
|
568
|
+
this.cursor = new ContiguousCursor(appliedThrough, this.maxTrackedAboveCursor);
|
|
569
|
+
this.persisted = appliedThrough;
|
|
570
|
+
}
|
|
571
|
+
/** Items this pass must apply; untracked ordinals always apply. */
|
|
572
|
+
claimLive(items) {
|
|
573
|
+
const mine = this.cursor.claim(items.map(ordinalOf));
|
|
574
|
+
return items.filter((item) => {
|
|
575
|
+
const ordinal = ordinalOf(item);
|
|
576
|
+
return !isTracked(ordinal) || mine.has(ordinal);
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
/** Marks items in flight on their streams; returns the release. */
|
|
580
|
+
markLive(items) {
|
|
581
|
+
const keys = /* @__PURE__ */ new Set();
|
|
582
|
+
for (const item of items) {
|
|
583
|
+
if (!isTracked(ordinalOf(item))) continue;
|
|
584
|
+
const key = streamKeyOf(item.context);
|
|
585
|
+
let ordinals = this.liveInFlight.get(key);
|
|
586
|
+
if (ordinals === void 0) {
|
|
587
|
+
ordinals = /* @__PURE__ */ new Set();
|
|
588
|
+
this.liveInFlight.set(key, ordinals);
|
|
589
|
+
}
|
|
590
|
+
ordinals.add(ordinalOf(item));
|
|
591
|
+
keys.add(key);
|
|
592
|
+
}
|
|
593
|
+
return () => {
|
|
594
|
+
for (const item of items) this.liveInFlight.get(streamKeyOf(item.context))?.delete(ordinalOf(item));
|
|
595
|
+
for (const key of keys) if (this.liveInFlight.get(key)?.size === 0) this.liveInFlight.delete(key);
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
/** True while a live pass applies an earlier operation of the stream. */
|
|
599
|
+
liveBelow(group) {
|
|
600
|
+
const ordinals = this.liveInFlight.get(streamKeyOf(group));
|
|
601
|
+
if (ordinals === void 0) return false;
|
|
602
|
+
for (const ordinal of ordinals) if (ordinal < group.lowest) return true;
|
|
603
|
+
return false;
|
|
604
|
+
}
|
|
605
|
+
/** Commits in chunks, yielding with no transaction open. */
|
|
606
|
+
async applyChunked(items, release = this.markLive(items)) {
|
|
186
607
|
try {
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
608
|
+
const { commitChunkSize, yieldDeadlineMs } = this.indexing;
|
|
609
|
+
let lastYield = performance.now();
|
|
610
|
+
let committed = 0;
|
|
611
|
+
for (let start = 0; start < items.length; start += commitChunkSize) {
|
|
612
|
+
if (start > 0 && performance.now() - lastYield > yieldDeadlineMs) {
|
|
613
|
+
await yieldToMain();
|
|
614
|
+
lastYield = performance.now();
|
|
615
|
+
}
|
|
616
|
+
const chunk = items.slice(start, start + commitChunkSize);
|
|
617
|
+
try {
|
|
618
|
+
await this.commitOperations(chunk);
|
|
619
|
+
} catch (error) {
|
|
620
|
+
this.failedItem = chunk[0];
|
|
621
|
+
const prefix = items.slice(0, committed);
|
|
622
|
+
this.cursor.settle(prefix.map(ordinalOf), true);
|
|
623
|
+
this.cursor.settle(items.slice(committed).map(ordinalOf), false);
|
|
624
|
+
if (prefix.length > 0) this.updateConsistencyTracker(prefix);
|
|
625
|
+
throw error;
|
|
626
|
+
}
|
|
627
|
+
committed += chunk.length;
|
|
628
|
+
}
|
|
629
|
+
this.cursor.settle(items.map(ordinalOf), true);
|
|
630
|
+
this.cursor.enforceLimit();
|
|
631
|
+
this.updateConsistencyTracker(items);
|
|
632
|
+
} finally {
|
|
633
|
+
release();
|
|
190
634
|
}
|
|
191
|
-
this.updateConsistencyTracker(prefix);
|
|
192
635
|
}
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
await this.
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
636
|
+
async replayFromCursor() {
|
|
637
|
+
const settledAtBoot = await this.settledAtBoot();
|
|
638
|
+
let page = await this.operationIndex.getSinceOrdinal(this.cursor.appliedThrough);
|
|
639
|
+
while (page.results.length > 0) {
|
|
640
|
+
const ordinals = page.results.map(ordinalOf);
|
|
641
|
+
const failed = await this.replayPage(page.results);
|
|
642
|
+
const pageMax = Math.max(...ordinals);
|
|
643
|
+
const to = this.cursor.target(Math.min(settledAtBoot, pageMax), ordinals);
|
|
644
|
+
if (to > this.cursor.appliedThrough) await this.moveCursor(to);
|
|
645
|
+
if (failed || !page.next) break;
|
|
646
|
+
page = await page.next();
|
|
647
|
+
}
|
|
199
648
|
}
|
|
200
|
-
/**
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
649
|
+
/** Without a probe, boot replay applies but leaves the cursor to sweeps. */
|
|
650
|
+
async settledAtBoot() {
|
|
651
|
+
try {
|
|
652
|
+
return await this.settledWatermark().refresh();
|
|
653
|
+
} catch (error) {
|
|
654
|
+
this.catchUpLogger.warn("@consumer could not probe the settled watermark at boot; the cursor waits for a sweep: @error", this.consumerId, error);
|
|
655
|
+
return this.cursor.appliedThrough;
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
/** Returns true when a chunk failed, which ends the replay. */
|
|
659
|
+
async replayPage(results) {
|
|
660
|
+
const owned = this.claimLive(results);
|
|
661
|
+
if (owned.length === 0) return false;
|
|
662
|
+
const release = this.markLive(owned);
|
|
663
|
+
try {
|
|
664
|
+
return await this.replayOwned(owned);
|
|
665
|
+
} finally {
|
|
666
|
+
release();
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
async replayOwned(owned) {
|
|
670
|
+
let rebuilt;
|
|
671
|
+
try {
|
|
672
|
+
rebuilt = await this.rebuildIfConfigured(owned);
|
|
673
|
+
} catch (error) {
|
|
674
|
+
this.cursor.settle(owned.map(ordinalOf), false);
|
|
675
|
+
this.block(owned[0], error);
|
|
676
|
+
return true;
|
|
677
|
+
}
|
|
678
|
+
this.cursor.settle(rebuilt.absent, true);
|
|
679
|
+
if (rebuilt.items.length === 0) return false;
|
|
680
|
+
try {
|
|
681
|
+
await this.applyChunked(rebuilt.items);
|
|
682
|
+
} catch (error) {
|
|
683
|
+
this.block(this.failedItem ?? rebuilt.items[0], error);
|
|
684
|
+
return true;
|
|
685
|
+
}
|
|
686
|
+
return false;
|
|
687
|
+
}
|
|
688
|
+
async sweepStream(group, signal) {
|
|
689
|
+
const owned = group.late.map(ordinalOf);
|
|
690
|
+
if (this.liveBelow(group)) {
|
|
691
|
+
this.cursor.settle(owned, false);
|
|
692
|
+
return {
|
|
693
|
+
replayed: 0,
|
|
694
|
+
reapplied: 0
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
const ownedSet = new Set(owned);
|
|
698
|
+
let items = group.late;
|
|
699
|
+
if (this.config.replayStreamSuffix ?? true) {
|
|
700
|
+
let suffix;
|
|
701
|
+
try {
|
|
702
|
+
suffix = await this.operationIndex.getStreamAfter(group, group.lowest, signal);
|
|
703
|
+
} catch (error) {
|
|
704
|
+
this.cursor.settle(owned, false);
|
|
705
|
+
return {
|
|
706
|
+
replayed: 0,
|
|
707
|
+
reapplied: 0,
|
|
708
|
+
blockedAt: this.block(group.late[0], error)
|
|
709
|
+
};
|
|
710
|
+
}
|
|
711
|
+
items = mergeByOrdinal(group.late, suffix);
|
|
712
|
+
}
|
|
713
|
+
let rebuilt;
|
|
714
|
+
try {
|
|
715
|
+
rebuilt = await this.rebuildIfConfigured(items);
|
|
716
|
+
} catch (error) {
|
|
717
|
+
this.cursor.settle(owned, false);
|
|
718
|
+
return {
|
|
719
|
+
replayed: 0,
|
|
720
|
+
reapplied: 0,
|
|
721
|
+
blockedAt: this.block(items[0], error)
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
try {
|
|
725
|
+
await this.commitOperations(rebuilt.items);
|
|
726
|
+
} catch (error) {
|
|
727
|
+
this.cursor.settle(owned, false);
|
|
728
|
+
return {
|
|
729
|
+
replayed: 0,
|
|
730
|
+
reapplied: 0,
|
|
731
|
+
blockedAt: this.block(rebuilt.items[0] ?? items[0], error)
|
|
732
|
+
};
|
|
733
|
+
}
|
|
734
|
+
this.cursor.settle(owned, true);
|
|
735
|
+
this.updateConsistencyTracker(rebuilt.items);
|
|
736
|
+
this.notifySwept(rebuilt.items);
|
|
737
|
+
let replayed = 0;
|
|
738
|
+
for (const item of rebuilt.items) if (ownedSet.has(ordinalOf(item))) replayed++;
|
|
739
|
+
return {
|
|
740
|
+
replayed,
|
|
741
|
+
reapplied: rebuilt.items.length - replayed
|
|
742
|
+
};
|
|
743
|
+
}
|
|
744
|
+
/** A document gone from the write cache is absent, not a failure. */
|
|
745
|
+
async rebuildIfConfigured(items) {
|
|
746
|
+
if (!this.config.rebuildStateOnInit) return {
|
|
747
|
+
items,
|
|
748
|
+
absent: []
|
|
749
|
+
};
|
|
750
|
+
const rebuilt = [];
|
|
751
|
+
const absent = [];
|
|
752
|
+
for (const item of items) {
|
|
753
|
+
let result;
|
|
754
|
+
try {
|
|
755
|
+
result = await this.rebuildStateForOperations([item]);
|
|
756
|
+
} catch (error) {
|
|
757
|
+
if (!(error instanceof DocumentNotFoundError)) throw error;
|
|
758
|
+
this.catchUpLogger.warn("@consumer dropped ordinal @ordinal: document @documentId is gone", this.consumerId, ordinalOf(item), item.context.documentId);
|
|
759
|
+
absent.push(ordinalOf(item));
|
|
760
|
+
continue;
|
|
761
|
+
}
|
|
762
|
+
rebuilt.push(...result);
|
|
763
|
+
}
|
|
764
|
+
return {
|
|
765
|
+
items: rebuilt,
|
|
766
|
+
absent
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
block(item, error) {
|
|
770
|
+
const blockedAt = {
|
|
771
|
+
ordinal: ordinalOf(item),
|
|
772
|
+
documentId: item.context.documentId,
|
|
773
|
+
scope: item.context.scope,
|
|
774
|
+
branch: item.context.branch,
|
|
775
|
+
type: item.operation.action.type,
|
|
776
|
+
error: errorMessage(error)
|
|
777
|
+
};
|
|
778
|
+
if (this.loggedFailure !== blockedAt.ordinal) {
|
|
779
|
+
this.loggedFailure = blockedAt.ordinal;
|
|
780
|
+
this.catchUpLogger.error("@consumer cursor held at @applied: ordinal @ordinal (@documentId/@scope/@branch, @type) failed: @error", this.consumerId, this.cursor.appliedThrough, blockedAt.ordinal, blockedAt.documentId, blockedAt.scope, blockedAt.branch, blockedAt.type, blockedAt.error);
|
|
781
|
+
}
|
|
782
|
+
return blockedAt;
|
|
783
|
+
}
|
|
784
|
+
notifySwept(items) {
|
|
785
|
+
if (this.sweptListeners.size === 0 || items.length === 0) return;
|
|
786
|
+
const coordinates = this.coordinatesOf(items);
|
|
787
|
+
for (const listener of this.sweptListeners) listener(coordinates);
|
|
788
|
+
}
|
|
789
|
+
/** Compare-and-set against the value this process last wrote. */
|
|
790
|
+
async moveCursor(to) {
|
|
791
|
+
if (to <= this.persisted) {
|
|
792
|
+
this.advanceCursor(to);
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
const expected = this.persisted;
|
|
796
|
+
const result = await this.db.updateTable("ViewState").set({
|
|
797
|
+
lastOrdinal: to,
|
|
211
798
|
lastOperationTimestamp: /* @__PURE__ */ new Date()
|
|
212
|
-
}).where("readModelId", "=", this.config.readModelId).
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
799
|
+
}).where("readModelId", "=", this.config.readModelId).where("lastOrdinal", "=", expected).executeTakeFirst();
|
|
800
|
+
if (Number(result.numUpdatedRows) > 0) {
|
|
801
|
+
this.persisted = to;
|
|
802
|
+
this.advanceCursor(to);
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
805
|
+
const stored = await this.loadState();
|
|
806
|
+
if (stored === void 0) {
|
|
807
|
+
await this.initializeState(0);
|
|
808
|
+
this.lowered(0);
|
|
809
|
+
return;
|
|
220
810
|
}
|
|
221
|
-
if (
|
|
222
|
-
|
|
223
|
-
}
|
|
224
|
-
/** The park lifts once a later pass commits the operation that failed. */
|
|
225
|
-
liftParkIfCommitted(items) {
|
|
226
|
-
if (this.uncommittedOrdinal === 0) return;
|
|
227
|
-
for (const item of items) if (item.context.ordinal === this.uncommittedOrdinal) {
|
|
228
|
-
this.uncommittedOrdinal = 0;
|
|
811
|
+
if (stored < this.cursor.appliedThrough) {
|
|
812
|
+
this.lowered(stored);
|
|
229
813
|
return;
|
|
230
814
|
}
|
|
815
|
+
this.persisted = stored;
|
|
816
|
+
this.advanceCursor(Math.min(to, stored));
|
|
817
|
+
}
|
|
818
|
+
advanceCursor(to) {
|
|
819
|
+
const before = this.cursor.appliedThrough;
|
|
820
|
+
this.cursor.advance(to);
|
|
821
|
+
if (this.cursor.appliedThrough > before) this.onCursorAdvanced(this.cursor.appliedThrough);
|
|
822
|
+
}
|
|
823
|
+
lowered(to) {
|
|
824
|
+
this.catchUpLogger.info("@consumer cursor lowered externally from @old to @new; replaying", this.consumerId, this.cursor.appliedThrough, to);
|
|
825
|
+
this.resetCursor(to);
|
|
231
826
|
}
|
|
232
827
|
};
|
|
233
828
|
//#endregion
|
|
234
829
|
//#region src/read-models/coordinator.ts
|
|
830
|
+
/** Claims a batch for every model that supports it, as the batch queues. */
|
|
831
|
+
function reserveBatch(readModels, operations, logger) {
|
|
832
|
+
const reservations = /* @__PURE__ */ new Map();
|
|
833
|
+
for (const readModel of readModels) {
|
|
834
|
+
if (readModel.reserveOperations === void 0) continue;
|
|
835
|
+
try {
|
|
836
|
+
reservations.set(readModel, readModel.reserveOperations(operations));
|
|
837
|
+
} catch (error) {
|
|
838
|
+
logger.error("Read model @name could not reserve a batch: @Error", readModel.name, error);
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
return reservations;
|
|
842
|
+
}
|
|
843
|
+
/** Applies the model's reservation, or indexes a model registered since. */
|
|
844
|
+
function indexReserved(readModel, operations, reservations) {
|
|
845
|
+
const reservation = reservations.get(readModel);
|
|
846
|
+
return reservation ? reservation.apply() : readModel.indexOperations(operations);
|
|
847
|
+
}
|
|
848
|
+
/** Frees every claim a run did not apply, so a sweep can take it. */
|
|
849
|
+
function releaseBatch(reservations) {
|
|
850
|
+
for (const reservation of reservations.values()) reservation.release();
|
|
851
|
+
}
|
|
235
852
|
/**
|
|
236
853
|
* Coordinates read model synchronization by listening to operation write events
|
|
237
854
|
* and updating all registered read models on per-`documentId:scope:branch`
|
|
@@ -284,6 +901,9 @@ var ReadModelCoordinator = class {
|
|
|
284
901
|
getChainDepth() {
|
|
285
902
|
return this.chains.size;
|
|
286
903
|
}
|
|
904
|
+
indexedReadModels() {
|
|
905
|
+
return [...this.preReady, ...this.postReady];
|
|
906
|
+
}
|
|
287
907
|
addReadModel(readModel, stage) {
|
|
288
908
|
if (this.readModels.some(({ name }) => name === readModel.name)) throw new Error(`Read model "${readModel.name}" is already registered`);
|
|
289
909
|
if (stage === "pre_ready") this.preReady.push(readModel);
|
|
@@ -297,7 +917,9 @@ var ReadModelCoordinator = class {
|
|
|
297
917
|
}
|
|
298
918
|
const enqueuedAt = performance.now();
|
|
299
919
|
const key = this.queueKeyFor(event);
|
|
300
|
-
const
|
|
920
|
+
const reservations = reserveBatch(this.readModels, event.operations, this.logger);
|
|
921
|
+
const run = () => this.runChain(event, enqueuedAt, reservations);
|
|
922
|
+
const current = (this.chains.get(key) ?? Promise.resolve()).then(run, run);
|
|
301
923
|
this.chains.set(key, current);
|
|
302
924
|
current.finally(() => {
|
|
303
925
|
if (this.chains.get(key) === current) this.chains.delete(key);
|
|
@@ -322,11 +944,18 @@ var ReadModelCoordinator = class {
|
|
|
322
944
|
postReadyDurationMs: 0
|
|
323
945
|
});
|
|
324
946
|
}
|
|
325
|
-
async runChain(event, enqueuedAt) {
|
|
947
|
+
async runChain(event, enqueuedAt, reservations) {
|
|
948
|
+
try {
|
|
949
|
+
await this.runBatch(event, enqueuedAt, reservations);
|
|
950
|
+
} finally {
|
|
951
|
+
releaseBatch(reservations);
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
async runBatch(event, enqueuedAt, reservations) {
|
|
326
955
|
const chainWaitDurationMs = performance.now() - enqueuedAt;
|
|
327
956
|
const preReadyStart = performance.now();
|
|
328
957
|
try {
|
|
329
|
-
await Promise.all(this.preReady.map((readModel) => this.indexWithTiming(readModel, "pre_ready", event)));
|
|
958
|
+
await Promise.all(this.preReady.map((readModel) => this.indexWithTiming(readModel, "pre_ready", event, reservations)));
|
|
330
959
|
} catch (error) {
|
|
331
960
|
this.logger.error("Pre-ready read model indexing failed for job @jobId: @Error", { jobId: event.jobId }, error);
|
|
332
961
|
}
|
|
@@ -344,7 +973,7 @@ var ReadModelCoordinator = class {
|
|
|
344
973
|
const emitDurationMs = performance.now() - emitStart;
|
|
345
974
|
const postReadyStart = performance.now();
|
|
346
975
|
try {
|
|
347
|
-
await Promise.all(this.postReady.map((readModel) => this.indexWithTiming(readModel, "post_ready", event)));
|
|
976
|
+
await Promise.all(this.postReady.map((readModel) => this.indexWithTiming(readModel, "post_ready", event, reservations)));
|
|
348
977
|
} catch (error) {
|
|
349
978
|
this.logger.error("Post-ready read model indexing failed for job @jobId: @Error", { jobId: event.jobId }, error);
|
|
350
979
|
}
|
|
@@ -358,11 +987,11 @@ var ReadModelCoordinator = class {
|
|
|
358
987
|
postReadyDurationMs
|
|
359
988
|
});
|
|
360
989
|
}
|
|
361
|
-
async indexWithTiming(readModel, stage, event) {
|
|
990
|
+
async indexWithTiming(readModel, stage, event, reservations) {
|
|
362
991
|
const start = performance.now();
|
|
363
992
|
let success = false;
|
|
364
993
|
try {
|
|
365
|
-
await readModel
|
|
994
|
+
await indexReserved(readModel, event.operations, reservations);
|
|
366
995
|
success = true;
|
|
367
996
|
} finally {
|
|
368
997
|
this.emitReadModelIndexed({
|
|
@@ -420,7 +1049,8 @@ var KyselyDocumentView = class extends BaseReadModel {
|
|
|
420
1049
|
super(db, operationIndex, writeCache, consistencyTracker, {
|
|
421
1050
|
readModelId: DOCUMENT_VIEW_READ_MODEL,
|
|
422
1051
|
rebuildStateOnInit: true,
|
|
423
|
-
indexing
|
|
1052
|
+
indexing,
|
|
1053
|
+
replayStreamSuffix: false
|
|
424
1054
|
});
|
|
425
1055
|
this.operationStore = operationStore;
|
|
426
1056
|
this.deletedDocumentRead = deletedDocumentRead;
|
|
@@ -436,10 +1066,9 @@ var KyselyDocumentView = class extends BaseReadModel {
|
|
|
436
1066
|
* echoes may be stale. All other action types index only header and their
|
|
437
1067
|
* own scope.
|
|
438
1068
|
*
|
|
439
|
-
*
|
|
440
|
-
*
|
|
441
|
-
*
|
|
442
|
-
* reverts a concurrent rename with the stale echo its later chunks carry.
|
|
1069
|
+
* Every row accepts a write only from an operation whose global ordinal is
|
|
1070
|
+
* at least the one the row already carries, so an older duplicate from a
|
|
1071
|
+
* sweep, boot replay or chunk interleave never rolls a scope back.
|
|
443
1072
|
*/
|
|
444
1073
|
async commitOperations(items) {
|
|
445
1074
|
await this._db.transaction().execute(async (trx) => {
|
|
@@ -466,9 +1095,9 @@ var KyselyDocumentView = class extends BaseReadModel {
|
|
|
466
1095
|
lastOperationHash: hash,
|
|
467
1096
|
lastOperationOrdinal: ordinal,
|
|
468
1097
|
lastUpdatedAt: now
|
|
469
|
-
}).where("documentId", "=", documentId).where("branch", "=", branch).execute();
|
|
1098
|
+
}).where("documentId", "=", documentId).where("branch", "=", branch).where("lastOperationOrdinal", "<=", ordinal).execute();
|
|
470
1099
|
const deletedDocumentScope = fullState?.document;
|
|
471
|
-
if (deletedDocumentScope !== void 0) await trx.updateTable("DocumentSnapshot").set({ content: deletedDocumentScope }).where("documentId", "=", documentId).where("branch", "=", branch).where("scope", "=", "document").execute();
|
|
1100
|
+
if (deletedDocumentScope !== void 0) await trx.updateTable("DocumentSnapshot").set({ content: deletedDocumentScope }).where("documentId", "=", documentId).where("branch", "=", branch).where("scope", "=", "document").where("lastOperationOrdinal", "<=", ordinal).execute();
|
|
472
1101
|
await trx.deleteFrom("SlugMapping").where("documentId", "=", documentId).where("branch", "=", branch).execute();
|
|
473
1102
|
continue;
|
|
474
1103
|
}
|
|
@@ -494,7 +1123,7 @@ var KyselyDocumentView = class extends BaseReadModel {
|
|
|
494
1123
|
"snapshotVersion",
|
|
495
1124
|
"lastOperationOrdinal"
|
|
496
1125
|
]).$if(needsExistingContent, (qb) => qb.select("content")).where("documentId", "=", documentId).where("scope", "=", scopeName).where("branch", "=", branch).executeTakeFirst();
|
|
497
|
-
if (
|
|
1126
|
+
if (existingSnapshot !== void 0 && existingSnapshot.lastOperationOrdinal > ordinal) continue;
|
|
498
1127
|
const newState = typeof scopeState === "object" && scopeState !== null ? scopeState : {};
|
|
499
1128
|
let slug = existingSnapshot?.slug ?? null;
|
|
500
1129
|
let name = existingSnapshot?.name ?? null;
|
|
@@ -527,7 +1156,7 @@ var KyselyDocumentView = class extends BaseReadModel {
|
|
|
527
1156
|
content: newState,
|
|
528
1157
|
slug,
|
|
529
1158
|
name
|
|
530
|
-
}).where("documentId", "=", documentId).where("scope", "=", scopeName).where("branch", "=", branch)
|
|
1159
|
+
}).where("documentId", "=", documentId).where("scope", "=", scopeName).where("branch", "=", branch).where("lastOperationOrdinal", "<=", ordinal).execute();
|
|
531
1160
|
else {
|
|
532
1161
|
const snapshot = {
|
|
533
1162
|
id: v4(),
|
|
@@ -1103,6 +1732,6 @@ var KyselyDocumentIndexer = class extends BaseReadModel {
|
|
|
1103
1732
|
}
|
|
1104
1733
|
};
|
|
1105
1734
|
//#endregion
|
|
1106
|
-
export { KyselyDocumentView as a, ReadModelCoordinator as c,
|
|
1735
|
+
export { ContiguousCursor as _, KyselyDocumentView as a, isCatchUpConsumer as b, ReadModelCoordinator as c, reserveBatch as d, BaseReadModel as f, unchunkedReadModelIndexingConfig as g, defaultReadModelIndexingConfig as h, DeletedDocumentRead as i, indexReserved as l, DEFAULT_READ_MODEL_YIELD_DEADLINE_MS as m, ConsistencyTracker as n, DOCUMENT_INDEXER_READ_MODEL as o, DEFAULT_COMMIT_CHUNK_SIZE as p, makeConsistencyKey as r, DOCUMENT_VIEW_READ_MODEL as s, KyselyDocumentIndexer as t, releaseBatch as u, defaultCatchUpConfig as v, CatchUpScheduler as y };
|
|
1107
1736
|
|
|
1108
|
-
//# sourceMappingURL=document-indexer-
|
|
1737
|
+
//# sourceMappingURL=document-indexer-I6i_YzsR.js.map
|