@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.
@@ -1,7 +1,278 @@
1
- import { ot as ReactorEventTypes, ut as yieldToMain } from "./forwarding-logger-CnuVDVze.js";
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
- * Base class for read models that provides catch-up/rewind functionality.
35
- * Handles initialization, state tracking via ViewState table, and consistency tracking.
36
- * Subclasses override commitOperations() with their specific domain logic.
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
- * Lowest ordinal this model failed to commit and has not committed since, or
49
- * zero when there is none. The stored cursor is held below it so replay from
50
- * the cursor still reaches every operation the failed pass left out.
51
- */
52
- uncommittedOrdinal = 0;
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
- * Initializes the read model by loading state and catching up on missed operations.
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
- const viewState = await this.loadState();
67
- if (viewState !== void 0) this.lastOrdinal = viewState;
68
- else await this.initializeState();
69
- let page = await this.operationIndex.getSinceOrdinal(this.lastOrdinal);
70
- while (page.results.length > 0) {
71
- const ops = this.config.rebuildStateOnInit ? await this.rebuildStateForOperations(page.results) : page.results;
72
- await this.indexOperations(ops);
73
- if (!page.next) break;
74
- page = await page.next();
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
- * Commits the batch in chunks, yielding between them with no transaction
79
- * open. A chunk that throws leaves the earlier chunks committed, so the pass
80
- * saves the cursor for that prefix and parks it below the operation it could
81
- * not commit before rethrowing.
82
- */
83
- async indexOperations(items) {
84
- if (items.length === 0) return;
85
- const { commitChunkSize, yieldDeadlineMs } = this.indexing;
86
- let lastYield = performance.now();
87
- let committed = 0;
88
- for (let start = 0; start < items.length; start += commitChunkSize) {
89
- if (start > 0 && performance.now() - lastYield > yieldDeadlineMs) {
90
- await yieldToMain();
91
- lastYield = performance.now();
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
- const chunk = items.slice(start, start + commitChunkSize);
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.commitOperations(chunk);
466
+ late = await this.operationIndex.getByOrdinals([...mine], signal);
96
467
  } catch (error) {
97
- this.park(items, committed);
98
- await this.recordCommittedPrefix(items.slice(0, committed));
468
+ this.cursor.settle(mine, false);
99
469
  throw error;
100
470
  }
101
- committed += chunk.length;
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.liftParkIfCommitted(items);
104
- await this.persistCursor(items);
105
- this.updateConsistencyTracker(items);
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(document)
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: 0
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
- this.consistencyTracker.update(coordinates);
561
+ return coordinates;
178
562
  }
179
- /**
180
- * Saves the cursor for the chunks that did commit before a later chunk threw.
181
- * A failure here is swallowed: the cursor simply stays where the pass found
182
- * it, which is equally safe, and the commit error is the one worth raising.
183
- */
184
- async recordCommittedPrefix(prefix) {
185
- if (prefix.length === 0) return;
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
- await this.persistCursor(prefix);
188
- } catch {
189
- return;
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
- /** Writes the cursor for the given items, never past a parked ordinal. */
194
- async persistCursor(items) {
195
- await this.db.transaction().execute(async (trx) => {
196
- await this.saveState(trx, items);
197
- await this.clampCursorToPark(trx);
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
- * Holds the cursor written by {@link saveState}, which subclasses may
202
- * override, below the lowest operation this model failed to commit.
203
- */
204
- async clampCursorToPark(trx) {
205
- if (this.uncommittedOrdinal === 0) return;
206
- const ceiling = this.uncommittedOrdinal - 1;
207
- if (this.lastOrdinal <= ceiling) return;
208
- this.lastOrdinal = ceiling;
209
- await trx.updateTable("ViewState").set({
210
- lastOrdinal: ceiling,
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).execute();
213
- }
214
- /** Remembers the lowest ordinal the failed pass left uncommitted. */
215
- park(items, committed) {
216
- let lowest = 0;
217
- for (let i = committed; i < items.length; i++) {
218
- const ordinal = items[i].context.ordinal;
219
- if (lowest === 0 || ordinal < lowest) lowest = ordinal;
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 (lowest === 0) return;
222
- if (this.uncommittedOrdinal === 0 || lowest < this.uncommittedOrdinal) this.uncommittedOrdinal = lowest;
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 current = (this.chains.get(key) ?? Promise.resolve()).then(() => this.runChain(event, enqueuedAt));
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.indexOperations(event.operations);
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
- * The header row is the one row every scope's chain writes, so it accepts a
440
- * write only from an operation whose global ordinal is at least the one the
441
- * row already carries. Without that, a chunked pass that started earlier
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 (scopeName === "header" && existingSnapshot !== void 0 && existingSnapshot.lastOperationOrdinal > ordinal) continue;
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).$if(scopeName === "header", (qb) => qb.where("lastOperationOrdinal", "<=", ordinal)).execute();
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, DEFAULT_READ_MODEL_YIELD_DEADLINE_MS as d, defaultReadModelIndexingConfig as f, DeletedDocumentRead as i, BaseReadModel as l, ConsistencyTracker as n, DOCUMENT_INDEXER_READ_MODEL as o, unchunkedReadModelIndexingConfig as p, makeConsistencyKey as r, DOCUMENT_VIEW_READ_MODEL as s, KyselyDocumentIndexer as t, DEFAULT_COMMIT_CHUNK_SIZE as u };
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-BVTGbEB0.js.map
1737
+ //# sourceMappingURL=document-indexer-I6i_YzsR.js.map