@rocicorp/zero 1.11.0-canary.35 → 1.11.0-canary.36

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.
Files changed (54) hide show
  1. package/out/zero/package.js +1 -1
  2. package/out/zero/package.js.map +1 -1
  3. package/out/zero-cache/src/config/zero-config.d.ts +20 -0
  4. package/out/zero-cache/src/config/zero-config.d.ts.map +1 -1
  5. package/out/zero-cache/src/config/zero-config.js +52 -0
  6. package/out/zero-cache/src/config/zero-config.js.map +1 -1
  7. package/out/zero-cache/src/server/syncer.d.ts.map +1 -1
  8. package/out/zero-cache/src/server/syncer.js +17 -1
  9. package/out/zero-cache/src/server/syncer.js.map +1 -1
  10. package/out/zero-cache/src/services/change-source/pg/backfill-stream.js +1 -1
  11. package/out/zero-cache/src/services/change-source/pg/backfill-stream.js.map +1 -1
  12. package/out/zero-cache/src/services/change-source/pg/replication-slots.d.ts +27 -7
  13. package/out/zero-cache/src/services/change-source/pg/replication-slots.d.ts.map +1 -1
  14. package/out/zero-cache/src/services/change-source/pg/replication-slots.js +124 -62
  15. package/out/zero-cache/src/services/change-source/pg/replication-slots.js.map +1 -1
  16. package/out/zero-cache/src/services/view-syncer/batch-pacer.d.ts +79 -0
  17. package/out/zero-cache/src/services/view-syncer/batch-pacer.d.ts.map +1 -0
  18. package/out/zero-cache/src/services/view-syncer/batch-pacer.js +249 -0
  19. package/out/zero-cache/src/services/view-syncer/batch-pacer.js.map +1 -0
  20. package/out/zero-cache/src/services/view-syncer/cvr.d.ts +27 -1
  21. package/out/zero-cache/src/services/view-syncer/cvr.d.ts.map +1 -1
  22. package/out/zero-cache/src/services/view-syncer/cvr.js +107 -17
  23. package/out/zero-cache/src/services/view-syncer/cvr.js.map +1 -1
  24. package/out/zero-cache/src/services/view-syncer/pipeline-driver.d.ts +85 -3
  25. package/out/zero-cache/src/services/view-syncer/pipeline-driver.d.ts.map +1 -1
  26. package/out/zero-cache/src/services/view-syncer/pipeline-driver.js +301 -20
  27. package/out/zero-cache/src/services/view-syncer/pipeline-driver.js.map +1 -1
  28. package/out/zero-cache/src/services/view-syncer/push-cost.d.ts +132 -0
  29. package/out/zero-cache/src/services/view-syncer/push-cost.d.ts.map +1 -0
  30. package/out/zero-cache/src/services/view-syncer/push-cost.js +164 -0
  31. package/out/zero-cache/src/services/view-syncer/push-cost.js.map +1 -0
  32. package/out/zero-cache/src/services/view-syncer/push-cutoff.d.ts +55 -0
  33. package/out/zero-cache/src/services/view-syncer/push-cutoff.d.ts.map +1 -0
  34. package/out/zero-cache/src/services/view-syncer/push-cutoff.js +56 -0
  35. package/out/zero-cache/src/services/view-syncer/push-cutoff.js.map +1 -0
  36. package/out/zero-cache/src/services/view-syncer/query-stats.d.ts +7 -0
  37. package/out/zero-cache/src/services/view-syncer/query-stats.d.ts.map +1 -1
  38. package/out/zero-cache/src/services/view-syncer/query-stats.js +20 -1
  39. package/out/zero-cache/src/services/view-syncer/query-stats.js.map +1 -1
  40. package/out/zero-cache/src/services/view-syncer/snapshotter.d.ts +136 -5
  41. package/out/zero-cache/src/services/view-syncer/snapshotter.d.ts.map +1 -1
  42. package/out/zero-cache/src/services/view-syncer/snapshotter.js +246 -18
  43. package/out/zero-cache/src/services/view-syncer/snapshotter.js.map +1 -1
  44. package/out/zero-cache/src/services/view-syncer/view-syncer.d.ts +8 -2
  45. package/out/zero-cache/src/services/view-syncer/view-syncer.d.ts.map +1 -1
  46. package/out/zero-cache/src/services/view-syncer/view-syncer.js +381 -16
  47. package/out/zero-cache/src/services/view-syncer/view-syncer.js.map +1 -1
  48. package/out/zero-client/src/client/version.js +1 -1
  49. package/out/zero-react/src/use-query.js +1 -1
  50. package/out/zero-react/src/use-query.js.map +1 -1
  51. package/out/zero-server/src/queries/process-queries.d.ts.map +1 -1
  52. package/out/zero-server/src/queries/process-queries.js +1 -1
  53. package/out/zero-server/src/queries/process-queries.js.map +1 -1
  54. package/package.json +1 -1
