@rocicorp/zero 1.11.0-canary.2 → 1.11.0-canary.4

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.
@@ -10,7 +10,7 @@ import { manualSpan, startAsyncSpan, startSpan } from "../../../../otel/src/span
10
10
  import { ZERO_VERSION_COLUMN_NAME } from "../replicator/schema/constants.js";
11
11
  import { transformAndHashQuery } from "../../auth/read-authorizer.js";
12
12
  import { randInt } from "../../../../shared/src/rand.js";
13
- import { getOrCreateCounter, getOrCreateLatencyHistogram, getOrCreateNativeHistogram, getOrCreateUpDownCounter } from "../../observability/metrics.js";
13
+ import { getOrCreateCounter, getOrCreateLatencyHistogram, getOrCreateNativeHistogram, getOrCreateUpDownCounter, getOrCreateValueHistogram } from "../../observability/metrics.js";
14
14
  import { rowIDString } from "../../types/row-key.js";
15
15
  import "../replicator/schema/replication-state.js";
16
16
  import { parseSignature } from "./row-set-signature.js";
@@ -25,8 +25,9 @@ import { isAuthErrorBody } from "../../auth/auth.js";
25
25
  import { ClientHandler, startPoke } from "./client-handler.js";
26
26
  import { tracer } from "./tracer.js";
27
27
  import { CVRStore, ClientNotFoundError } from "./cvr-store.js";
28
- import { CVRConfigDrivenUpdater, CVRQueryDrivenUpdater, nextEvictionTime } from "./cvr.js";
28
+ import { CVRConfigDrivenUpdater, CVRQueryDrivenUpdater, classifyQueriesForHydration, nextEvictionTime } from "./cvr.js";
29
29
  import { E2EServingLagTracker } from "./e2e-serving-lag.js";
30
+ import { HydrationBudget } from "./hydration-budget.js";
30
31
  import { handleInspect } from "./inspect-handler.js";
31
32
  import { QueryCoveringIndex } from "./query-covering.js";
32
33
  import { resolver } from "@rocicorp/resolver";
@@ -90,12 +91,27 @@ var ViewSyncerService = class {
90
91
  #stopped = resolver();
91
92
  #initialized = resolver();
92
93
  #cvr;
93
- #pipelinesSynced = false;
94
+ /**
95
+ * Indicates whether the query pipelines have completed initial catch-up and
96
+ * hydration with the CVR snapshot, and are ready for steady-state operation.
97
+ *
98
+ * - When `false`: The syncer is in its initial catch-up phase. The replica
99
+ * advances without diffs until it reaches `cvr.version.stateVersion`,
100
+ * after which `#maybeHydratePipelines` hydrates queries.
101
+ * - When `true`: Pipelines are fully populated and in steady-state; replica
102
+ * changes advance incrementally via `#advancePipelines`, and client query
103
+ * updates are processed via `#syncQueryPipelineSet`.
104
+ *
105
+ * Resets to `false` if `#advancePipelines` returns a `ResetPipelinesSignal`
106
+ * and pipelines must be reset and rehydrated.
107
+ */
108
+ #pipelinesHydrated = false;
94
109
  #servedVersion = null;
95
110
  #e2eServingLagTracker = new E2EServingLagTracker();
96
111
  #expiredQueriesTimer = 0;
97
112
  #authMaintenanceTimer = 0;
98
113
  #setTimeout;
114
+ #now;
99
115
  #customQueryTransformer;
100
116
  #queryReplacements = /* @__PURE__ */ new Map();
101
117
  #activeClients = getOrCreateUpDownCounter("sync", "active-clients", "Number of active sync clients");
@@ -122,10 +138,56 @@ var ViewSyncerService = class {
122
138
  #pipelineResets = getOrCreateCounter("sync", "pipeline-resets", "Number of pipeline resets");
123
139
  #rowSetSignatureDrifts = getOrCreateCounter("sync", "query.row-set-signature-drifts", "Number of times re-hydration of an unchanged query produced a different row-set signature than what is stored in the CVR (forcing a configVersion bump and full re-execution). Expected to be near-zero in steady state; persistent non-zero values indicate non-deterministic query execution (e.g. Cap operator picking different N-row subsets).");
124
140
  #sameHashRehydrationVersionBumps = getOrCreateCounter("sync", "query.same-hash-rehydrations-forced-bump", "Number of times query-set reconciliation forced a configVersion bump for already-gotten same-transformation-hash query rehydration because trackQueries would not otherwise bump. Expected to be near-zero; non-zero values indicate pipeline/CVR row-set drift reached query-set reconciliation.");
141
+ #hydrationBudgetExhaustions = getOrCreateCounter("sync", "hydration_budget_exhaustions", {
142
+ description: "Number of hydration passes that exhaust their budget.",
143
+ unit: "{pass}"
144
+ });
145
+ #hydrationBudgetEvictions = getOrCreateCounter("sync", "hydration_budget_evictions", {
146
+ description: "Number of inactive queries removed after hydration budget exhaustion.",
147
+ unit: "{query}"
148
+ });
149
+ #hydrationBudgetElapsed = getOrCreateValueHistogram("sync", "hydration_budget_elapsed", {
150
+ description: "Elapsed milliseconds when optional query hydration stopped.",
151
+ unit: "ms",
152
+ bucketBoundaries: [
153
+ 1,
154
+ 2,
155
+ 5,
156
+ 10,
157
+ 20,
158
+ 50,
159
+ 100,
160
+ 200,
161
+ 500,
162
+ 1e3,
163
+ 5e3
164
+ ]
165
+ });
166
+ #hydrationBudgetOvershoot = getOrCreateValueHistogram("sync", "hydration_budget_overshoot", {
167
+ description: "Milliseconds elapsed beyond the configured hydration budget.",
168
+ unit: "ms",
169
+ bucketBoundaries: [
170
+ 0,
171
+ 1,
172
+ 2,
173
+ 5,
174
+ 10,
175
+ 20,
176
+ 50,
177
+ 100,
178
+ 200,
179
+ 500,
180
+ 1e3
181
+ ]
182
+ });
183
+ #queryEvictions = getOrCreateCounter("sync", "query_evictions", {
184
+ description: "Number of inactive queries evicted, grouped by reason.",
185
+ unit: "{query}"
186
+ });
125
187
  #inspectorDelegate;
