@prisma/streams-server 0.1.1 → 0.1.3

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 (91) hide show
  1. package/CONTRIBUTING.md +8 -0
  2. package/package.json +2 -1
  3. package/src/app.ts +290 -17
  4. package/src/app_core.ts +1833 -698
  5. package/src/app_local.ts +144 -4
  6. package/src/auto_tune.ts +62 -0
  7. package/src/bootstrap.ts +159 -1
  8. package/src/concurrency_gate.ts +108 -0
  9. package/src/config.ts +116 -14
  10. package/src/db/db.ts +1201 -131
  11. package/src/db/schema.ts +308 -8
  12. package/src/foreground_activity.ts +55 -0
  13. package/src/index/indexer.ts +254 -124
  14. package/src/index/lexicon_file_cache.ts +261 -0
  15. package/src/index/lexicon_format.ts +93 -0
  16. package/src/index/lexicon_indexer.ts +789 -0
  17. package/src/index/secondary_indexer.ts +824 -0
  18. package/src/index/secondary_schema.ts +105 -0
  19. package/src/ingest.ts +10 -12
  20. package/src/manifest.ts +143 -8
  21. package/src/memory.ts +183 -8
  22. package/src/metrics.ts +15 -29
  23. package/src/metrics_emitter.ts +26 -3
  24. package/src/notifier.ts +121 -5
  25. package/src/objectstore/accounting.ts +92 -0
  26. package/src/objectstore/mock_r2.ts +1 -1
  27. package/src/objectstore/r2.ts +17 -1
  28. package/src/profiles/evlog/schema.ts +234 -0
  29. package/src/profiles/evlog.ts +299 -0
  30. package/src/profiles/generic.ts +47 -0
  31. package/src/profiles/index.ts +205 -0
  32. package/src/profiles/metrics/block_format.ts +109 -0
  33. package/src/profiles/metrics/normalize.ts +366 -0
  34. package/src/profiles/metrics/schema.ts +319 -0
  35. package/src/profiles/metrics.ts +85 -0
  36. package/src/profiles/profile.ts +225 -0
  37. package/src/{touch/engine.ts → profiles/stateProtocol/changes.ts} +3 -20
  38. package/src/profiles/stateProtocol/routes.ts +389 -0
  39. package/src/profiles/stateProtocol/types.ts +6 -0
  40. package/src/profiles/stateProtocol/validation.ts +51 -0
  41. package/src/profiles/stateProtocol.ts +100 -0
  42. package/src/read_filter.ts +468 -0
  43. package/src/reader.ts +2151 -164
  44. package/src/runtime/host_runtime.ts +5 -0
  45. package/src/runtime_memory.ts +200 -0
  46. package/src/runtime_memory_sampler.ts +235 -0
  47. package/src/schema/read_json.ts +43 -0
  48. package/src/schema/registry.ts +563 -59
  49. package/src/search/agg_format.ts +638 -0
  50. package/src/search/aggregate.ts +389 -0
  51. package/src/search/binary/codec.ts +162 -0
  52. package/src/search/binary/docset.ts +67 -0
  53. package/src/search/binary/restart_strings.ts +181 -0
  54. package/src/search/binary/varint.ts +34 -0
  55. package/src/search/bitset.ts +19 -0
  56. package/src/search/col_format.ts +382 -0
  57. package/src/search/col_runtime.ts +59 -0
  58. package/src/search/column_encoding.ts +43 -0
  59. package/src/search/companion_file_cache.ts +319 -0
  60. package/src/search/companion_format.ts +313 -0
  61. package/src/search/companion_manager.ts +1086 -0
  62. package/src/search/companion_plan.ts +218 -0
  63. package/src/search/fts_format.ts +423 -0
  64. package/src/search/fts_runtime.ts +333 -0
  65. package/src/search/query.ts +875 -0
  66. package/src/search/schema.ts +245 -0
  67. package/src/segment/cache.ts +93 -2
  68. package/src/segment/cached_segment.ts +89 -0
  69. package/src/segment/format.ts +108 -36
  70. package/src/segment/segmenter.ts +79 -5
  71. package/src/segment/segmenter_worker.ts +35 -6
  72. package/src/segment/segmenter_workers.ts +42 -12
  73. package/src/server.ts +150 -36
  74. package/src/sqlite/adapter.ts +185 -14
  75. package/src/sqlite/runtime_stats.ts +163 -0
  76. package/src/stats.ts +3 -3
  77. package/src/stream_size_reconciler.ts +100 -0
  78. package/src/touch/canonical_change.ts +7 -0
  79. package/src/touch/live_metrics.ts +94 -64
  80. package/src/touch/live_templates.ts +15 -1
  81. package/src/touch/manager.ts +166 -88
  82. package/src/touch/{interpreter_worker.ts → processor_worker.ts} +19 -14
  83. package/src/touch/spec.ts +95 -92
  84. package/src/touch/touch_journal.ts +4 -0
  85. package/src/touch/worker_pool.ts +8 -14
  86. package/src/touch/worker_protocol.ts +3 -3
  87. package/src/uploader.ts +77 -6
  88. package/src/util/bloom256.ts +2 -2
  89. package/src/util/byte_lru.ts +73 -0
  90. package/src/util/lru.ts +8 -0
  91. package/src/util/stream_paths.ts +19 -0