@@ -22,6 +22,8 @@ import "../../types/row-key.js";
22
22
  import { getSubscriptionState } from "../replicator/schema/replication-state.js";
23
23
  import { checkClientSchema } from "./client-schema.js";
24
24
  import { planWarningMessage } from "./plan-warnings.js";
25
+ import { PushCutoff } from "./push-cutoff.js";
26
+ import { PushCostEstimator, ReadCostEstimator, shouldRebuildInsteadOfReading, shouldStartOnRebuild } from "./push-cost.js";
25
27
  import { queryShape } from "./query-shape.js";
26
28
  import { rowIDSignatureUnit } from "./row-set-signature.js";
27
29
  import { ResetPipelinesSignal } from "./snapshotter.js";
@@ -114,6 +116,22 @@ var PipelineDriver = class {
114
116
  #pushedRowChanges = null;
115
117
  #hydrateContext = null;
116
118
  #advanceContext = null;
119
+ /**
120
+ * Whether a {@link CostBasedAdvance} has been set up and has not
121
+ * completed. It may already have removed pipelines, but the sources only
122
+ * move to the new snapshot when the iteration over its changes completes,
123
+ * so nothing can be hydrated until then. If the iteration is given up on
124
+ * or throws, they never do, and nothing can be hydrated until the
125
+ * pipelines are {@link reset()}.
126
+ */
127
+ #costBasedAdvanceIncomplete = false;
128
+ /**
129
+ * The queries abandoned by a {@link CostBasedAdvance} that have not been
130
+ * added again since.
131
+ */
132
+ #abandoned = /* @__PURE__ */ new Map();
133
+ #pushCosts = new PushCostEstimator();
134
+ #readCosts = new ReadCostEstimator();
117
135
  #replicaVersion = null;
118
136
  #primaryKeys = null;
119
137
  #permissions = null;
@@ -175,6 +193,8 @@ var PipelineDriver = class {
175
193
  this.#pipelines.delete(queryID);
176
194
  this.#destroyPipeline(queryID, pipeline, "reset");
177
195
  }
196
+ this.#abandoned.clear();
197
+ this.#costBasedAdvanceIncomplete = false;
178
198
  this.#tables.clear();
179
199
  this.#allTableNames.clear();
180
200
  this.#rowSetSignatures.clear();
@@ -230,11 +250,16 @@ var PipelineDriver = class {
230
250
  * {@link reset()} before hydrating. ({@link advance()} detects this when
231
251
  * the diff encounters the RESET op; this path skips the diff and so must
232
252
  * check explicitly.)
253
+ *
254
+ * @param releasePreviousSnapshot Whether to release the snapshot that the
255
+ * driver was at, which is otherwise left open until the next
256
+ * advance (see `Snapshotter.releasePrevious()`).
233
257
  */
234
- advanceWithoutDiff() {
258
+ advanceWithoutDiff(releasePreviousSnapshot = false) {
235
259
  const { prev, curr } = this.#snapshotter.advanceWithoutDiff();
236
260
  if (curr.schemaChangedSince(prev.version)) throw new ResetPipelinesSignal(`schema changed between ${prev.version} and ${curr.version}`, "schema-change");
237
261
  for (const table of this.#tables.values()) table.setDB(curr.db.db);
262
+ if (releasePreviousSnapshot) this.#snapshotter.releasePrevious();
238
263
  return curr.version;
239
264
  }
240
265
  #ensureCostModelExistsIfEnabled(db) {
@@ -266,6 +291,15 @@ var PipelineDriver = class {
266
291
  return this.#pipelines;
267
292
  }
268
293
  /**
294
+ * The queries whose pipelines a {@link CostBasedAdvance} removed and that
295
+ * have not been hydrated again since, by query ID. They are not in
296
+ * {@link queries()}. A query whose hydration is started but does not
297
+ * finish is still one of them.
298
+ */
299
+ abandonedQueries() {
300
+ return this.#abandoned;
301
+ }
302
+ /**
269
303
  * Stats from the hydration of the pipeline for `queryID`, or `undefined` if
270
304
  * the query has no pipeline.
271
305
  */
@@ -327,7 +361,7 @@ var PipelineDriver = class {
327
361
  for (const source of this.#tables.values()) total += source.pendingBytes;
328
362
  return total;
329
363
  }
330
- #logQueryPipelineLifecycle({ zeroEvent, pipelineRunID, queryHash, transformationHash, queryName, hydrationReason, stopReason, hydrationTimeMs, hydrationRowCount, hydrationRowsRead, pipelineLifetimeMs }) {
364
+ #logQueryPipelineLifecycle({ zeroEvent, pipelineRunID, queryHash, transformationHash, queryName, hydrationReason, stopReason, hydrationTimeMs, hydrationRowCount, hydrationRowsRead, pipelineLifetimeMs, abandonReason, estimatedPushMs, pushTimeMs, pushedChanges, level = "info" }) {
331
365
  let lc = this.#lc.withContext("zeroEvent", zeroEvent).withContext("pipelineRunID", pipelineRunID).withContext("queryHash", queryHash).withContext("transformationHash", transformationHash);
332
366
  if (queryName !== void 0) lc = lc.withContext("queryName", queryName);
333
367
  if (hydrationReason !== void 0) lc = lc.withContext("hydrationReason", hydrationReason);
@@ -336,7 +370,11 @@ var PipelineDriver = class {
336
370
  if (hydrationRowCount !== void 0) lc = lc.withContext("hydrationRowCount", hydrationRowCount);
337
371
  if (hydrationRowsRead !== void 0) lc = lc.withContext("hydrationRowsRead", hydrationRowsRead);
338
372
  if (pipelineLifetimeMs !== void 0) lc = lc.withContext("pipelineLifetimeMs", pipelineLifetimeMs);
339
- lc.info?.("query pipeline lifecycle");
373
+ if (abandonReason !== void 0) lc = lc.withContext("abandonReason", abandonReason);
374
+ if (estimatedPushMs !== void 0) lc = lc.withContext("estimatedPushMs", estimatedPushMs);
375
+ if (pushTimeMs !== void 0) lc = lc.withContext("pushTimeMs", pushTimeMs);
376
+ if (pushedChanges !== void 0) lc = lc.withContext("pushedChanges", pushedChanges);
377
+ lc[level]?.("query pipeline lifecycle");
340
378
  }
341
379
  /**
342
380
  * A `{scalar: true}` that cannot be honored degrades silently to a plain
@@ -354,13 +392,16 @@ var PipelineDriver = class {
354
392
  #disableCorrelatedPredicatePushdown() {
355
393
  return this.#config?.enableCorrelatedPredicatePushdown === false;
356
394
  }
357
- #resolveScalarSubqueries(ast) {
395
+ #resolveScalarSubqueries(ast, tables) {
358
396
  const companionInputs = [];
359
397
  const executor = (subqueryAST, childField) => {
360
398
  const input = buildPipeline(subqueryAST, {
361
399
  enableNotExists: true,
362
400
  disableCorrelatedPredicatePushdown: this.#disableCorrelatedPredicatePushdown(),
363
- getSource: (name) => this.#getSource(name),
401
+ getSource: (name) => {
402
+ tables.add(name);
403
+ return this.#getSource(name);
404
+ },
364
405
  createStorage: () => this.#createStorage(),
365
406
  decorateSourceInput: (input) => input,
366
407
  decorateInput: (input) => input,
@@ -409,17 +450,20 @@ var PipelineDriver = class {
409
450
  this.removeQuery(queryID, "replace-query");
410
451
  const pipelineRunID = randomID();
411
452
  const shape = queryShape(query);
453
+ const level = this.#abandoned.has(queryID) ? "debug" : "info";
412
454
  this.#logQueryPipelineLifecycle({
413
455
  zeroEvent: "query-pipeline-hydrate-start",
414
456
  pipelineRunID,
415
457
  queryHash: queryID,
416
458
  transformationHash,
417
459
  queryName,
418
- hydrationReason
460
+ hydrationReason,
461
+ level
419
462
  });
420
463
  const debugDelegate = runtimeDebugFlags.trackRowsVended ? new Debug(true) : void 0;
421
464
  const costModel = this.#ensureCostModelExistsIfEnabled(this.#snapshotter.current().db.db);
422
465
  assert(this.#advanceContext === null, "Cannot hydrate while advance is in progress");
466
+ assert(!this.#costBasedAdvanceIncomplete, "Cannot hydrate before the advance has completed");
423
467
  this.#hydrateContext = { timer };
424
468
  const rowsReadAtStart = this.#totalRowsRead();
425
469
  let hydrationFinished = false;
@@ -428,10 +472,11 @@ var PipelineDriver = class {
428
472
  let planWarnings = [];
429
473
  const planWarningThresholds = this.#planWarningThresholds;
430
474
  let builtInputs = [];
475
+ const tables = /* @__PURE__ */ new Set();
431
476
  try {
432
477
  this.#rowSetSignatures.set(queryID, 0n);
433
478
  this.#changedRowSetSignatures.add(queryID);
434
- const { ast: resolvedQuery, companions: companionMeta, companionInputs, ignoredScalarHints } = this.#resolveScalarSubqueries(query);
479
+ const { ast: resolvedQuery, companions: companionMeta, companionInputs, ignoredScalarHints } = this.#resolveScalarSubqueries(query, tables);
435
480
  builtInputs = [...companionInputs];
436
481
  this.#warnIgnoredScalarHints(queryID, ignoredScalarHints);
437
482
  const input = buildPipeline(resolvedQuery, {
@@ -439,7 +484,10 @@ var PipelineDriver = class {
439
484
  enableNotExists: true,
440
485
  disableCorrelatedPredicatePushdown: this.#disableCorrelatedPredicatePushdown(),
441
486
  enablePlannerAwarePushdown: this.#config?.enablePlannerAwarePushdown !== false,
442
- getSource: (name) => this.#getSource(name),
487
+ getSource: (name) => {
488
+ tables.add(name);
489
+ return this.#getSource(name);
490
+ },
443
491
  createStorage: () => this.#createStorage(),
444
492
  decorateSourceInput: (input, _queryID) => new MeasurePushOperator(new QueryFailureLoggingOperator(this.#lc, input, queryID, transformationHash, queryName), queryID, this.#metricsDelegate, "query-update-server"),
445
493
  decorateInput: (input) => input,
@@ -524,8 +572,10 @@ var PipelineDriver = class {
524
572
  shape,
525
573
  transformationHash,
526
574
  ...queryName !== void 0 && { queryName },
527
- companions: liveCompanions
575
+ companions: liveCompanions,
576
+ tables
528
577
  });
578
+ this.#abandoned.delete(queryID);
529
579
  hydrationFinished = true;
530
580
  this.#queryStats?.recordHydration({
531
581
  queryName,
@@ -546,7 +596,8 @@ var PipelineDriver = class {
546
596
  hydrationReason,
547
597
  hydrationTimeMs,
548
598
  hydrationRowCount,
549
- hydrationRowsRead
599
+ hydrationRowsRead,
600
+ level
550
601
  });
551
602
  } catch (e) {
552
603
  hydrationFailed = true;
@@ -606,15 +657,24 @@ var PipelineDriver = class {
606
657
  * was not added.
607
658
  */
608
659
  removeQuery(queryID, stopReason = "remove-query") {
660
+ this.#removeQuery(queryID, stopReason);
661
+ }
662
+ /**
663
+ * @param abandoned If an advancement is abandoning the query's pipeline,
664
+ * the record of that, which is logged.
665
+ */
666
+ #removeQuery(queryID, stopReason, abandoned) {
609
667
  const pipeline = this.#pipelines.get(queryID);
610
668
  if (pipeline) {
611
669
  this.#pipelines.delete(queryID);
612
- this.#destroyPipeline(queryID, pipeline, stopReason);
670
+ this.#destroyPipeline(queryID, pipeline, stopReason, abandoned);
613
671
  }
614
672
  this.#rowSetSignatures.delete(queryID);
615
673
  this.#changedRowSetSignatures.delete(queryID);
674
+ if (stopReason !== "replace-query") this.#abandoned.delete(queryID);
675
+ if (stopReason === "remove-query") this.#pushCosts.forget(queryID);
616
676
  }
617
- #destroyPipeline(queryID, pipeline, stopReason) {
677
+ #destroyPipeline(queryID, pipeline, stopReason, abandoned) {
618
678
  this.#logQueryPipelineLifecycle({
619
679
  zeroEvent: "query-pipeline-stop",
620
680
  pipelineRunID: pipeline.pipelineRunID,
@@ -625,7 +685,14 @@ var PipelineDriver = class {
625
685
  stopReason,
626
686
  hydrationTimeMs: pipeline.hydrationTimeMs,
627
687
  hydrationRowCount: pipeline.hydrationRowCount,
628
- pipelineLifetimeMs: Date.now() - pipeline.pipelineReadyAtMs
688
+ pipelineLifetimeMs: Date.now() - pipeline.pipelineReadyAtMs,
689
+ ...abandoned && {
690
+ abandonReason: abandoned.reason,
691
+ estimatedPushMs: abandoned.estimatedPushMs,
692
+ pushTimeMs: abandoned.pushTimeMs,
693
+ pushedChanges: abandoned.changes,
694
+ level: "debug"
695
+ }
629
696
  });
630
697
  pipeline.input.destroy();
631
698
  for (const companion of pipeline.companions) companion.input.destroy();
@@ -689,20 +756,41 @@ var PipelineDriver = class {
689
756
  * measure the progress of the advancement and abort with a
690
757
  * {@link ResetPipelinesSignal} if it is estimated to take longer
691
758
  * than a hydration.
759
+ * @param costBased If given, the advancement is bounded query by query
760
+ * instead (see {@link CostBasedAdvance}).
692
761
  * @return The resulting row changes for all added queries. Note that the
693
762
  * `changes` must be iterated over in their entirety in order to
694
763
  * advance the database snapshot.
764
+ * @throws A {@link ResetPipelinesSignal} if `costBased` is given and the
765
+ * changes include a schema change, a truncation or a change of
766
+ * the permissions. It is thrown before any change is pushed or
767
+ * any pipeline is removed, as the caller resets the pipelines
768
+ * either way. Without `costBased`, it is iterating over the
769
+ * `changes` that throws it, on getting to that change.
695
770
  */
696
- advance(timer) {
771
+ advance(timer, costBased) {
697
772
  assert(this.initialized(), "Pipeline driver must be initialized before advancing");
698
- const diff = this.#snapshotter.advance(this.#tableSpecs, this.#allTableNames, this.#tables, "divergent");
773
+ let diff;
774
+ let context;
775
+ if (costBased === void 0) diff = this.#snapshotter.advance(this.#tableSpecs, this.#allTableNames, this.#tables, "divergent");
776
+ else {
777
+ const { prev, curr } = this.#snapshotter.advanceWithoutDiff();
778
+ const counts = curr.countChangesSince(prev.version);
779
+ const reset = this.#snapshotter.pendingReset(this.#tableSpecs, counts);
780
+ if (reset) throw reset;
781
+ context = this.#startCostBasedAdvance(costBased, curr.version > prev.version, counts);
782
+ diff = this.#snapshotter.diff(this.#tableSpecs, this.#allTableNames, this.#tables, "divergent", counts, context.abandoned.length > 0 ? this.#tablesRead() : void 0);
783
+ this.#costBasedAdvanceIncomplete = true;
784
+ }
699
785
  const { prev, curr, changes } = diff;
700
786
  this.#lc.debug?.(`advance ${prev.version} => ${curr.version}: ${changes} changes`);
701
787
  const progress = { changesProcessed: 0 };
702
788
  return {
703
789
  version: curr.version,
704
790
  numChanges: changes,
705
- changes: this.#trackRowSetSignatures(this.#advance(diff, timer, changes, progress)),
791
+ changes: this.#trackRowSetSignatures(this.#advance(diff, timer, changes, progress, context)),
792
+ abandoned: () => context?.abandoned ?? [],
793
+ estimates: () => context?.estimates ?? [],
706
794
  progress: () => ({
707
795
  changesScanned: diff.changesScanned,
708
796
  changesProcessed: progress.changesProcessed,
@@ -710,7 +798,109 @@ var PipelineDriver = class {
710
798
  })
711
799
  };
712
800
  }
713
- *#advance(diff, timer, numChanges, progress) {
801
+ /** The tables that the pipelines read. */
802
+ #tablesRead() {
803
+ const tables = /* @__PURE__ */ new Set();
804
+ for (const pipeline of this.#pipelines.values()) for (const table of pipeline.tables) tables.add(table);
805
+ return tables;
806
+ }
807
+ /**
808
+ * Removes the pipelines of the queries that are estimated to be cheaper to
809
+ * hydrate again than to push the advancement's changes to: first each
810
+ * query for what pushing costs it, and then the ones that are left for
811
+ * what reading the changes costs them together.
812
+ *
813
+ * @param canAbandon Whether pipelines can be removed, by this or by the
814
+ * cutoff.
815
+ * @param counts The rows that changed since the previous snapshot.
816
+ */
817
+ #startCostBasedAdvance({ shouldCutOff, rebuildMargin }, canAbandon, counts) {
818
+ const changes = counts.byTable;
819
+ const estimatedPushMs = /* @__PURE__ */ new Map();
820
+ const abandoned = [];
821
+ if (canAbandon) {
822
+ for (const [queryID, pipeline] of this.#pipelines) {
823
+ const estimate = this.#pushCosts.estimate(queryID, pipeline.tables, changes);
824
+ if (estimate === void 0) continue;
825
+ if (shouldStartOnRebuild(estimate, pipeline.hydrationTimeMs, rebuildMargin)) this.#abandon(abandoned, "estimate", pipeline, {
826
+ queryID,
827
+ pushTimeMs: 0,
828
+ changes: 0,
829
+ hydrationTimeMs: pipeline.hydrationTimeMs,
830
+ estimatedPushMs: estimate
831
+ });
832
+ else estimatedPushMs.set(queryID, estimate);
833
+ }
834
+ this.#abandonInsteadOfReading(counts, rebuildMargin, estimatedPushMs, abandoned);
835
+ }
836
+ return {
837
+ cutoff: new PushCutoff(canAbandon ? shouldCutOff : () => false),
838
+ entries: counts.total,
839
+ estimatedPushMs,
840
+ abandoned,
841
+ estimates: []
842
+ };
843
+ }
844
+ /**
845
+ * Removes the pipelines that are left, if reading the advancement's
846
+ * changes and pushing them to those is estimated to cost more than
847
+ * hydrating them again. The changes are then not read at all.
848
+ */
849
+ #abandonInsteadOfReading(counts, rebuildMargin, estimatedPushMs, abandoned) {
850
+ if (this.#pipelines.size === 0) return;
851
+ let entriesOfTablesRead = 0;
852
+ for (const table of this.#tablesRead()) entriesOfTablesRead += counts.byTable.get(table) ?? 0;
853
+ const queries = Array.from(this.#pipelines, ([queryID, pipeline]) => ({
854
+ queryID,
855
+ pipeline,
856
+ estimatedPushMs: estimatedPushMs.get(queryID),
857
+ hydrationTimeMs: pipeline.hydrationTimeMs
858
+ }));
859
+ const estimatedReadMs = this.#readCosts.estimate({
860
+ walk: counts.total,
861
+ parse: abandoned.length > 0 ? entriesOfTablesRead : counts.total,
862
+ lookup: entriesOfTablesRead
863
+ });
864
+ if (!shouldRebuildInsteadOfReading(estimatedReadMs, queries, rebuildMargin)) return;
865
+ let pushMs = 0;
866
+ let hydrationMs = 0;
867
+ for (const query of queries) {
868
+ pushMs += query.estimatedPushMs ?? 0;
869
+ hydrationMs += query.hydrationTimeMs;
870
+ }
871
+ this.#lc.debug?.("Not reading a batch of changes: hydrating its queries again is estimated to cost less", {
872
+ entries: counts.total,
873
+ entriesOfTablesRead,
874
+ estimatedReadMs,
875
+ estimatedPushMs: pushMs,
876
+ hydrationTimeMs: hydrationMs,
877
+ rebuildMargin,
878
+ queries: queries.length
879
+ });
880
+ for (const { queryID, pipeline, ...estimate } of queries) this.#abandon(abandoned, "read", pipeline, {
881
+ queryID,
882
+ pushTimeMs: 0,
883
+ changes: 0,
884
+ ...estimate
885
+ });
886
+ estimatedPushMs.clear();
887
+ }
888
+ #abandon(abandoned, reason, pipeline, progress) {
889
+ const { transformationHash, transformedAst, originalAst, queryName } = pipeline;
890
+ const query = {
891
+ ...progress,
892
+ reason,
893
+ transformationHash,
894
+ transformedAst,
895
+ originalAst,
896
+ ...queryName !== void 0 && { queryName }
897
+ };
898
+ this.#removeQuery(progress.queryID, "abandon-advance", query);
899
+ this.#abandoned.set(progress.queryID, query);
900
+ abandoned.push(query);
901
+ this.#queryStats?.recordAbandoned(pipeline, reason);
902
+ }
903
+ *#advance(diff, timer, numChanges, progress, costBased) {
714
904
  assert(this.#hydrateContext === null, "Cannot advance while hydration is in progress");
715
905
  const totalHydrationTimeMs = this.totalHydrationTimeMs();
716
906
  const advanceContext = {
@@ -726,11 +916,13 @@ var PipelineDriver = class {
726
916
  pos: 0,
727
917
  currentTable: void 0,
728
918
  queryStats: /* @__PURE__ */ new Map(),
919
+ costBased,
729
920
  pushTimeMs: 0,
730
921
  stage: "diff"
731
922
  };
732
923
  this.#advanceContext = advanceContext;
733
924
  let timedOut = false;
925
+ let readToEnd = true;
734
926
  try {
735
927
  advanceContext.reservedRows = this.#reserveDeferredWrites(numChanges);
736
928
  const deferWrites = advanceContext.reservedRows !== void 0;
@@ -740,7 +932,14 @@ var PipelineDriver = class {
740
932
  for (const { table, prevValues: probedPrevValues, nextValue, rowKey } of diff) {
741
933
  advanceContext.stage = "between";
742
934
  advanceContext.completedChanges = diff.changesScanned - 1;
743
- if (this.#shouldAdvanceYieldMaybeAbortAdvance()) yield "yield";
935
+ if (this.#checkAdvance(advanceContext)) yield "yield";
936
+ if (costBased) {
937
+ this.#cutOffQueries(advanceContext, costBased, diff, table);
938
+ if (this.#pipelines.size === 0) {
939
+ readToEnd = false;
940
+ break;
941
+ }
942
+ }
744
943
  const start = timer.totalElapsed();
745
944
  advanceContext.currentChangeStartMs = start;
746
945
  advanceContext.currentTable = table;
@@ -779,7 +978,7 @@ var PipelineDriver = class {
779
978
  advanceContext.completedChanges = diff.changesScanned;
780
979
  }
781
980
  }
782
- this.#shouldAdvanceYieldMaybeAbortAdvance(false);
981
+ this.#checkAdvance(advanceContext, false);
783
982
  if (holds === "fits") holds = this.#holdPendingRows(advanceContext);
784
983
  } finally {
785
984
  advanceContext.pushTimeMs += timer.totalElapsed() - start;
@@ -793,9 +992,26 @@ var PipelineDriver = class {
793
992
  advanceContext.completedChanges = diff.changesScanned;
794
993
  const { curr } = diff;
795
994
  for (const table of this.#tables.values()) table.setDB(curr.db.db);
995
+ this.#costBasedAdvanceIncomplete = false;
796
996
  this.#ensureCostModelExistsIfEnabled(curr.db.db);
797
997
  this.#lc.debug?.(`Advanced to ${curr.version}`);
798
998
  this.#logSlowAdvances(advanceContext);
999
+ if (costBased) {
1000
+ this.#recordPushCosts(advanceContext, costBased, diff.changesScannedByTable);
1001
+ if (readToEnd && diff.changes > 0 && diff.stats) {
1002
+ const { changeLogReadMs, parseMs, rowReadMs } = diff.stats;
1003
+ this.#readCosts.record({
1004
+ walk: changeLogReadMs,
1005
+ parse: parseMs,
1006
+ lookup: rowReadMs
1007
+ }, {
1008
+ walk: costBased.entries,
1009
+ parse: diff.changesScanned,
1010
+ lookup: diff.changesLookedUp
1011
+ });
1012
+ }
1013
+ this.#snapshotter.releasePrevious();
1014
+ }
799
1015
  } catch (e) {
800
1016
  if (e instanceof ResetPipelinesSignal && e.reason === "advancement-timeout") {
801
1017
  timedOut = true;
@@ -809,6 +1025,70 @@ var PipelineDriver = class {
809
1025
  this.#recordAdvanceStats(advanceContext, timedOut);
810
1026
  }
811
1027
  }
1028
+ /**
1029
+ * Checks the progress of the advancement. Returns whether it should yield.
1030
+ *
1031
+ * An advancement is bounded in one of two ways, never both. A
1032
+ * {@link CostBasedAdvance} cuts queries off one by one between changes
1033
+ * (see {@link #cutOffQueries}) and always runs to completion.
1034
+ * Without one, {@link #shouldAdvanceYieldMaybeAbortAdvance} aborts the
1035
+ * advancement with a {@link ResetPipelinesSignal} if it takes too long.
1036
+ */
1037
+ #checkAdvance(advanceContext, checkYield = true) {
1038
+ if (advanceContext.costBased === void 0) return this.#shouldAdvanceYieldMaybeAbortAdvance(checkYield);
1039
+ return checkYield && advanceContext.timer.elapsedLap() > this.#yieldThresholdMs();
1040
+ }
1041
+ /**
1042
+ * Removes the pipelines of the queries that the cutoff says to.
1043
+ *
1044
+ * @param nextTable The table of the change that `diff` has just yielded,
1045
+ * which has not been pushed yet.
1046
+ */
1047
+ #cutOffQueries({ queryStats }, costBased, diff, nextTable) {
1048
+ const cutOff = costBased.cutoff.takeCutOff((queryID) => {
1049
+ const pipeline = this.#pipelines.get(queryID);
1050
+ const stats = queryStats.get(queryID);
1051
+ return pipeline === void 0 || stats === void 0 ? void 0 : {
1052
+ queryID,
1053
+ pushTimeMs: stats.timeMs,
1054
+ changes: stats.changes,
1055
+ hydrationTimeMs: pipeline.hydrationTimeMs
1056
+ };
1057
+ });
1058
+ if (cutOff.length === 0) return;
1059
+ const entriesByTable = new Map(diff.changesScannedByTable);
1060
+ entriesByTable.set(nextTable, (entriesByTable.get(nextTable) ?? 1) - 1);
1061
+ for (const progress of cutOff) {
1062
+ const { queryID } = progress;
1063
+ const pipeline = must(this.#pipelines.get(queryID));
1064
+ this.#pushCosts.record(queryID, pipeline.tables, queryStats.get(queryID)?.timeMsByTable, entriesByTable);
1065
+ this.#queryStats?.recordAdvance(pipeline, {
1066
+ timeMs: progress.pushTimeMs,
1067
+ changes: progress.changes,
1068
+ timedOut: false
1069
+ });
1070
+ this.#abandon(costBased.abandoned, "cutoff", pipeline, {
1071
+ ...progress,
1072
+ estimatedPushMs: costBased.estimatedPushMs.get(queryID)
1073
+ });
1074
+ }
1075
+ }
1076
+ /**
1077
+ * Learns from a completed advancement what its changes cost the queries
1078
+ * that were pushed all of them.
1079
+ */
1080
+ #recordPushCosts({ queryStats }, { estimatedPushMs, estimates }, entriesByTable) {
1081
+ for (const [queryID, pipeline] of this.#pipelines) {
1082
+ const stats = queryStats.get(queryID);
1083
+ this.#pushCosts.record(queryID, pipeline.tables, stats?.timeMsByTable, entriesByTable);
1084
+ const estimate = estimatedPushMs.get(queryID);
1085
+ if (estimate !== void 0) estimates.push({
1086
+ queryID,
1087
+ estimatedPushMs: estimate,
1088
+ pushTimeMs: stats?.timeMs ?? 0
1089
+ });
1090
+ }
1091
+ }
812
1092
  /** Accounts the time each query took in an advancement to its shape. */
813
1093
  #recordAdvanceStats({ queryStats }, timedOut) {
814
1094
  if (this.#queryStats === void 0) return;
@@ -883,6 +1163,7 @@ var PipelineDriver = class {
883
1163
  timeMsByTable: /* @__PURE__ */ new Map()
884
1164
  }));
885
1165
  stats.timeMs += timeMs;
1166
+ advance.costBased?.cutoff.recordPush(queryID);
886
1167
  if (stats.lastPos !== advance.pos) {
887
1168
  stats.lastPos = advance.pos;
888
1169
  stats.changes++;
@@ -1000,7 +1281,7 @@ var PipelineDriver = class {
1000
1281
  }
1001
1282
  #shouldYield() {
1002
1283
  if (this.#hydrateContext) return this.#hydrateContext.timer.elapsedLap() > this.#yieldThresholdMs();
1003
- if (this.#advanceContext) return this.#shouldAdvanceYieldMaybeAbortAdvance();
1284
+ if (this.#advanceContext) return this.#checkAdvance(this.#advanceContext);
1004
1285
  throw new Error("shouldYield called outside of hydration or advancement");
1005
1286
  }
1006
1287
  /**