126
188
  #config;
127
189
  #runPriorityOp;
128
- constructor(config, lc, shard, taskID, clientGroupID, cvrDb, pipelineDriver, versionChanges, drainCoordinator, slowHydrateThreshold, inspectorDelegate, connContextManager, customQueryTransformer, runPriorityOp, keepaliveMs = DEFAULT_KEEPALIVE_MS, setTimeoutFn = setTimeout.bind(globalThis)) {
190
+ constructor(config, lc, shard, taskID, clientGroupID, cvrDb, pipelineDriver, versionChanges, drainCoordinator, slowHydrateThreshold, inspectorDelegate, connContextManager, customQueryTransformer, runPriorityOp, keepaliveMs = DEFAULT_KEEPALIVE_MS, setTimeoutFn = setTimeout.bind(globalThis), now = performance.now.bind(performance)) {
129
191
  this.#config = config;
130
192
  this.id = clientGroupID;
131
193
  this.connContextManager = connContextManager;
@@ -140,6 +202,7 @@ var ViewSyncerService = class {
140
202
  this.#customQueryTransformer = customQueryTransformer;
141
203
  this.#cvrStore = new CVRStore(lc, cvrDb, shard, taskID, clientGroupID, () => this.#stateChanges.cancel());
142
204
  this.#setTimeout = setTimeoutFn;
205
+ this.#now = now;
143
206
  this.#runPriorityOp = runPriorityOp;
144
207
  this.keepalive();
145
208
  }
@@ -213,26 +276,18 @@ var ViewSyncerService = class {
213
276
  lc.info?.(`resetting CVR: ${message}`);
214
277
  throw new ClientNotFoundError(message);
215
278
  }
216
- if (this.#pipelinesSynced) {
279
+ if (this.#pipelinesHydrated) {
217
280
  const result = await this.#advancePipelines(lc, cvr);
218
281
  if (result === "success") return;
219
282
  lc.info?.(`resetting pipelines: ${result.message}`);
220
283
  this.#pipelineResets.add(1, { reason: result.reason });
221
284
  this.#pipelines.reset(clientSchema);
222
- this.#pipelinesSynced = false;
285
+ this.#pipelinesHydrated = false;
223
286
  this.connContextManager.setSharedRetransformReady(false);
224
287
  }
225
- const version = this.#pipelines.advanceWithoutDiff();
226
- const cvrVer = versionString(cvr.version);
227
- if (version < cvr.version.stateVersion) {
228
- lc.debug?.(`replica@${version} is behind cvr@${cvrVer}`);
229
- return;
230
- }
231
- lc.info?.(`init pipelines@${version} (cvr@${cvrVer})`);
232
- const driftedQueryIDs = await this.#hydrateUnchangedQueries(lc, cvr);
233
- await this.#syncQueryPipelineSet(lc, cvr, "missing", void 0, driftedQueryIDs);
234
- this.#pipelinesSynced = true;
235
- this.connContextManager.setSharedRetransformReady(true);
288
+ const backgroundConnCtx = this.connContextManager.getBackgroundConnectionContext();
289
+ if (backgroundConnCtx) await this.#maybeHydratePipelines(lc, cvr, backgroundConnCtx);
290
+ else lc.info?.("No validated background connection; deferring pipeline init");
236
291
  });
237
292
  }