@@ -0,0 +1,1086 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { Result } from "better-result";
3
+ import type { Config } from "../config";
4
+ import type {
5
+ SearchCompanionPlanRow,
6
+ SearchSegmentCompanionRow,
7
+ SegmentRow,
8
+ SqliteDurableStore,
9
+ } from "../db/db";
10
+ import type { Metrics } from "../metrics";
11
+ import type { ObjectStore } from "../objectstore/interface";
12
+ import { SchemaRegistryStore, type SchemaRegistry, type SearchFieldConfig } from "../schema/registry";
13
+ import { SegmentDiskCache } from "../segment/cache";
14
+ import { loadSegmentBytesCached } from "../segment/cached_segment";
15
+ import { iterateBlockRecordsResult } from "../segment/format";
16
+ import { dsError } from "../util/ds_error.ts";
17
+ import { RuntimeMemorySampler } from "../runtime_memory_sampler";
18
+ import { ConcurrencyGate } from "../concurrency_gate";
19
+ import type { ForegroundActivityTracker } from "../foreground_activity";
20
+ import { retry } from "../util/retry";
21
+ import { yieldToEventLoop } from "../util/yield";
22
+ import { searchCompanionObjectKey, streamHash16Hex } from "../util/stream_paths";
23
+ import { buildDesiredSearchCompanionPlan, hashSearchCompanionPlan, type SearchCompanionPlan } from "./companion_plan";
24
+ import {
25
+ PSCIX2_MAX_TOC_BYTES,
26
+ decodeCompanionSectionPayloadResult,
27
+ decodeBundledSegmentCompanionResult,
28
+ decodeBundledSegmentCompanionTocResult,
29
+ encodeBundledSegmentCompanionFromPayloads,
30
+ encodeCompanionSectionPayload,
31
+ type BundledSegmentCompanion,
32
+ type CompanionSectionKind,
33
+ type CompanionSectionInputMap,
34
+ type CompanionSectionMap,
35
+ type CompanionToc,
36
+ type EncodedCompanionSectionPayload,
37
+ } from "./companion_format";
38
+ import { CompanionFileCache } from "./companion_file_cache";
39
+ import type { ColFieldInput, ColScalar, ColSectionInput, ColSectionView } from "./col_format";
40
+ import {
41
+ analyzeTextValue,
42
+ canonicalizeColumnValue,
43
+ extractRawSearchValuesForFieldsResult,
44
+ normalizeKeywordValue,
45
+ } from "./schema";
46
+ import type { FtsFieldInput, FtsSectionInput, FtsSectionView, FtsTermInput } from "./fts_format";
47
+ import { buildMetricsBlockRecord } from "../profiles/metrics/normalize";
48
+ import type { MetricsBlockSectionInput, MetricsBlockSectionView } from "../profiles/metrics/block_format";
49
+ import { parseDurationMsResult } from "../util/duration";
50
+ import {
51
+ cloneAggMeasureState,
52
+ extractRollupContributionResult,
53
+ mergeAggMeasureState,
54
+ rollupRequiredFieldNames,
55
+ } from "./aggregate";
56
+ import type { AggMeasureState, AggSectionInput, AggWindowGroup, AggSectionView } from "./agg_format";
57
+ import type { SearchRollupConfig } from "../schema/registry";
58
+ import type { CompanionSectionLookupStats } from "../index/indexer";
59
+
60
+ type CompanionBuildError = { kind: "invalid_companion_build"; message: string };
61
+
62
+ function invalidCompanionBuild<T = never>(message: string): Result<T, CompanionBuildError> {
63
+ return Result.err({ kind: "invalid_companion_build", message });
64
+ }
65
+
66
+ type ColumnFieldBuilder = {
67
+ config: SearchFieldConfig;
68
+ kind: ColFieldInput["kind"];
69
+ docIds: number[];
70
+ values: ColScalar[];
71
+ invalid: boolean;
72
+ };
73
+
74
+ type FtsFieldBuilder = {
75
+ config: SearchFieldConfig;
76
+ companion: FtsFieldInput;
77
+ };
78
+
79
+ type GroupBuilder = {
80
+ key: string;
81
+ measures: Record<string, AggMeasureState>;
82
+ };
83
+
84
+ type MetricsBlockBuilder = {
85
+ records: MetricsBlockSectionInput["records"];
86
+ minWindowStartMs: number | undefined;
87
+ maxWindowEndMs: number | undefined;
88
+ };
89
+
90
+ type AggRollupBuilder = {
91
+ rollup: SearchRollupConfig;
92
+ intervalsMs: number[];
93
+ intervalMap: Map<number, Map<number, Map<string, GroupBuilder>>>;
94
+ dimensionNames: string[];
95
+ fieldNames: string[];
96
+ };
97
+
98
+ type CompanionBuildProgress = {
99
+ docCount: number;
100
+ colFields: number;
101
+ colValues: number;
102
+ ftsFields: number;
103
+ ftsTerms: number;
104
+ ftsPostings: number;
105
+ ftsPositions: number;
106
+ aggRollups: number;
107
+ aggWindows: number;
108
+ aggGroups: number;
109
+ metricRecords: number;
110
+ };
111
+
112
+ const PAYLOAD_DECODER = new TextDecoder();
113
+
114
+ function compareValues(left: bigint | number | boolean, right: bigint | number | boolean): number {
115
+ if (typeof left === "bigint" && typeof right === "bigint") return left < right ? -1 : left > right ? 1 : 0;
116
+ if (typeof left === "number" && typeof right === "number") return left < right ? -1 : left > right ? 1 : 0;
117
+ if (typeof left === "boolean" && typeof right === "boolean") return left === right ? 0 : left ? 1 : -1;
118
+ return String(left).localeCompare(String(right));
119
+ }
120
+
121
+ const AGG_DIMENSION_SEPARATOR = "\u001f";
122
+ const AGG_DIMENSION_NULL = "\u0000";
123
+
124
+ function encodeAggDimensionPart(value: string | null): string {
125
+ if (value == null) return AGG_DIMENSION_NULL;
126
+ return value.replaceAll(AGG_DIMENSION_SEPARATOR, `${AGG_DIMENSION_SEPARATOR}${AGG_DIMENSION_SEPARATOR}`);
127
+ }
128
+
129
+ function decodeAggDimensionPart(value: string): string | null {
130
+ if (value === AGG_DIMENSION_NULL) return null;
131
+ return value.replaceAll(`${AGG_DIMENSION_SEPARATOR}${AGG_DIMENSION_SEPARATOR}`, AGG_DIMENSION_SEPARATOR);
132
+ }
133
+
134
+ function encodeAggGroupKey(dimensions: Record<string, string | null>, dimensionNames: string[]): string {
135
+ return dimensionNames.map((name) => encodeAggDimensionPart(dimensions[name] ?? null)).join(AGG_DIMENSION_SEPARATOR);
136
+ }
137
+
138
+ function decodeAggGroupKey(groupKey: string, dimensionNames: string[]): Record<string, string | null> {
139
+ const parts: string[] = [];
140
+ let current = "";
141
+ for (let index = 0; index < groupKey.length; index++) {
142
+ const char = groupKey[index]!;
143
+ if (char !== AGG_DIMENSION_SEPARATOR) {
144
+ current += char;
145
+ continue;
146
+ }
147
+ const next = groupKey[index + 1];
148
+ if (next === AGG_DIMENSION_SEPARATOR) {
149
+ current += AGG_DIMENSION_SEPARATOR;
150
+ index += 1;
151
+ continue;
152
+ }
153
+ parts.push(current);
154
+ current = "";
155
+ }
156
+ parts.push(current);
157
+ const decoded: Record<string, string | null> = {};
158
+ for (let index = 0; index < dimensionNames.length; index++) {
159
+ decoded[dimensionNames[index]!] = decodeAggDimensionPart(parts[index] ?? AGG_DIMENSION_NULL);
160
+ }
161
+ return decoded;
162
+ }
163
+
164
+ function parseSectionKinds(row: SearchSegmentCompanionRow): Set<CompanionSectionKind> {
165
+ try {
166
+ const parsed = JSON.parse(row.sections_json);
167
+ if (!Array.isArray(parsed)) return new Set();
168
+ return new Set(
169
+ parsed.filter((value): value is CompanionSectionKind => value === "col" || value === "fts" || value === "agg" || value === "mblk")
170
+ );
171
+ } catch {
172
+ return new Set();
173
+ }
174
+ }
175
+
176
+ export class SearchCompanionManager {
177
+ private readonly queue = new Set<string>();
178
+ private readonly building = new Set<string>();
179
+ private readonly fileCache: CompanionFileCache;
180
+ private readonly segmentCache?: SegmentDiskCache;
181
+ private readonly yieldBlocks: number;
182
+ private readonly memorySampler?: RuntimeMemorySampler;
183
+ private readonly asyncGate: ConcurrencyGate;
184
+ private readonly foregroundActivity?: ForegroundActivityTracker;
185
+ private timer: any | null = null;
186
+ private running = false;
187
+
188
+ constructor(
189
+ private readonly cfg: Config,
190
+ private readonly db: SqliteDurableStore,
191
+ private readonly os: ObjectStore,
192
+ private readonly registry: SchemaRegistryStore,
193
+ segmentCache?: SegmentDiskCache,
194
+ private readonly publishManifest?: (stream: string) => Promise<void>,
195
+ private readonly onMetadataChanged?: (stream: string) => void,
196
+ private readonly metrics?: Metrics,
197
+ memorySampler?: RuntimeMemorySampler,
198
+ asyncGate?: ConcurrencyGate,
199
+ foregroundActivity?: ForegroundActivityTracker
200
+ ) {
201
+ this.yieldBlocks = Math.max(1, cfg.searchCompanionYieldBlocks);
202
+ this.segmentCache = segmentCache;
203
+ this.memorySampler = memorySampler;
204
+ this.asyncGate = asyncGate ?? new ConcurrencyGate(1);
205
+ this.foregroundActivity = foregroundActivity;
206
+ this.fileCache = new CompanionFileCache(
207
+ `${cfg.rootDir}/cache/companions`,
208
+ cfg.searchCompanionFileCacheMaxBytes,
209
+ cfg.searchCompanionFileCacheMaxAgeMs,
210
+ cfg.searchCompanionMappedCacheEntries
211
+ );
212
+ }
213
+
214
+ private async yieldBackgroundWork(): Promise<void> {
215
+ if (this.foregroundActivity) {
216
+ await this.foregroundActivity.yieldBackgroundWork();
217
+ return;
218
+ }
219
+ await yieldToEventLoop();
220
+ }
221
+
222
+ start(): void {
223
+ if (this.timer) return;
224
+ this.timer = setInterval(() => {
225
+ void this.tick();
226
+ }, this.cfg.indexCheckIntervalMs);
227
+ }
228
+
229
+ stop(): void {
230
+ if (this.timer) clearInterval(this.timer);
231
+ this.timer = null;
232
+ this.fileCache.clearMapped();
233
+ }
234
+
235
+ enqueue(stream: string): void {
236
+ this.queue.add(stream);
237
+ }
238
+
239
+ async getColSegmentCompanion(stream: string, segmentIndex: number): Promise<ColSectionView | null> {
240
+ return (await this.getSectionCompanion(stream, segmentIndex, "col")) ?? null;
241
+ }
242
+
243
+ async getFtsSegmentCompanion(stream: string, segmentIndex: number): Promise<FtsSectionView | null> {
244
+ return (await this.getFtsSegmentCompanionWithStats(stream, segmentIndex)).companion;
245
+ }
246
+
247
+ async getFtsSegmentCompanionWithStats(
248
+ stream: string,
249
+ segmentIndex: number
250
+ ): Promise<{ companion: FtsSectionView | null; stats: CompanionSectionLookupStats }> {
251
+ const result = await this.getSectionCompanionWithStats(stream, segmentIndex, "fts");
252
+ return { companion: result.companion ?? null, stats: result.stats };
253
+ }
254
+
255
+ async getAggSegmentCompanion(stream: string, segmentIndex: number): Promise<AggSectionView | null> {
256
+ return (await this.getSectionCompanion(stream, segmentIndex, "agg")) ?? null;
257
+ }
258
+
259
+ async getMetricsBlockSegmentCompanion(stream: string, segmentIndex: number): Promise<MetricsBlockSectionView | null> {
260
+ return (await this.getSectionCompanion(stream, segmentIndex, "mblk")) ?? null;
261
+ }
262
+
263
+ getLocalCacheBytes(stream: string): number {
264
+ return this.fileCache.bytesForObjectKeyPrefix(`streams/${streamHash16Hex(stream)}/segments/`);
265
+ }
266
+
267
+ getMemoryStats(): {
268
+ fileCacheBytes: number;
269
+ fileCacheEntries: number;
270
+ mappedFileBytes: number;
271
+ mappedFileEntries: number;
272
+ pinnedFileEntries: number;
273
+ } {
274
+ const stats = this.fileCache.stats();
275
+ return {
276
+ fileCacheBytes: stats.usedBytes,
277
+ fileCacheEntries: stats.entryCount,
278
+ mappedFileBytes: stats.mappedBytes,
279
+ mappedFileEntries: stats.mappedEntryCount,
280
+ pinnedFileEntries: stats.pinnedEntryCount,
281
+ };
282
+ }
283
+
284
+ private async getSectionCompanion<K extends CompanionSectionKind>(
285
+ stream: string,
286
+ segmentIndex: number,
287
+ kind: K
288
+ ): Promise<CompanionSectionMap[K] | null> {
289
+ return (await this.getSectionCompanionWithStats(stream, segmentIndex, kind)).companion;
290
+ }
291
+
292
+ private async getSectionCompanionWithStats<K extends CompanionSectionKind>(
293
+ stream: string,
294
+ segmentIndex: number,
295
+ kind: K
296
+ ): Promise<{ companion: CompanionSectionMap[K] | null; stats: CompanionSectionLookupStats }> {
297
+ const leave = this.memorySampler?.enter("companion_read", { stream, segment_index: segmentIndex, kind });
298
+ try {
299
+ let sectionGetMs = 0;
300
+ let decodeMs = 0;
301
+ const planRow = this.getCurrentPlanRow(stream);
302
+ if (!planRow) return { companion: null, stats: { sectionGetMs, decodeMs } };
303
+ const row = this.db.getSearchSegmentCompanion(stream, segmentIndex);
304
+ if (!row || row.plan_generation !== planRow.generation) return { companion: null, stats: { sectionGetMs, decodeMs } };
305
+ if (!parseSectionKinds(row).has(kind)) return { companion: null, stats: { sectionGetMs, decodeMs } };
306
+ const sectionStartedAt = Date.now();
307
+ const bundle = await this.loadBundleResult(row);
308
+ if (Result.isError(bundle)) throw dsError(bundle.error.message);
309
+ const plan = this.parsePlanRowResult(planRow);
310
+ if (Result.isError(plan)) throw dsError(plan.error.message);
311
+ const sectionBytes = this.sectionPayloadResult(bundle.value.bytes, bundle.value.toc, row.object_key, kind);
312
+ if (Result.isError(sectionBytes)) throw dsError(sectionBytes.error.message);
313
+ sectionGetMs = Date.now() - sectionStartedAt;
314
+ const decodeStartedAt = Date.now();
315
+ const decoded = decodeCompanionSectionPayloadResult(kind, sectionBytes.value, plan.value);
316
+ if (Result.isError(decoded)) throw dsError(decoded.error.message);
317
+ decodeMs = Date.now() - decodeStartedAt;
318
+ return { companion: decoded.value ?? null, stats: { sectionGetMs, decodeMs } };
319
+ } finally {
320
+ leave?.();
321
+ }
322
+ }
323
+
324
+ private getCurrentPlanRow(stream: string): SearchCompanionPlanRow | null {
325
+ const regRes = this.registry.getRegistryResult(stream);
326
+ if (Result.isError(regRes)) return null;
327
+ const desiredPlan = buildDesiredSearchCompanionPlan(regRes.value);
328
+ const desiredHash = hashSearchCompanionPlan(desiredPlan);
329
+ const current = this.db.getSearchCompanionPlan(stream);
330
+ if (current && current.plan_hash === desiredHash) return current;
331
+ return null;
332
+ }
333
+
334
+ private parsePlanRowResult(planRow: SearchCompanionPlanRow): Result<SearchCompanionPlan, CompanionBuildError> {
335
+ try {
336
+ const parsed = JSON.parse(planRow.plan_json) as SearchCompanionPlan;
337
+ if (!parsed || !parsed.families || !Array.isArray(parsed.fields) || !Array.isArray(parsed.rollups)) {
338
+ return invalidCompanionBuild("invalid bundled companion plan json");
339
+ }
340
+ return Result.ok(parsed);
341
+ } catch (e: unknown) {
342
+ return invalidCompanionBuild(String((e as any)?.message ?? e));
343
+ }
344
+ }
345
+
346
+ private async loadBundleResult(
347
+ row: SearchSegmentCompanionRow
348
+ ): Promise<Result<{ bytes: Uint8Array; toc: CompanionToc }, CompanionBuildError>> {
349
+ if (row.size_bytes <= 0) return invalidCompanionBuild(`invalid .cix size for ${row.object_key}`);
350
+ const bundleRes = await this.fileCache.loadMappedBundleResult({
351
+ objectKey: row.object_key,
352
+ expectedSize: row.size_bytes,
353
+ loadBytes: async () =>
354
+ retry(
355
+ async () => {
356
+ const data = await this.os.get(row.object_key);
357
+ if (!data) throw dsError(`missing .cix object ${row.object_key}`);
358
+ return data;
359
+ },
360
+ {
361
+ retries: this.cfg.objectStoreRetries,
362
+ baseDelayMs: this.cfg.objectStoreBaseDelayMs,
363
+ maxDelayMs: this.cfg.objectStoreMaxDelayMs,
364
+ timeoutMs: this.cfg.objectStoreTimeoutMs,
365
+ }
366
+ ),
367
+ decodeToc: (bytes) => {
368
+ const tocRes = decodeBundledSegmentCompanionTocResult(bytes.subarray(0, Math.min(bytes.byteLength, PSCIX2_MAX_TOC_BYTES)));
369
+ if (Result.isError(tocRes)) return Result.err({ message: tocRes.error.message });
370
+ return Result.ok(tocRes.value);
371
+ },
372
+ });
373
+ if (Result.isError(bundleRes)) return invalidCompanionBuild(bundleRes.error.message);
374
+ return Result.ok({ bytes: bundleRes.value.bytes, toc: bundleRes.value.toc });
375
+ }
376
+
377
+ private sectionPayloadResult(
378
+ bytes: Uint8Array,
379
+ toc: CompanionToc,
380
+ objectKey: string,
381
+ kind: CompanionSectionKind
382
+ ): Result<Uint8Array, CompanionBuildError> {
383
+ const section = toc.sections.find((entry) => entry.kind === kind);
384
+ if (!section) return invalidCompanionBuild(`missing ${kind} section in ${objectKey}`);
385
+ if (section.offset < 0 || section.length < 0 || section.offset + section.length > bytes.byteLength) {
386
+ return invalidCompanionBuild(`invalid ${kind} section bounds in ${objectKey}`);
387
+ }
388
+ return Result.ok(bytes.subarray(section.offset, section.offset + section.length));
389
+ }
390
+
391
+ private async tick(): Promise<void> {
392
+ if (this.running) return;
393
+ this.running = true;
394
+ try {
395
+ if (this.metrics) {
396
+ this.metrics.record("tieredstore.companion.build.queue_len", this.queue.size, "count");
397
+ this.metrics.record("tieredstore.companion.builds_inflight", this.building.size, "count");
398
+ }
399
+ const streams = Array.from(new Set([...this.db.listSearchCompanionPlanStreams(), ...this.queue]));
400
+ this.queue.clear();
401
+ for (const stream of streams) {
402
+ try {
403
+ const buildRes = await this.buildPendingSegmentsResult(stream);
404
+ if (Result.isError(buildRes)) {
405
+ console.error("bundled companion build failed", stream, buildRes.error.message);
406
+ this.queue.add(stream);
407
+ }
408
+ } catch (e: unknown) {
409
+ console.error("bundled companion tick failed", stream, e);
410
+ this.queue.add(stream);
411
+ }
412
+ }
413
+ } finally {
414
+ this.running = false;
415
+ }
416
+ }
417
+
418
+ private async buildPendingSegmentsResult(stream: string): Promise<Result<void, CompanionBuildError>> {
419
+ if (this.building.has(stream)) return Result.ok(undefined);
420
+ this.building.add(stream);
421
+ try {
422
+ const regRes = this.registry.getRegistryResult(stream);
423
+ if (Result.isError(regRes)) return invalidCompanionBuild(regRes.error.message);
424
+ const desiredPlan = buildDesiredSearchCompanionPlan(regRes.value);
425
+ const desiredHash = hashSearchCompanionPlan(desiredPlan);
426
+ const wantedFamilies = Object.values(desiredPlan.families).some(Boolean);
427
+ let planRow = this.db.getSearchCompanionPlan(stream);
428
+ if (!wantedFamilies) {
429
+ if (planRow) {
430
+ this.db.deleteSearchSegmentCompanions(stream);
431
+ this.db.deleteSearchCompanionPlan(stream);
432
+ this.onMetadataChanged?.(stream);
433
+ if (this.publishManifest) {
434
+ try {
435
+ await this.publishManifest(stream);
436
+ } catch {
437
+ // background loop will retry
438
+ }
439
+ }
440
+ }
441
+ return Result.ok(undefined);
442
+ }
443
+ if (!planRow) {
444
+ this.db.upsertSearchCompanionPlan(stream, 1, desiredHash, JSON.stringify(desiredPlan));
445
+ planRow = this.db.getSearchCompanionPlan(stream);
446
+ } else if (planRow.plan_hash !== desiredHash) {
447
+ this.db.upsertSearchCompanionPlan(stream, planRow.generation + 1, desiredHash, JSON.stringify(desiredPlan));
448
+ planRow = this.db.getSearchCompanionPlan(stream);
449
+ }
450
+ if (!planRow) return Result.ok(undefined);
451
+
452
+ const uploadedSegments = this.db.countUploadedSegments(stream);
453
+ const stale: number[] = [];
454
+ for (let segmentIndex = 0; segmentIndex < uploadedSegments; segmentIndex++) {
455
+ const current = this.db.getSearchSegmentCompanion(stream, segmentIndex);
456
+ if (!current || current.plan_generation !== planRow.generation) stale.push(segmentIndex);
457
+ }
458
+ if (this.metrics) {
459
+ this.metrics.record("tieredstore.companion.lag.segments", stale.length, "count", undefined, stream);
460
+ }
461
+ if (stale.length === 0) return Result.ok(undefined);
462
+
463
+ const batchLimit = Math.max(1, this.cfg.searchCompanionBuildBatchSegments);
464
+ const batch = stale.slice(0, batchLimit);
465
+ let builtCount = 0;
466
+ for (const nextSegmentIndex of batch) {
467
+ const seg = this.db.getSegmentByIndex(stream, nextSegmentIndex);
468
+ if (!seg || !seg.r2_etag) continue;
469
+ const startedAt = Date.now();
470
+ const companionRes = await this.asyncGate.run(async () =>
471
+ this.memorySampler
472
+ ? await this.memorySampler.track(
473
+ "companion",
474
+ { stream, segment_index: seg.segment_index, plan_generation: planRow.generation },
475
+ () => this.buildEncodedBundledCompanionResult(regRes.value, desiredPlan, planRow.generation, seg)
476
+ )
477
+ : await this.buildEncodedBundledCompanionResult(regRes.value, desiredPlan, planRow.generation, seg)
478
+ );
479
+ if (Result.isError(companionRes)) return companionRes;
480
+ const objectId = Buffer.from(randomBytes(8)).toString("hex");
481
+ const objectKey = searchCompanionObjectKey(streamHash16Hex(stream), seg.segment_index, objectId);
482
+ const payload = companionRes.value.payload;
483
+ const sectionSizes = companionRes.value.sectionSizes;
484
+ try {
485
+ await retry(
486
+ () => this.os.put(objectKey, payload, { contentLength: payload.byteLength }),
487
+ {
488
+ retries: this.cfg.objectStoreRetries,
489
+ baseDelayMs: this.cfg.objectStoreBaseDelayMs,
490
+ maxDelayMs: this.cfg.objectStoreMaxDelayMs,
491
+ timeoutMs: this.cfg.objectStoreTimeoutMs,
492
+ }
493
+ );
494
+ } catch (e: unknown) {
495
+ return invalidCompanionBuild(String((e as any)?.message ?? e));
496
+ }
497
+ const cacheRes = this.fileCache.storeBytesResult(objectKey, payload);
498
+ if (Result.isError(cacheRes)) {
499
+ console.warn("bundled companion local cache populate failed", objectKey, cacheRes.error.message);
500
+ }
501
+ const sectionKinds = companionRes.value.sectionKinds;
502
+ this.db.upsertSearchSegmentCompanion(
503
+ stream,
504
+ seg.segment_index,
505
+ objectKey,
506
+ planRow.generation,
507
+ JSON.stringify(sectionKinds),
508
+ JSON.stringify(sectionSizes),
509
+ payload.byteLength,
510
+ companionRes.value.primaryTimestampMinMs,
511
+ companionRes.value.primaryTimestampMaxMs
512
+ );
513
+ builtCount += 1;
514
+ if (this.metrics) {
515
+ const elapsedNs = BigInt(Date.now() - startedAt) * 1_000_000n;
516
+ this.metrics.record("tieredstore.companion.build.latency", Number(elapsedNs), "ns", undefined, stream);
517
+ this.metrics.record("tieredstore.companion.objects.built", 1, "count", undefined, stream);
518
+ }
519
+ }
520
+
521
+ if (stale.length > builtCount) this.queue.add(stream);
522
+ if (builtCount === 0) return Result.ok(undefined);
523
+
524
+ this.onMetadataChanged?.(stream);
525
+ if (this.publishManifest) {
526
+ try {
527
+ await this.publishManifest(stream);
528
+ } catch (e: unknown) {
529
+ console.error("bundled companion manifest publish failed", stream, e);
530
+ // background loop will retry
531
+ }
532
+ }
533
+ return Result.ok(undefined);
534
+ } finally {
535
+ this.building.delete(stream);
536
+ }
537
+ }
538
+
539
+ private async loadSegmentBytesResult(seg: SegmentRow): Promise<Result<Uint8Array, CompanionBuildError>> {
540
+ try {
541
+ const bytes = await loadSegmentBytesCached(
542
+ this.os,
543
+ seg,
544
+ this.segmentCache,
545
+ {
546
+ retries: this.cfg.objectStoreRetries,
547
+ baseDelayMs: this.cfg.objectStoreBaseDelayMs,
548
+ maxDelayMs: this.cfg.objectStoreMaxDelayMs,
549
+ timeoutMs: this.cfg.objectStoreTimeoutMs,
550
+ }
551
+ );
552
+ return Result.ok(bytes);
553
+ } catch (e: unknown) {
554
+ return invalidCompanionBuild(String((e as any)?.message ?? e));
555
+ }
556
+ }
557
+
558
+ private async visitParsedSegmentRecordsResult(
559
+ segmentBytes: Uint8Array,
560
+ seg: SegmentRow,
561
+ visit: (args: {
562
+ docCount: number;
563
+ offset: bigint;
564
+ parsed: unknown | null;
565
+ parsedOk: boolean;
566
+ }) => Promise<Result<void, CompanionBuildError>>
567
+ ): Promise<Result<number, CompanionBuildError>> {
568
+ let docCount = 0;
569
+ let offset = seg.start_offset;
570
+ let processedBlocks = 0;
571
+ let lastBlockOffset = -1;
572
+ for (const recRes of iterateBlockRecordsResult(segmentBytes)) {
573
+ if (Result.isError(recRes)) return invalidCompanionBuild(recRes.error.message);
574
+ const rec = recRes.value;
575
+ if (rec.blockOffset !== lastBlockOffset) {
576
+ processedBlocks += 1;
577
+ lastBlockOffset = rec.blockOffset;
578
+ if (processedBlocks % this.yieldBlocks === 0) await this.yieldBackgroundWork();
579
+ }
580
+ let parsed: unknown = null;
581
+ let parsedOk = false;
582
+ try {
583
+ parsed = JSON.parse(PAYLOAD_DECODER.decode(rec.payload));
584
+ parsedOk = true;
585
+ } catch {
586
+ parsed = null;
587
+ }
588
+ const visitRes = await visit({ docCount, offset, parsed, parsedOk });
589
+ if (Result.isError(visitRes)) return visitRes;
590
+ offset += 1n;
591
+ docCount += 1;
592
+ }
593
+ return Result.ok(docCount);
594
+ }
595
+
596
+ private async buildEncodedBundledCompanionResult(
597
+ registry: SchemaRegistry,
598
+ plan: SearchCompanionPlan,
599
+ planGeneration: number,
600
+ seg: SegmentRow
601
+ ): Promise<
602
+ Result<
603
+ {
604
+ payload: Uint8Array;
605
+ sectionKinds: CompanionSectionKind[];
606
+ sectionSizes: Record<string, number>;
607
+ primaryTimestampMinMs: bigint | null;
608
+ primaryTimestampMaxMs: bigint | null;
609
+ },
610
+ CompanionBuildError
611
+ >
612
+ > {
613
+ const leaveLoad = this.memorySampler?.enter("companion_load_segment", {
614
+ stream: seg.stream,
615
+ segment_index: seg.segment_index,
616
+ });
617
+ const bytesRes = await this.loadSegmentBytesResult(seg);
618
+ leaveLoad?.();
619
+ if (Result.isError(bytesRes)) return bytesRes;
620
+ const segmentBytes = bytesRes.value;
621
+ const colBuilders = plan.families.col ? this.createColBuilders(registry) : new Map<string, ColumnFieldBuilder>();
622
+ const ftsBuilders = plan.families.fts ? this.createFtsBuilders(registry) : new Map<string, FtsFieldBuilder>();
623
+ const aggBuildersRes = plan.families.agg ? this.createAggRollupBuildersResult(registry) : Result.ok(new Map<string, AggRollupBuilder>());
624
+ if (Result.isError(aggBuildersRes)) return aggBuildersRes;
625
+ const aggBuilders = aggBuildersRes.value;
626
+ const metricsBuilder: MetricsBlockBuilder | null = plan.families.mblk
627
+ ? { records: [], minWindowStartMs: undefined, maxWindowEndMs: undefined }
628
+ : null;
629
+ const requiredFieldNames = new Set<string>();
630
+ for (const fieldName of colBuilders.keys()) requiredFieldNames.add(fieldName);
631
+ for (const fieldName of ftsBuilders.keys()) requiredFieldNames.add(fieldName);
632
+ for (const builder of aggBuilders.values()) {
633
+ for (const fieldName of builder.fieldNames) requiredFieldNames.add(fieldName);
634
+ }
635
+ const fieldNameList = Array.from(requiredFieldNames).sort((a, b) => a.localeCompare(b));
636
+ const leaveScan = this.memorySampler?.enter("companion_scan_records", {
637
+ stream: seg.stream,
638
+ segment_index: seg.segment_index,
639
+ });
640
+ const docCountRes = await this.visitParsedSegmentRecordsResult(segmentBytes, seg, async ({ docCount, offset, parsed, parsedOk }) => {
641
+ let rawSearchValues: Map<string, unknown[]> | null = null;
642
+ if (parsedOk && fieldNameList.length > 0) {
643
+ const leaveExtract = this.memorySampler?.enter("companion_extract_raw", { doc_count: docCount });
644
+ const rawValuesRes = extractRawSearchValuesForFieldsResult(registry, offset, parsed, fieldNameList);
645
+ leaveExtract?.();
646
+ if (Result.isError(rawValuesRes)) return invalidCompanionBuild(rawValuesRes.error.message);
647
+ rawSearchValues = rawValuesRes.value;
648
+ }
649
+ if (rawSearchValues) {
650
+ const leaveCol = this.memorySampler?.enter("companion_record_col", { doc_count: docCount });
651
+ this.recordColBuilders(colBuilders, rawSearchValues, docCount);
652
+ leaveCol?.();
653
+ const leaveFts = this.memorySampler?.enter("companion_record_fts", { doc_count: docCount });
654
+ this.recordFtsBuilders(ftsBuilders, rawSearchValues, docCount);
655
+ leaveFts?.();
656
+ }
657
+ if (parsedOk && rawSearchValues) {
658
+ const leaveAgg = this.memorySampler?.enter("companion_record_agg", { doc_count: docCount });
659
+ for (const builder of aggBuilders.values()) {
660
+ const contributionRes = extractRollupContributionResult(registry, builder.rollup, offset, parsed, rawSearchValues);
661
+ if (Result.isError(contributionRes)) {
662
+ leaveAgg?.();
663
+ return invalidCompanionBuild(contributionRes.error.message);
664
+ }
665
+ if (!contributionRes.value) continue;
666
+ const recordRes = this.recordAggContributionResult(builder, contributionRes.value);
667
+ if (Result.isError(recordRes)) {
668
+ leaveAgg?.();
669
+ return recordRes;
670
+ }
671
+ }
672
+ leaveAgg?.();
673
+ }
674
+ if (metricsBuilder && parsedOk) {
675
+ const leaveMetrics = this.memorySampler?.enter("companion_record_mblk", { doc_count: docCount });
676
+ this.recordMetricsBlockBuilder(metricsBuilder, parsed, docCount);
677
+ leaveMetrics?.();
678
+ }
679
+ if (this.memorySampler && (docCount + 1) % 1024 === 0) {
680
+ this.memorySampler.capture("companion_progress", {
681
+ stream: seg.stream,
682
+ segment_index: seg.segment_index,
683
+ ...this.summarizeCompanionBuildProgress(colBuilders, ftsBuilders, aggBuilders, metricsBuilder, docCount + 1),
684
+ });
685
+ }
686
+ return Result.ok(undefined);
687
+ });
688
+ leaveScan?.();
689
+ if (Result.isError(docCountRes)) return docCountRes;
690
+
691
+ const sectionPayloads: EncodedCompanionSectionPayload[] = [];
692
+ const sectionKinds: CompanionSectionKind[] = [];
693
+ const sectionSizes: Record<string, number> = {};
694
+ let primaryTimestampMinMs: bigint | null = null;
695
+ let primaryTimestampMaxMs: bigint | null = null;
696
+ const addSection = (payload: EncodedCompanionSectionPayload): void => {
697
+ sectionPayloads.push(payload);
698
+ const kind = payload.kind;
699
+ sectionKinds.push(kind);
700
+ sectionSizes[kind] = payload.payload.byteLength;
701
+ };
702
+
703
+ if (plan.families.col) {
704
+ const leaveColEncode = this.memorySampler?.enter("companion_encode_col", {
705
+ stream: seg.stream,
706
+ segment_index: seg.segment_index,
707
+ doc_count: docCountRes.value,
708
+ });
709
+ const colSection = this.finalizeColSection(registry, colBuilders, docCountRes.value);
710
+ const primaryTimestampField = colSection.primary_timestamp_field;
711
+ const primaryTimestampColumn = primaryTimestampField ? colSection.fields[primaryTimestampField] : undefined;
712
+ primaryTimestampMinMs = typeof primaryTimestampColumn?.min === "bigint" ? primaryTimestampColumn.min : null;
713
+ primaryTimestampMaxMs = typeof primaryTimestampColumn?.max === "bigint" ? primaryTimestampColumn.max : null;
714
+ addSection(encodeCompanionSectionPayload("col", colSection, plan));
715
+ colBuilders.clear();
716
+ leaveColEncode?.();
717
+ }
718
+ if (plan.families.fts) {
719
+ const leaveFtsEncode = this.memorySampler?.enter("companion_encode_fts", {
720
+ stream: seg.stream,
721
+ segment_index: seg.segment_index,
722
+ doc_count: docCountRes.value,
723
+ });
724
+ addSection(encodeCompanionSectionPayload("fts", this.finalizeFtsSection(ftsBuilders, docCountRes.value), plan));
725
+ ftsBuilders.clear();
726
+ leaveFtsEncode?.();
727
+ }
728
+ if (plan.families.agg) {
729
+ const leaveAggEncode = this.memorySampler?.enter("companion_encode_agg", {
730
+ stream: seg.stream,
731
+ segment_index: seg.segment_index,
732
+ });
733
+ addSection(encodeCompanionSectionPayload("agg", this.finalizeAggSection(aggBuilders), plan));
734
+ aggBuilders.clear();
735
+ leaveAggEncode?.();
736
+ }
737
+ if (plan.families.mblk && metricsBuilder) {
738
+ const leaveMetricsEncode = this.memorySampler?.enter("companion_encode_mblk", {
739
+ stream: seg.stream,
740
+ segment_index: seg.segment_index,
741
+ });
742
+ addSection(encodeCompanionSectionPayload("mblk", this.finalizeMetricsBlockSection(metricsBuilder), plan));
743
+ metricsBuilder.records.length = 0;
744
+ leaveMetricsEncode?.();
745
+ }
746
+
747
+ return Result.ok({
748
+ payload: encodeBundledSegmentCompanionFromPayloads({
749
+ stream: seg.stream,
750
+ segment_index: seg.segment_index,
751
+ plan_generation: planGeneration,
752
+ sections: sectionPayloads,
753
+ }),
754
+ sectionKinds,
755
+ sectionSizes,
756
+ primaryTimestampMinMs,
757
+ primaryTimestampMaxMs,
758
+ });
759
+ }
760
+
761
+ private async buildBundledCompanionResult(
762
+ registry: SchemaRegistry,
763
+ plan: SearchCompanionPlan,
764
+ planGeneration: number,
765
+ seg: SegmentRow
766
+ ): Promise<Result<BundledSegmentCompanion, CompanionBuildError>> {
767
+ const encodedRes = await this.buildEncodedBundledCompanionResult(registry, plan, planGeneration, seg);
768
+ if (Result.isError(encodedRes)) return encodedRes;
769
+ const decodedRes = decodeBundledSegmentCompanionResult(encodedRes.value.payload, plan);
770
+ if (Result.isError(decodedRes)) return invalidCompanionBuild(decodedRes.error.message);
771
+ return Result.ok(decodedRes.value);
772
+ }
773
+
774
+ private createColBuilders(registry: SchemaRegistry): Map<string, ColumnFieldBuilder> {
775
+ const columnFields = Object.entries(registry.search?.fields ?? {}).filter(([, field]) => field.column === true);
776
+ const builders = new Map<string, ColumnFieldBuilder>();
777
+ for (const [fieldName, field] of columnFields) {
778
+ builders.set(fieldName, { config: field, kind: field.kind, docIds: [], values: [], invalid: false });
779
+ }
780
+ return builders;
781
+ }
782
+
783
+ private recordColBuilders(builders: Map<string, ColumnFieldBuilder>, rawSearchValues: Map<string, unknown[]>, docCount: number): void {
784
+ for (const [fieldName, builder] of builders) {
785
+ if (builder.invalid) continue;
786
+ const rawValues = rawSearchValues.get(fieldName) ?? [];
787
+ const colValues: Array<bigint | number | boolean> = [];
788
+ for (const rawValue of rawValues) {
789
+ const normalized = canonicalizeColumnValue(builder.config, rawValue);
790
+ if (normalized != null) colValues.push(normalized);
791
+ }
792
+ if (colValues.length > 1) {
793
+ builder.invalid = true;
794
+ continue;
795
+ }
796
+ if (colValues.length === 1) {
797
+ builder.docIds.push(docCount);
798
+ builder.values.push(colValues[0]!);
799
+ }
800
+ }
801
+ }
802
+
803
+ private finalizeColSection(
804
+ registry: SchemaRegistry,
805
+ builders: Map<string, ColumnFieldBuilder>,
806
+ docCount: number
807
+ ): ColSectionInput {
808
+ const fields: Record<string, ColFieldInput> = {};
809
+ const primaryTimestampField = registry.search?.primaryTimestampField;
810
+ for (const [fieldName, builder] of builders) {
811
+ if (builder.invalid) continue;
812
+ let minValue: bigint | number | boolean | null = null;
813
+ let maxValue: bigint | number | boolean | null = null;
814
+ for (const value of builder.values) {
815
+ if (minValue == null || compareValues(value, minValue) < 0) minValue = value;
816
+ if (maxValue == null || compareValues(value, maxValue) > 0) maxValue = value;
817
+ }
818
+ if (builder.values.length === 0) continue;
819
+ fields[fieldName] = {
820
+ kind: builder.kind,
821
+ doc_ids: builder.docIds,
822
+ values: builder.values,
823
+ min: minValue,
824
+ max: maxValue,
825
+ };
826
+ }
827
+
828
+ return {
829
+ doc_count: docCount,
830
+ primary_timestamp_field: primaryTimestampField ?? undefined,
831
+ fields,
832
+ };
833
+ }
834
+
835
+ private createFtsFieldBuilder(field: SearchFieldConfig): FtsFieldBuilder {
836
+ return {
837
+ config: field,
838
+ companion: {
839
+ kind: field.kind,
840
+ exact: field.exact === true ? true : undefined,
841
+ prefix: field.prefix === true ? true : undefined,
842
+ positions: field.positions === true ? true : undefined,
843
+ exists_docs: [],
844
+ terms: Object.create(null) as Record<string, FtsTermInput>,
845
+ },
846
+ };
847
+ }
848
+
849
+ private createFtsBuilders(registry: SchemaRegistry): Map<string, FtsFieldBuilder> {
850
+ const builders = new Map<string, FtsFieldBuilder>();
851
+ for (const [fieldName, field] of Object.entries(registry.search?.fields ?? {}).sort((a, b) => a[0].localeCompare(b[0]))) {
852
+ if (field.kind !== "text" && !(field.kind === "keyword" && field.prefix === true)) continue;
853
+ builders.set(fieldName, this.createFtsFieldBuilder(field));
854
+ }
855
+ return builders;
856
+ }
857
+
858
+ private recordFtsBuilders(builders: Map<string, FtsFieldBuilder>, rawSearchValues: Map<string, unknown[]>, docCount: number): void {
859
+ for (const [fieldName, builder] of builders) {
860
+ const fieldCompanion = builder.companion;
861
+ const textValues: string[] = [];
862
+ for (const rawValue of rawSearchValues.get(fieldName) ?? []) {
863
+ if (builder.config.kind === "keyword") {
864
+ const normalized = normalizeKeywordValue(rawValue, builder.config.normalizer);
865
+ if (normalized != null) textValues.push(normalized);
866
+ } else if (builder.config.kind === "text" && typeof rawValue === "string") {
867
+ textValues.push(rawValue);
868
+ }
869
+ }
870
+ if (textValues.length === 0) continue;
871
+ fieldCompanion.exists_docs.push(docCount);
872
+ if (builder.config.kind === "keyword") {
873
+ for (const value of textValues) {
874
+ const postings = fieldCompanion.terms[value] ?? { doc_ids: [] };
875
+ const docIds = postings.doc_ids;
876
+ if (docIds.length === 0 || docIds[docIds.length - 1] !== docCount) docIds.push(docCount);
877
+ fieldCompanion.terms[value] = postings;
878
+ }
879
+ continue;
880
+ }
881
+ let position = 0;
882
+ for (const value of textValues) {
883
+ const tokens = analyzeTextValue(value, builder.config.analyzer);
884
+ for (const token of tokens) {
885
+ const postings = fieldCompanion.terms[token] ?? {
886
+ doc_ids: [],
887
+ freqs: fieldCompanion.positions ? [] : undefined,
888
+ positions: fieldCompanion.positions ? [] : undefined,
889
+ };
890
+ const docIds = postings.doc_ids;
891
+ const lastIndex = docIds.length - 1;
892
+ if (lastIndex < 0 || docIds[lastIndex] !== docCount) {
893
+ docIds.push(docCount);
894
+ if (fieldCompanion.positions) {
895
+ postings.freqs!.push(1);
896
+ postings.positions!.push(position);
897
+ }
898
+ } else if (fieldCompanion.positions) {
899
+ postings.freqs![lastIndex] = (postings.freqs![lastIndex] ?? 0) + 1;
900
+ postings.positions!.push(position);
901
+ }
902
+ fieldCompanion.terms[token] = postings;
903
+ position += 1;
904
+ }
905
+ }
906
+ }
907
+ }
908
+
909
+ private finalizeFtsSection(
910
+ builders: Map<string, FtsFieldBuilder>,
911
+ docCount: number
912
+ ): FtsSectionInput {
913
+ const orderedFields = Object.create(null) as Record<string, FtsFieldInput>;
914
+ for (const [fieldName, builder] of Array.from(builders.entries()).sort((a, b) => a[0].localeCompare(b[0]))) {
915
+ orderedFields[fieldName] = builder.companion;
916
+ }
917
+ return {
918
+ doc_count: docCount,
919
+ fields: orderedFields,
920
+ };
921
+ }
922
+
923
+ private createAggRollupBuildersResult(registry: SchemaRegistry): Result<Map<string, AggRollupBuilder>, CompanionBuildError> {
924
+ const builders = new Map<string, AggRollupBuilder>();
925
+ for (const [rollupName, rollup] of Object.entries(registry.search?.rollups ?? {}).sort((a, b) => a[0].localeCompare(b[0]))) {
926
+ const parsedIntervalsRes = this.parseRollupIntervalsResult(rollup);
927
+ if (Result.isError(parsedIntervalsRes)) return parsedIntervalsRes;
928
+ const intervalMap = new Map<number, Map<number, Map<string, GroupBuilder>>>();
929
+ for (const intervalMs of parsedIntervalsRes.value) intervalMap.set(intervalMs, new Map());
930
+ builders.set(rollupName, {
931
+ rollup,
932
+ intervalsMs: parsedIntervalsRes.value,
933
+ intervalMap,
934
+ dimensionNames: [...(rollup.dimensions ?? [])],
935
+ fieldNames: rollupRequiredFieldNames(registry, rollup),
936
+ });
937
+ }
938
+ return Result.ok(builders);
939
+ }
940
+
941
+ private finalizeAggSection(builders: Map<string, AggRollupBuilder>): AggSectionInput {
942
+ const encodedRollups: AggSectionInput["rollups"] = {};
943
+ for (const [rollupName, builder] of builders) {
944
+ encodedRollups[rollupName] = { intervals: this.finalizeAggIntervals(builder.intervalMap, builder.dimensionNames) };
945
+ }
946
+
947
+ return { rollups: encodedRollups };
948
+ }
949
+
950
+ private summarizeCompanionBuildProgress(
951
+ colBuilders: Map<string, ColumnFieldBuilder>,
952
+ ftsBuilders: Map<string, FtsFieldBuilder>,
953
+ aggBuilders: Map<string, AggRollupBuilder>,
954
+ metricsBuilder: MetricsBlockBuilder | null,
955
+ docCount: number
956
+ ): CompanionBuildProgress {
957
+ let colValues = 0;
958
+ for (const builder of colBuilders.values()) colValues += builder.values.length;
959
+
960
+ let ftsTerms = 0;
961
+ let ftsPostings = 0;
962
+ let ftsPositions = 0;
963
+ for (const builder of ftsBuilders.values()) {
964
+ for (const postings of Object.values(builder.companion.terms)) {
965
+ ftsTerms += 1;
966
+ ftsPostings += postings.doc_ids.length;
967
+ ftsPositions += postings.positions?.length ?? 0;
968
+ }
969
+ }
970
+
971
+ let aggWindows = 0;
972
+ let aggGroups = 0;
973
+ for (const builder of aggBuilders.values()) {
974
+ for (const windowMap of builder.intervalMap.values()) {
975
+ aggWindows += windowMap.size;
976
+ for (const groups of windowMap.values()) aggGroups += groups.size;
977
+ }
978
+ }
979
+
980
+ return {
981
+ docCount,
982
+ colFields: colBuilders.size,
983
+ colValues,
984
+ ftsFields: ftsBuilders.size,
985
+ ftsTerms,
986
+ ftsPostings,
987
+ ftsPositions,
988
+ aggRollups: aggBuilders.size,
989
+ aggWindows,
990
+ aggGroups,
991
+ metricRecords: metricsBuilder?.records.length ?? 0,
992
+ };
993
+ }
994
+
995
+ private parseRollupIntervalsResult(rollup: SearchRollupConfig): Result<number[], CompanionBuildError> {
996
+ const parsed: number[] = [];
997
+ for (const interval of rollup.intervals) {
998
+ const intervalMsRes = parseDurationMsResult(interval);
999
+ if (Result.isError(intervalMsRes)) return invalidCompanionBuild(intervalMsRes.error.message);
1000
+ parsed.push(intervalMsRes.value);
1001
+ }
1002
+ return Result.ok(parsed);
1003
+ }
1004
+
1005
+ private recordAggContributionResult(
1006
+ builder: AggRollupBuilder,
1007
+ contribution: {
1008
+ timestampMs: number;
1009
+ dimensions: Record<string, string | null>;
1010
+ measures: Record<string, AggMeasureState>;
1011
+ }
1012
+ ): Result<void, CompanionBuildError> {
1013
+ const groupKey = encodeAggGroupKey(contribution.dimensions, builder.dimensionNames);
1014
+ for (const intervalMs of builder.intervalsMs) {
1015
+ if (!Number.isFinite(intervalMs) || intervalMs <= 0) return invalidCompanionBuild(`invalid rollup interval ${intervalMs}`);
1016
+ const startMs = Math.floor(contribution.timestampMs / intervalMs) * intervalMs;
1017
+ const windowMap = builder.intervalMap.get(intervalMs) ?? new Map<number, Map<string, GroupBuilder>>();
1018
+ builder.intervalMap.set(intervalMs, windowMap);
1019
+ const groups = windowMap.get(startMs) ?? new Map<string, GroupBuilder>();
1020
+ windowMap.set(startMs, groups);
1021
+ let group = groups.get(groupKey);
1022
+ if (!group) {
1023
+ const measures: Record<string, AggMeasureState> = {};
1024
+ for (const [measureName, state] of Object.entries(contribution.measures)) {
1025
+ measures[measureName] = cloneAggMeasureState(state);
1026
+ }
1027
+ group = {
1028
+ key: groupKey,
1029
+ measures,
1030
+ };
1031
+ groups.set(groupKey, group);
1032
+ continue;
1033
+ }
1034
+ for (const [measureName, state] of Object.entries(contribution.measures)) {
1035
+ const existing = group.measures[measureName];
1036
+ group.measures[measureName] = existing ? mergeAggMeasureState(existing, state) : cloneAggMeasureState(state);
1037
+ }
1038
+ }
1039
+ return Result.ok(undefined);
1040
+ }
1041
+
1042
+ private finalizeAggIntervals(
1043
+ intervalMap: Map<number, Map<number, Map<string, GroupBuilder>>>,
1044
+ dimensionNames: string[]
1045
+ ): AggSectionInput["rollups"][string]["intervals"] {
1046
+ const intervals: AggSectionInput["rollups"][string]["intervals"] = {};
1047
+ for (const [intervalMs, windowMap] of Array.from(intervalMap.entries()).sort((a, b) => a[0] - b[0])) {
1048
+ intervals[String(intervalMs)] = {
1049
+ interval_ms: intervalMs,
1050
+ windows: Array.from(windowMap.entries())
1051
+ .sort((a, b) => a[0] - b[0])
1052
+ .map(([startMs, groups]) => ({
1053
+ start_ms: startMs,
1054
+ groups: Array.from(groups.values()).map((group) => ({
1055
+ dimensions: decodeAggGroupKey(group.key, dimensionNames),
1056
+ measures: group.measures,
1057
+ })),
1058
+ })),
1059
+ };
1060
+ }
1061
+ return intervals;
1062
+ }
1063
+
1064
+ private finalizeMetricsBlockSection(builder: MetricsBlockBuilder): MetricsBlockSectionInput {
1065
+ return {
1066
+ record_count: builder.records.length,
1067
+ min_window_start_ms: builder.minWindowStartMs,
1068
+ max_window_end_ms: builder.maxWindowEndMs,
1069
+ records: builder.records,
1070
+ };
1071
+ }
1072
+
1073
+ private recordMetricsBlockBuilder(builder: MetricsBlockBuilder, parsed: unknown, docCount: number): void {
1074
+ const normalizedRes = buildMetricsBlockRecord(docCount, parsed);
1075
+ if (Result.isError(normalizedRes)) return;
1076
+ builder.records.push(normalizedRes.value);
1077
+ builder.minWindowStartMs =
1078
+ builder.minWindowStartMs == null
1079
+ ? normalizedRes.value.windowStartMs
1080
+ : Math.min(builder.minWindowStartMs, normalizedRes.value.windowStartMs);
1081
+ builder.maxWindowEndMs =
1082
+ builder.maxWindowEndMs == null
1083
+ ? normalizedRes.value.windowEndMs
1084
+ : Math.max(builder.maxWindowEndMs, normalizedRes.value.windowEndMs);
1085
+ }
1086
+ }