238
293
  if (this.#drainCoordinator.shouldDrain()) this.#drainCoordinator.drainNextIn(this.#totalHydrationTimeMs());
@@ -246,11 +301,57 @@ var ViewSyncerService = class {
246
301
  this.#stopped.resolve();
247
302
  }
248
303
  }
304
+ /**
305
+ * Ensures that query pipelines are hydrated and ready for steady-state
306
+ * operation.
307
+ *
308
+ * Preconditions:
309
+ * 1. Must be called from within the `#lock`.
310
+ * 2. The caller must provide an active, validated `ConnectionContext` for
311
+ * evaluating custom query transforms and permissions.
312
+ *
313
+ * Readiness prerequisites:
314
+ * - The underlying `QueryPipelineDriver` must be initialized with the schema
315
+ * (i.e. `this.#pipelines.initialized()` is true).
316
+ * - The SQLite replica must have caught up to at least the CVR's
317
+ * `stateVersion` (`version >= cvr.version.stateVersion`).
318
+ *
319
+ * If either prerequisite is not met, this method returns early as a no-op,
320
+ * waiting for the replica to catch up or the driver to initialize.
321
+ *
322
+ * Once ready:
323
+ * - Hydrates unchanged queries from the CVR snapshot without full row diffing.
324
+ * - Syncs missing, errored, or drifted queries via `#syncQueryPipelineSet`.
325
+ * - Sets `#pipelinesHydrated = true` and enables shared retransformations.
326
+ */
327
+ async #maybeHydratePipelines(lc, cvr, connCtx) {
328
+ if (!this.#pipelines.initialized()) return;
329
+ const version = this.#pipelines.advanceWithoutDiff();
330
+ const cvrVer = versionString(cvr.version);
331
+ if (version < cvr.version.stateVersion) {
332
+ lc.debug?.(`replica@${version} is behind cvr@${cvrVer}`);
333
+ return;
334
+ }
335
+ lc.info?.(`init pipelines@${version} (cvr@${cvrVer})`);
336
+ const hydrationBudget = new HydrationBudget(this.#config.viewSyncerHydrationBudgetMs ?? 0, this.#now);
337
+ const hydrationPassStats = {
338
+ activeHydratedQueries: 0,
339
+ inactiveHydratedQueries: 0
340
+ };
341
+ const driftedQueryIDs = await this.#hydrateUnchangedQueries(lc, cvr, connCtx, hydrationPassStats, hydrationBudget);
342
+ await this.#syncQueryPipelineSet(lc, cvr, "missing", connCtx, driftedQueryIDs, hydrationBudget, hydrationPassStats);
343
+ this.#pipelinesHydrated = true;
344
+ this.connContextManager.setSharedRetransformReady(true);
345
+ }
249
346
  #removeExpiredQueries = async (lc, cvr) => {
250
347
  if (hasExpiredQueries(cvr)) {
251
348
  lc = lc.withContext("method", "#removeExpiredQueries");
252
349
  lc.debug?.("Queries have expired");
253
- if (this.#pipelinesSynced) await this.#syncQueryPipelineSet(lc, cvr, "missing", void 0);
350
+ if (this.#pipelinesHydrated) {
351
+ const connCtx = this.connContextManager.getBackgroundConnectionContext();
352
+ if (connCtx) await this.#syncQueryPipelineSet(lc, cvr, "missing", connCtx);
353
+ else lc.info?.("No validated background connection to remove expired queries; deferring");
354
+ }
254
355
  }
255
356
  this.#scheduleExpireEviction(lc, cvr);
256
357
  };
@@ -466,7 +567,7 @@ var ViewSyncerService = class {
466
567
  }
467
568
  lc.debug?.("Auth changed, re-validating and re-transforming queries");
468
569
  const connCtx = this.connContextManager.mustGetConnectionContext(selector);
469
- if (!this.#pipelinesSynced) {
570
+ if (!this.#pipelinesHydrated) {
470
571
  if (!await this.#validateConnection(connCtx)) return;
471
572
  }
472
573
  return await this.#handleConfigUpdate(lc, clientID, {}, cvr, "all", void 0, connCtx);
@@ -528,7 +629,8 @@ var ViewSyncerService = class {
528
629
  await pokers.end(newCVR.version);
529
630
  });
530
631
  }
531
- if (this.#pipelinesSynced) await this.#syncQueryPipelineSet(lc, this.#cvr, customQueryTransformMode, connCtx);
632
+ if (!this.#pipelinesHydrated) await this.#maybeHydratePipelines(lc, this.#cvr, connCtx);
633
+ else await this.#syncQueryPipelineSet(lc, this.#cvr, customQueryTransformMode, connCtx);
532
634
  return this.#cvr;
533
635
  }
534
636
  /**
@@ -651,7 +753,7 @@ var ViewSyncerService = class {
651
753
  *
652
754
  * This must be called from within the #lock.
653
755
  */
654
- async #hydrateUnchangedQueries(lc, cvr) {
756
+ async #hydrateUnchangedQueries(lc, cvr, connCtx, hydrationPassStats, hydrationBudget) {
655
757
  assert(this.#pipelines.initialized(), "pipelines must be initialized");
656
758
  const dbVersion = this.#pipelines.currentVersion();
657
759
  const cvrVersion = cvr.version;
@@ -660,33 +762,27 @@ var ViewSyncerService = class {
660
762
  return /* @__PURE__ */ new Set();
661
763
  }
662
764
  const gotQueries = Object.entries(cvr.queries).filter(([_, state]) => state.transformationHash !== void 0);
765
+ const { required: requiredGotQueries } = classifyQueriesForHydration(gotQueries.map(([, query]) => query));
663
766
  const customQueries = /* @__PURE__ */ new Map();
664
767
  const otherQueries = [];
665
- let inactivatedCount = 0;
666
- for (const [, query] of gotQueries) {
667
- if (query.type !== "internal" && Object.values(query.clientState).every(({ inactivatedAt }) => inactivatedAt !== void 0)) {
668
- inactivatedCount++;
669
- continue;
670
- }
671
- if (query.type === "custom") customQueries.set(query.id, query);
672
- else otherQueries.push(query);
673
- }
768
+ const inactivatedCount = gotQueries.length - requiredGotQueries.length;
769
+ for (const query of requiredGotQueries) if (query.type === "custom") customQueries.set(query.id, query);
770
+ else otherQueries.push(query);
674
771
  const transformedQueries = [];
675
772
  let customErrorCount = 0;
676
773
  let customHashMismatchCount = 0;
677
774
  let otherHashMismatchCount = 0;
678
775
  if (customQueries.size > 0 && !this.#customQueryTransformer) lc.warn?.("Custom/named queries were requested but no `ZERO_QUERY_URL` is configured for Zero Cache.");
679
- const backgroundContext = this.connContextManager.mustGetBackgroundConnectionContext();
680
776
  const customQueryTransformer = this.#customQueryTransformer;
681
777
  if (customQueryTransformer && customQueries.size > 0) {
682
- const transformedCustomQueries = await this.#runPriorityOp(lc, "#hydrateUnchangedQueries transforming custom queries", () => customQueryTransformer.transform(backgroundContext, customQueries.values()));
683
- if (transformedCustomQueries.kind === "success" && !transformedCustomQueries.cached) this.connContextManager.validateConnection(backgroundContext, backgroundContext.revision, transformedCustomQueries.validation);
778
+ const transformedCustomQueries = await hydrationBudget.excluding(() => this.#runPriorityOp(lc, "#hydrateUnchangedQueries transforming custom queries", () => customQueryTransformer.transform(connCtx, customQueries.values())));
779
+ if (transformedCustomQueries.kind === "success" && !transformedCustomQueries.cached) this.connContextManager.validateConnection(connCtx, connCtx.revision, transformedCustomQueries.validation);
684
780
  if (Array.isArray(transformedCustomQueries.result)) for (const q of transformedCustomQueries.result) if ("error" in q) customErrorCount++;
685
781
  else if (q.transformationHash !== customQueries.get(q.id)?.transformationHash) customHashMismatchCount++;
686
782
  else transformedQueries.push(q);
687
783
  }
688
784
  for (const q of otherQueries) {
689
- const transformed = transformAndHashQuery(lc, q.id, q.ast, must(this.#pipelines.currentPermissions()).permissions ?? { tables: {} }, backgroundContext.auth?.type === "jwt" ? backgroundContext.auth : void 0, q.type === "internal");
785
+ const transformed = transformAndHashQuery(lc, q.id, q.ast, must(this.#pipelines.currentPermissions()).permissions ?? { tables: {} }, connCtx.auth?.type === "jwt" ? connCtx.auth : void 0, q.type === "internal");
690
786
  if (transformed.transformationHash === q.transformationHash) transformedQueries.push(transformed);
691
787
  else otherHashMismatchCount++;
692
788
  }
@@ -716,6 +812,7 @@ var ViewSyncerService = class {
716
812
  else count++;
717
813
  });
718
814
  const elapsed = timer.totalElapsed();
815
+ hydrationPassStats.activeHydratedQueries++;
719
816
  this.#hydrations.add(1);
720
817
  this.#hydrationTime.recordMs(elapsed);
721
818
  this.#addQueryMaterializationServerMetric(transformationHash, elapsed);
@@ -820,7 +917,10 @@ var ViewSyncerService = class {
820
917
  *
821
918
  * This must be called from within the #lock.
822
919
  */
823
- #syncQueryPipelineSet(lc, cvr, customQueryTransformMode, connCtx, driftedQueryIDs = /* @__PURE__ */ new Set()) {
920
+ #syncQueryPipelineSet(lc, cvr, customQueryTransformMode, connCtx, driftedQueryIDs = /* @__PURE__ */ new Set(), hydrationBudget = new HydrationBudget(this.#config.viewSyncerHydrationBudgetMs ?? 0, this.#now), hydrationPassStats = {
921
+ activeHydratedQueries: 0,
922
+ inactiveHydratedQueries: 0
923
+ }) {
824
924
  return startAsyncSpan(tracer, "vs.#syncQueryPipelineSet", async (span) => {
825
925
  const start = performance.now();
826
926
  span.setAttribute("clientGroupID", this.id);
@@ -829,38 +929,39 @@ var ViewSyncerService = class {
829
929
  const now = Date.now();
830
930
  const ttlClock = this.#getTTLClock(now);
831
931
  const cvrQueryEntires = Object.entries(cvr.queries);
832
- const customQueries = /* @__PURE__ */ new Map();
833
- const otherQueries = [];
834
932
  const transformedQueries = [];
835
- const resolvedConnCtx = connCtx ?? this.connContextManager.mustGetBackgroundConnectionContext();
836
- for (const [id, query] of cvrQueryEntires) if (query.type === "custom") {
837
- assert(id === query.id, "custom query id mismatch");
838
- customQueries.set(id, query);
839
- } else otherQueries.push({
840
- id,
841
- query
842
- });
843
- for (const { id, query: origQuery } of otherQueries) {
844
- assert(id === origQuery.id, "query id mismatch");
845
- const transformed = transformAndHashQuery(lc, origQuery.id, origQuery.ast, must(this.#pipelines.currentPermissions()).permissions ?? { tables: {} }, resolvedConnCtx.auth?.type === "jwt" ? resolvedConnCtx.auth : void 0, origQuery.type === "internal");
846
- transformedQueries.push({
847
- id,
848
- origQuery,
849
- transformed
850
- });
851
- }
852
- if (customQueries.size > 0 && !this.#customQueryTransformer) lc.warn?.("Custom/named queries were requested but no `ZERO_QUERY_URL` is configured for Zero Cache.");
853
- let erroredQueryIDs;
854
- const customQueriesToTransform = customQueryTransformMode === "all" ? [...customQueries.values()] : customQueryTransformMode && [...customQueries.values()].filter((q) => !this.#pipelines.queries().has(q.id));
933
+ const naturallyExpiredQueryIDs = new Set(cvrQueryEntires.map(([, query]) => query).filter((query) => expired(ttlClock, query)).map((query) => query.id));
934
+ if (naturallyExpiredQueryIDs.size > 0) this.#queryEvictions.add(naturallyExpiredQueryIDs.size, { reason: "ttl" });
935
+ const hydrationCandidates = cvrQueryEntires.map(([, query]) => query).filter((query) => !naturallyExpiredQueryIDs.has(query.id));
936
+ const { required, optional } = classifyQueriesForHydration(hydrationCandidates);
937
+ const requiredQueryIDs = new Set(required.map((query) => query.id));
938
+ const optionalToHydrate = hydrationBudget.limitMs === 0 ? optional : optional.filter((query) => query.patchVersion !== void 0);
939
+ if (!this.#customQueryTransformer && hydrationCandidates.some((query) => query.type === "custom")) lc.warn?.("Custom/named queries were requested but no `ZERO_QUERY_URL` is configured for Zero Cache.");
855
940
  const customQueryTransformer = this.#customQueryTransformer;
856
- if (customQueryTransformer && customQueriesToTransform.length > 0) {
941
+ const erroredQueryIDs = [];
942
+ let transformedCustomQueryCount = 0;
943
+ const transformOtherQueries = (queries) => {
944
+ for (const origQuery of queries) {
945
+ if (origQuery.type === "custom") continue;
946
+ const transformed = transformAndHashQuery(lc, origQuery.id, origQuery.ast, must(this.#pipelines.currentPermissions()).permissions ?? { tables: {} }, connCtx.auth?.type === "jwt" ? connCtx.auth : void 0, origQuery.type === "internal");
947
+ transformedQueries.push({
948
+ id: origQuery.id,
949
+ origQuery,
950
+ transformed
951
+ });
952
+ }
953
+ };
954
+ const shouldTransformCustomQuery = (query) => customQueryTransformMode === "all" || customQueryTransformMode && !this.#pipelines.queries().has(query.id);
955
+ const transformCustomQueries = async (queries) => {
956
+ if (!customQueryTransformer || queries.length === 0) return;
957
+ transformedCustomQueryCount += queries.length;
857
958
  const transformStart = performance.now();
858
959
  let transformedCustomQueries;
859
960
  try {
860
- transformedCustomQueries = await this.#runPriorityOp(lc, "#syncQueryPipelineSet transforming custom queries", () => customQueryTransformer.transform(resolvedConnCtx, customQueriesToTransform));
961
+ transformedCustomQueries = await hydrationBudget.excluding(() => this.#runPriorityOp(lc, "#syncQueryPipelineSet transforming custom queries", () => customQueryTransformer.transform(connCtx, queries)));
861
962
  if (transformedCustomQueries.kind === "failed") throw new ProtocolErrorWithLevel(transformedCustomQueries.result, "warn");
862
963
  else {
863
- if (!transformedCustomQueries.cached) this.connContextManager.validateConnection(resolvedConnCtx, resolvedConnCtx.revision, transformedCustomQueries.validation);
964
+ if (!transformedCustomQueries.cached) this.connContextManager.validateConnection(connCtx, connCtx.revision, transformedCustomQueries.validation);
864
965
  this.#queryTransformations.add(1, { result: "success" });
865
966
  }
866
967
  } catch (e) {
@@ -871,8 +972,9 @@ var ViewSyncerService = class {
871
972
  this.#queryTransformationTime.recordMs(transformDuration);
872
973
  }
873
974
  const successfullyTransformedCustomQueries = /* @__PURE__ */ new Map();
874
- erroredQueryIDs = this.#processTransformedCustomQueries(lc, transformedCustomQueries.result, (q) => {
875
- const origQuery = customQueries.get(q.id);
975
+ const customQueryMap = new Map(queries.map((query) => [query.id, query]));
976
+ erroredQueryIDs.push(...this.#processTransformedCustomQueries(lc, transformedCustomQueries.result, (q) => {
977
+ const origQuery = customQueryMap.get(q.id);
876
978
  if (origQuery) {
877
979
  successfullyTransformedCustomQueries.set(q.id, q);
878
980
  transformedQueries.push({
@@ -881,7 +983,7 @@ var ViewSyncerService = class {
881
983
  transformed: q
882
984
  });
883
985
  }
884
- }, customQueries);
986
+ }, customQueryMap));
885
987
  for (const [queryID, newTransform] of successfullyTransformedCustomQueries) {
886
988
  const existingTransformHash = cvr.queries[queryID]?.transformationHash;
887
989
  if (existingTransformHash) {
@@ -894,21 +996,28 @@ var ViewSyncerService = class {
894
996
  } else this.#queryTransformationNoOps.add(1);
895
997
  }
896
998
  }
897
- }
898
- const removeQueriesQueryIds = new Set([...Object.values(cvr.queries).filter((q) => expired(ttlClock, q)).map((q) => q.id), ...erroredQueryIDs || []]);
999
+ };
1000
+ const queriesToTransform = [...required, ...optionalToHydrate];
1001
+ transformOtherQueries(queriesToTransform);
1002
+ await transformCustomQueries(queriesToTransform.filter((query) => query.type === "custom" && shouldTransformCustomQuery(query)));
1003
+ const removeQueriesQueryIds = new Set([...naturallyExpiredQueryIDs, ...erroredQueryIDs]);
899
1004
  const addQueries = transformedQueries.map(({ id, origQuery, transformed }) => ({
900
1005
  id,
901
1006
  ast: transformed.transformedAst,
902
1007
  transformationHash: transformed.transformationHash,
903
1008
  name: origQuery.type === "custom" ? origQuery.name : void 0
904
1009
  })).filter((q) => !removeQueriesQueryIds.has(q.id) && this.#pipelines.queries().get(q.id)?.transformationHash !== q.transformationHash);
905
- lc.info?.(`syncQueryPipelineSet: ${cvrQueryEntires.length} CVR queries, ${customQueriesToTransform.length} custom re-transformed, ${erroredQueryIDs?.length ?? 0} errored, ${removeQueriesQueryIds.size} to remove, ${addQueries.length} to add`);
1010
+ const hydrationOrder = new Map([...required, ...optional].map((query, index) => [query.id, index]));
1011
+ addQueries.sort((a, b) => must(hydrationOrder.get(a.id)) - must(hydrationOrder.get(b.id)));
1012
+ const requiredAddQueries = addQueries.filter((query) => requiredQueryIDs.has(query.id));
1013
+ const optionalAddQueries = addQueries.filter((query) => !requiredQueryIDs.has(query.id));
1014
+ lc.info?.(`syncQueryPipelineSet: ${cvrQueryEntires.length} CVR queries, ${transformedCustomQueryCount} custom re-transformed, ${erroredQueryIDs.length} errored, ${removeQueriesQueryIds.size} to remove, ${addQueries.length} to add`);
906
1015
  for (const q of addQueries) {
907
1016
  const orig = cvr.queries[q.id];
908
1017
  lc.debug?.("ViewSyncer adding query", q.ast, "transformed from", orig.type === "custom" ? orig.name : orig.ast);
909
1018
  }
910
1019
  if (addQueries.length > 0 || removeQueriesQueryIds.size > 0) {
911
- await this.#addAndRemoveQueries(lc, cvr, addQueries, Array.from(removeQueriesQueryIds, (id) => ({ id })), driftedQueryIDs);
1020
+ await this.#addAndRemoveQueries(lc, cvr, requiredAddQueries, optionalAddQueries, Array.from(removeQueriesQueryIds, (id) => ({ id })), hydrationBudget, hydrationPassStats, driftedQueryIDs);
912
1021
  if (addQueries.length > 0) this.#viewSyncerHydration.recordMs(performance.now() - start);
913
1022
  } else await this.#catchupClients(lc, cvr);
914
1023
  });
@@ -941,8 +1050,27 @@ var ViewSyncerService = class {
941
1050
  record.count++;
942
1051
  if (record.count >= THRASH_THRESHOLD) this.#lc.warn?.(`Query thrashing detected for query ${queryID}. ${record.count} replacements in 60s. This may indicate clients with different auth contexts connecting to the same client group.`);
943
1052
  }
944
- #addAndRemoveQueries(lc, cvr, addQueries, removeQueries, driftedQueryIDs = /* @__PURE__ */ new Set()) {
1053
+ #recordHydrationBudgetExhaustion(lc, hydrationBudget, activeHydratedQueries, inactiveHydratedQueries, inactiveEvictedQueryIDs) {
1054
+ const elapsedMs = hydrationBudget.exhaustedAtMs;
1055
+ if (elapsedMs === void 0) return;
1056
+ this.#hydrationBudgetExhaustions.add(1);
1057
+ this.#hydrationBudgetEvictions.add(inactiveEvictedQueryIDs.length);
1058
+ this.#hydrationBudgetElapsed.record(elapsedMs);
1059
+ this.#hydrationBudgetOvershoot.record(Math.max(0, elapsedMs - hydrationBudget.limitMs));
1060
+ if (inactiveEvictedQueryIDs.length > 0) this.#queryEvictions.add(inactiveEvictedQueryIDs.length, { reason: "hydration-budget" });
1061
+ lc.info?.("view-syncer hydration budget exhausted", {
1062
+ clientGroupID: this.id,
1063
+ hydrationBudgetMs: hydrationBudget.limitMs,
1064
+ hydrationElapsedMs: elapsedMs,
1065
+ activeHydratedQueries,
1066
+ inactiveHydratedQueries,
1067
+ inactiveEvictedQueries: inactiveEvictedQueryIDs.length,
1068
+ firstEvictedQueryHash: inactiveEvictedQueryIDs[0] ?? null
1069
+ });
1070
+ }
1071
+ #addAndRemoveQueries(lc, cvr, requiredQueries, optionalQueries, removeQueries, hydrationBudget, hydrationPassStats, driftedQueryIDs = /* @__PURE__ */ new Set()) {
945
1072
  return startAsyncSpan(tracer, "vs.#addAndRemoveQueries", async () => {
1073
+ const addQueries = [...requiredQueries, ...optionalQueries];
946
1074
  assert(addQueries.length > 0 || removeQueries.length > 0, "Must have queries to add or remove");
947
1075
  const start = performance.now();
948
1076
  const stateVersion = this.#pipelines.currentVersion();
@@ -959,6 +1087,7 @@ var ViewSyncerService = class {
959
1087
  updater.ensureNewVersion();
960
1088
  }
961
1089
  const { queryPatches, newVersion } = updater.trackQueries(lc, addQueries, removeQueries);
1090
+ if (hydrationBudget.limitMs !== 0 && optionalQueries.length > 0) assert(cmpVersions(cvr.version, newVersion) < 0, "Optional hydration requires a final poke version before row processing so a removal is actually poked");
962
1091
  const pokers = startPoke(this.#getClients(), newVersion);
963
1092
  for (const patch of queryPatches) await pokers.addPatch(patch);
964
1093
  for (const q of removeQueries) {
@@ -975,10 +1104,17 @@ var ViewSyncerService = class {
975
1104
  let totalHydratedQueries = 0;
976
1105
  let coveredHydratedQueries = 0;
977
1106
  let firstCoveredQuery;
1107
+ const hydratedQueryIDs = [];
1108
+ const budgetEvictedQueryIDs = [];
978
1109
  const self = this;
979
1110
  await yieldProcess(lc);
980
- function* generateRowChanges(slowHydrateThreshold) {
981
- for (const q of addQueries) {
1111
+ function* hydrateQueries(queries, optional, slowHydrateThreshold) {
1112
+ for (let i = 0; i < queries.length; i++) {
1113
+ if (optional && hydrationBudget.exhausted()) {
1114
+ budgetEvictedQueryIDs.push(...queries.slice(i).map((query) => query.id));
1115
+ return;
1116
+ }
1117
+ const q = must(queries[i]);
982
1118
  let queryLC = lc.withContext("hash", q.id).withContext("queryHash", q.id).withContext("transformationHash", q.transformationHash);
983
1119
  if (q.name !== void 0) queryLC = queryLC.withContext("queryName", q.name);
984
1120
  queryLC.debug?.(`adding pipeline for query`, q.ast);
@@ -991,6 +1127,9 @@ var ViewSyncerService = class {
991
1127
  yield* pipelines.addQuery(q.transformationHash, q.id, q.ast, timer.startWithoutYielding(), q.name, "query-set-sync");
992
1128
  const elapsed = timer.stop();
993
1129
  totalProcessTime += elapsed;
1130
+ hydratedQueryIDs.push(q.id);
1131
+ if (optional) hydrationPassStats.inactiveHydratedQueries++;
1132
+ else hydrationPassStats.activeHydratedQueries++;
994
1133
  self.#addQueryMaterializationServerMetric(q.id, elapsed);
995
1134
  self.#inspectorDelegate.addQuery(q.id, q.ast);
996
1135
  queryCoveringIndex?.add(q.id, {
@@ -1004,18 +1143,30 @@ var ViewSyncerService = class {
1004
1143
  transformationHash: q.transformationHash,
1005
1144
  ...q.name !== void 0 && { name: q.name }
1006
1145
  });
1146
+ hydrations.add(1);
1147
+ hydrationTime.recordMs(elapsed);
1007
1148
  }
1008
- hydrations.add(1);
1009
- hydrationTime.recordMs(totalProcessTime);
1149
+ }
1150
+ function* generateRowChanges(slowHydrateThreshold) {
1151
+ yield* hydrateQueries(requiredQueries, false, slowHydrateThreshold);
1152
+ yield* hydrateQueries(optionalQueries, true, slowHydrateThreshold);
1010
1153
  }
1011
1154
  await this.#processChanges(lc, timer, generateRowChanges(this.#slowHydrateThreshold), updater, pokers);
1155
+ for (const patch of updater.removeTrackedQueries(budgetEvictedQueryIDs)) await pokers.addPatch(patch);
1156
+ for (const queryID of budgetEvictedQueryIDs) {
1157
+ this.#pipelines.removeQuery(queryID);
1158
+ this.#inspectorDelegate.removeQuery(queryID);
1159
+ this.#queryReplacements.delete(queryID);
1160
+ }
1161
+ this.#recordHydrationBudgetExhaustion(lc, hydrationBudget, hydrationPassStats.activeHydratedQueries, hydrationPassStats.inactiveHydratedQueries, budgetEvictedQueryIDs);
1012
1162
  this.#logQueryCoverageShadowSummary(lc, "add", totalHydratedQueries, coveredHydratedQueries, firstCoveredQuery);
1013
1163
  await startAsyncSpan(tracer, "vs.#syncQueryPipelineSet.deleteUnreferencedRows", async () => {
1014
1164
  for (const patch of await updater.deleteUnreferencedRows(lc)) await pokers.addPatch(patch);
1015
1165
  });
1016
1166
  this.#cvr = await this.#flushUpdater(lc, updater);
1167
+ if (budgetEvictedQueryIDs.length > 0) this.#scheduleExpireEviction(lc, this.#cvr);
1017
1168
  const finalVersion = this.#cvr.version;
1018
- await this.#catchupClients(lc, cvr, finalVersion, addQueries.map((q) => q.id), pokers);
1169
+ await this.#catchupClients(lc, cvr, finalVersion, hydratedQueryIDs, pokers);
1019
1170
  await startAsyncSpan(tracer, "vs.#syncQueryPipelineSet.pokeEnd", () => pokers.end(finalVersion));
1020
1171
  this.#markVersionServed(stateVersion);
1021
1172
  const wallTime = performance.now() - start;
@@ -1156,7 +1307,9 @@ var ViewSyncerService = class {
1156
1307
  *
1157
1308
  * Must be called from within the #lock.
1158
1309
  *
1159
- * Returns false if the advancement failed due to a schema change.
1310
+ * Returns 'success' if changes were successfully processed and poked,
1311
+ * or a `ResetPipelinesSignal` if advancement aborted (e.g. schema change,
1312
+ * timeout) and pipelines need to be reset and rehydrated.
1160
1313
  */
1161
1314
  #advancePipelines(lc, cvr) {
1162
1315
  return startAsyncSpan(tracer, "vs.#advancePipelines", async (span) => {
@@ -1329,8 +1482,10 @@ function isTransformFailedError(error) {
1329
1482
  return error.errorBody.kind === "TransformFailed" && !isAuthErrorBody(error.errorBody);
1330
1483
  }
1331
1484
  /**
1332
- * A query must be expired for all clients in order to be considered
1333
- * expired.
1485
+ * Whether a query's TTL has elapsed. A query must be expired for all clients
1486
+ * in order to be considered expired.
1487
+ *
1488
+ * Exported for testing.
1334
1489
  */
1335
1490
  function expired(ttlClock, q) {
1336
1491
  if (q.type === "internal") return false;