@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
package/src/app_core.ts CHANGED
@@ -1,9 +1,10 @@
1
1
  import { mkdirSync } from "node:fs";
2
+ import { createHash } from "node:crypto";
2
3
  import type { Config } from "./config";
3
- import { SqliteDurableStore } from "./db/db";
4
- import { IngestQueue, type ProducerInfo, type AppendRow } from "./ingest";
4
+ import { SqliteDurableStore, type StreamRow } from "./db/db";
5
+ import { IngestQueue, type ProducerInfo, type AppendRow, type AppendResult } from "./ingest";
5
6
  import type { ObjectStore } from "./objectstore/interface";
6
- import type { StreamReader, ReadBatch, ReaderError } from "./reader";
7
+ import type { StreamReader, ReadBatch, ReaderError, SearchResultBatch } from "./reader";
7
8
  import { StreamNotifier } from "./notifier";
8
9
  import { encodeOffset, parseOffsetResult, offsetToSeqOrNeg1, canonicalizeOffset, type ParsedOffset } from "./offset";
9
10
  import { parseDurationMsResult } from "./util/duration";
@@ -11,22 +12,51 @@ import { Metrics } from "./metrics";
11
12
  import { parseTimestampMsResult } from "./util/time";
12
13
  import { cleanupTempSegments } from "./util/cleanup";
13
14
  import { MetricsEmitter } from "./metrics_emitter";
14
- import { SchemaRegistryStore, type SchemaRegistry, type SchemaRegistryMutationError, type SchemaRegistryReadError } from "./schema/registry";
15
+ import {
16
+ SchemaRegistryStore,
17
+ parseSchemaUpdateResult,
18
+ type SchemaRegistry,
19
+ type SearchConfig,
20
+ type SchemaRegistryMutationError,
21
+ type SchemaRegistryReadError,
22
+ } from "./schema/registry";
23
+ import { decodeJsonPayloadResult } from "./schema/read_json";
15
24
  import { resolvePointerResult } from "./util/json_pointer";
16
- import { applyLensChainResult } from "./lens/lens";
17
25
  import { ExpirySweeper } from "./expiry_sweeper";
18
26
  import type { StatsCollector } from "./stats";
19
27
  import { BackpressureGate } from "./backpressure";
20
- import { MemoryGuard } from "./memory";
21
- import { TouchInterpreterManager } from "./touch/manager";
22
- import { isTouchEnabled } from "./touch/spec";
23
- import { parseTouchCursor } from "./touch/touch_journal";
24
- import { touchKeyIdFromRoutingKeyResult } from "./touch/touch_key_id";
25
- import { tableKeyIdFor, templateKeyIdFor } from "./touch/live_keys";
28
+ import { MemoryPressureMonitor } from "./memory";
29
+ import { RuntimeMemorySampler } from "./runtime_memory_sampler";
30
+ import { TouchProcessorManager } from "./touch/manager";
31
+ import type { SegmentDiskCache } from "./segment/cache";
32
+ import { StreamSizeReconciler } from "./stream_size_reconciler";
33
+ import { ConcurrencyGate } from "./concurrency_gate";
34
+ import {
35
+ buildProcessMemoryBreakdown,
36
+ type RuntimeHighWaterMark,
37
+ type RuntimeMemoryHighWaterSnapshot,
38
+ type RuntimeMemorySubsystemSnapshot,
39
+ type RuntimeMemorySnapshot,
40
+ } from "./runtime_memory";
26
41
  import type { SegmenterController } from "./segment/segmenter_workers";
27
42
  import type { UploaderController } from "./uploader";
28
- import type { IndexManager } from "./index/indexer";
43
+ import type { StreamIndexLookup } from "./index/indexer";
44
+ import { ForegroundActivityTracker } from "./foreground_activity";
29
45
  import { Result } from "better-result";
46
+ import { parseReadFilterResult } from "./read_filter";
47
+ import { hashSecondaryIndexField } from "./index/secondary_schema";
48
+ import { buildDesiredSearchCompanionPlan, hashSearchCompanionPlan } from "./search/companion_plan";
49
+ import { parseSearchRequestBodyResult, parseSearchRequestQueryResult } from "./search/query";
50
+ import { parseAggregateRequestBodyResult } from "./search/aggregate";
51
+ import {
52
+ StreamProfileStore,
53
+ parseProfileUpdateResult,
54
+ resolveJsonIngestCapability,
55
+ resolveTouchCapability,
56
+ type StreamTouchRoute,
57
+ } from "./profiles";
58
+ import { dsError } from "./util/ds_error.ts";
59
+ import { streamHash16Hex } from "./util/stream_paths";
30
60
 
31
61
  function withNosniff(headers: HeadersInit = {}): HeadersInit {
32
62
  return {
@@ -46,6 +76,66 @@ function json(status: number, body: any, headers: HeadersInit = {}): Response {
46
76
  });
47
77
  }
48
78
 
79
+ const OVERLOAD_RETRY_AFTER_SECONDS = "1";
80
+ const UNAVAILABLE_RETRY_AFTER_SECONDS = "5";
81
+ const APPEND_REQUEST_TIMEOUT_MS = 3_000;
82
+ const HTTP_RESOLVER_TIMEOUT_MS = 5_000;
83
+ const SEARCH_REQUEST_TIMEOUT_MS = 3_000;
84
+ const TIMEOUT_SENTINEL = Symbol("request-timeout");
85
+ const DEFAULT_TOUCH_JOURNAL_FILTER_BYTES = 4 * (1 << 22);
86
+
87
+ type TimeoutSentinel = typeof TIMEOUT_SENTINEL;
88
+
89
+ function retryAfterHeaders(seconds: string, headers: HeadersInit = {}): HeadersInit {
90
+ return {
91
+ "retry-after": seconds,
92
+ ...headers,
93
+ };
94
+ }
95
+
96
+ function clampSearchRequestTimeoutMs(timeoutMs: number | null): number {
97
+ return timeoutMs == null ? SEARCH_REQUEST_TIMEOUT_MS : Math.min(timeoutMs, SEARCH_REQUEST_TIMEOUT_MS);
98
+ }
99
+
100
+ async function awaitWithCooperativeTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T | TimeoutSentinel> {
101
+ let timer: ReturnType<typeof setTimeout> | null = null;
102
+ try {
103
+ return await Promise.race([
104
+ promise,
105
+ new Promise<TimeoutSentinel>((resolve) => {
106
+ timer = setTimeout(() => resolve(TIMEOUT_SENTINEL), timeoutMs);
107
+ }),
108
+ ]);
109
+ } finally {
110
+ if (timer != null) clearTimeout(timer);
111
+ }
112
+ }
113
+
114
+ function isAbortLikeError(error: unknown): boolean {
115
+ return typeof error === "object" && error != null && "name" in error && (error as { name?: unknown }).name === "AbortError";
116
+ }
117
+
118
+ function searchResponseHeaders(search: SearchResultBatch): HeadersInit {
119
+ return {
120
+ "search-timed-out": search.timedOut ? "true" : "false",
121
+ "search-timeout-ms": String(search.timeoutMs ?? SEARCH_REQUEST_TIMEOUT_MS),
122
+ "search-took-ms": String(search.tookMs),
123
+ "search-total-relation": search.total.relation,
124
+ "search-coverage-complete": search.coverage.complete ? "true" : "false",
125
+ "search-indexed-segments": String(search.coverage.indexedSegments),
126
+ "search-indexed-segment-time-ms": String(search.coverage.indexedSegmentTimeMs),
127
+ "search-fts-section-get-ms": String(search.coverage.ftsSectionGetMs),
128
+ "search-fts-decode-ms": String(search.coverage.ftsDecodeMs),
129
+ "search-fts-clause-estimate-ms": String(search.coverage.ftsClauseEstimateMs),
130
+ "search-scanned-segments": String(search.coverage.scannedSegments),
131
+ "search-scanned-segment-time-ms": String(search.coverage.scannedSegmentTimeMs),
132
+ "search-scanned-tail-docs": String(search.coverage.scannedTailDocs),
133
+ "search-scanned-tail-time-ms": String(search.coverage.scannedTailTimeMs),
134
+ "search-exact-candidate-time-ms": String(search.coverage.exactCandidateTimeMs),
135
+ "search-index-families-used": search.coverage.indexFamiliesUsed.join(","),
136
+ };
137
+ }
138
+
49
139
  function internalError(message = "internal server error"): Response {
50
140
  return json(500, { error: { code: "internal", message } });
51
141
  }
@@ -82,6 +172,44 @@ function tooLarge(msg: string): Response {
82
172
  return json(413, { error: { code: "payload_too_large", message: msg } });
83
173
  }
84
174
 
175
+ function unavailable(msg = "server shutting down"): Response {
176
+ return json(503, { error: { code: "unavailable", message: msg } }, retryAfterHeaders(UNAVAILABLE_RETRY_AFTER_SECONDS));
177
+ }
178
+
179
+ function overloaded(msg = "ingest queue full", code = "overloaded"): Response {
180
+ return json(429, { error: { code, message: msg } }, retryAfterHeaders(OVERLOAD_RETRY_AFTER_SECONDS));
181
+ }
182
+
183
+ function requestTimeout(msg = "request timed out"): Response {
184
+ return json(408, { error: { code: "request_timeout", message: msg } });
185
+ }
186
+
187
+ function appendTimeout(): Response {
188
+ return json(408, {
189
+ error: {
190
+ code: "append_timeout",
191
+ message: "append timed out; append outcome is unknown, check Stream-Next-Offset before retrying",
192
+ },
193
+ });
194
+ }
195
+
196
+ async function cancelRequestBody(req: Request): Promise<void> {
197
+ const body = req.body;
198
+ if (!body) return;
199
+ try {
200
+ await body.cancel("request rejected");
201
+ return;
202
+ } catch {
203
+ // ignore and try a reader-based cancel below
204
+ }
205
+ try {
206
+ const reader = body.getReader();
207
+ await reader.cancel("request rejected");
208
+ } catch {
209
+ // ignore
210
+ }
211
+ }
212
+
85
213
  function normalizeContentType(value: string | null): string | null {
86
214
  if (!value) return null;
87
215
  const base = value.split(";")[0]?.trim().toLowerCase();
@@ -143,6 +271,25 @@ function encodeSseEvent(eventType: string, data: string): string {
143
271
  return out;
144
272
  }
145
273
 
274
+ const INTERNAL_METRICS_STREAM = "__stream_metrics__";
275
+
276
+ function clearInternalMetricsAccelerationState(db: SqliteDurableStore): void {
277
+ db.deleteAccelerationState(INTERNAL_METRICS_STREAM);
278
+ }
279
+
280
+ function reconcileDeletedStreamAccelerationState(db: SqliteDurableStore): void {
281
+ let offset = 0;
282
+ const pageSize = 1000;
283
+ for (;;) {
284
+ const streams = db.listDeletedStreams(pageSize, offset);
285
+ for (const stream of streams) {
286
+ db.deleteAccelerationState(stream);
287
+ }
288
+ if (streams.length < pageSize) break;
289
+ offset += streams.length;
290
+ }
291
+ }
292
+
146
293
  function computeCursor(nowMs: number, provided: string | null): string {
147
294
  let cursor = Math.floor(nowMs / 1000);
148
295
  if (provided && /^[0-9]+$/.test(provided)) {
@@ -152,16 +299,16 @@ function computeCursor(nowMs: number, provided: string | null): string {
152
299
  return String(cursor);
153
300
  }
154
301
 
155
- function concatPayloads(parts: Uint8Array[]): Uint8Array {
156
- let total = 0;
157
- for (const p of parts) total += p.byteLength;
158
- const out = new Uint8Array(total);
159
- let off = 0;
160
- for (const p of parts) {
161
- out.set(p, off);
162
- off += p.byteLength;
302
+ function concatPayloads(parts: Uint8Array[]): Buffer {
303
+ return Buffer.concat(parts.map((part) => Buffer.from(part.buffer, part.byteOffset, part.byteLength)));
304
+ }
305
+
306
+ function bodyBufferFromBytes(bytes: Uint8Array): ArrayBuffer {
307
+ const buffer = bytes.buffer;
308
+ if (bytes.byteOffset === 0 && bytes.byteLength === buffer.byteLength) {
309
+ return buffer as ArrayBuffer;
163
310
  }
164
- return out;
311
+ return buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
165
312
  }
166
313
 
167
314
  function keyBytesFromString(s: string | null): Uint8Array | null {
@@ -183,15 +330,78 @@ function extractRoutingKey(reg: SchemaRegistry, value: any): Result<Uint8Array |
183
330
  return Result.ok(keyBytesFromString(resolved.value));
184
331
  }
185
332
 
186
- function schemaVersionForOffset(reg: SchemaRegistry, offset: bigint): number {
187
- if (!reg.boundaries || reg.boundaries.length === 0) return 0;
188
- const off = Number(offset);
189
- let version = 0;
190
- for (const b of reg.boundaries) {
191
- if (b.offset <= off) version = b.version;
192
- else break;
333
+ function timestampToIsoString(value: bigint | null): string | null {
334
+ return value == null ? null : new Date(Number(value)).toISOString();
335
+ }
336
+
337
+ function weakEtag(namespace: string, body: string): string {
338
+ const hash = createHash("sha1").update(body).digest("hex");
339
+ return `W/"${namespace}:${hash}"`;
340
+ }
341
+
342
+ function configuredExactIndexes(search: SearchConfig | undefined): Array<{ name: string; kind: string; configHash: string }> {
343
+ if (!search) return [];
344
+ return Object.entries(search.fields)
345
+ .filter(([, field]) => field.exact === true && field.kind !== "text")
346
+ .map(([name, field]) => ({
347
+ name,
348
+ kind: field.kind,
349
+ configHash: hashSecondaryIndexField({ name, config: field }),
350
+ }))
351
+ .sort((a, b) => a.name.localeCompare(b.name));
352
+ }
353
+
354
+ function configuredSearchFamilies(search: SearchConfig | undefined): Array<{ family: "col" | "fts" | "agg" | "mblk"; fields: string[] }> {
355
+ if (!search) return [];
356
+ const out: Array<{ family: "col" | "fts" | "agg" | "mblk"; fields: string[] }> = [];
357
+ const colFields = Object.entries(search.fields)
358
+ .filter(([, field]) => field.column === true)
359
+ .map(([name]) => name)
360
+ .sort((a, b) => a.localeCompare(b));
361
+ if (colFields.length > 0) out.push({ family: "col", fields: colFields });
362
+ const ftsFields = Object.entries(search.fields)
363
+ .filter(([, field]) => field.kind === "text" || (field.kind === "keyword" && field.prefix === true))
364
+ .map(([name]) => name)
365
+ .sort((a, b) => a.localeCompare(b));
366
+ if (ftsFields.length > 0) out.push({ family: "fts", fields: ftsFields });
367
+ const aggRollups = Object.keys(search.rollups ?? {}).sort((a, b) => a.localeCompare(b));
368
+ if (aggRollups.length > 0) out.push({ family: "agg", fields: aggRollups });
369
+ if (search.profile === "metrics") out.push({ family: "mblk", fields: ["metrics"] });
370
+ return out;
371
+ }
372
+
373
+ function parseCompanionSections(value: string): Set<string> {
374
+ try {
375
+ const parsed = JSON.parse(value);
376
+ return new Set(Array.isArray(parsed) ? parsed.filter((entry) => typeof entry === "string") : []);
377
+ } catch {
378
+ return new Set();
379
+ }
380
+ }
381
+
382
+ function parseCompanionSectionSizes(value: string): Record<string, number> {
383
+ try {
384
+ const parsed = JSON.parse(value);
385
+ if (!parsed || typeof parsed !== "object") return {};
386
+ const out: Record<string, number> = {};
387
+ for (const [key, raw] of Object.entries(parsed)) {
388
+ if (typeof raw === "number" && Number.isFinite(raw) && raw >= 0) out[key] = raw;
389
+ }
390
+ return out;
391
+ } catch {
392
+ return {};
193
393
  }
194
- return version;
394
+ }
395
+
396
+ function contiguousCoveredSegmentCount(rows: Array<{ segment_index: number; sections_json: string }>, family: string): number {
397
+ let expected = 0;
398
+ for (const row of rows) {
399
+ if (row.segment_index < expected) continue;
400
+ if (row.segment_index > expected) break;
401
+ if (!parseCompanionSections(row.sections_json).has(family)) break;
402
+ expected += 1;
403
+ }
404
+ return expected;
195
405
  }
196
406
 
197
407
  export type App = {
@@ -206,13 +416,21 @@ export type App = {
206
416
  reader: StreamReader;
207
417
  segmenter: SegmenterController;
208
418
  uploader: UploaderController;
209
- indexer?: IndexManager;
419
+ indexer?: StreamIndexLookup;
210
420
  metrics: Metrics;
211
421
  registry: SchemaRegistryStore;
212
- touch: TouchInterpreterManager;
422
+ profiles: StreamProfileStore;
423
+ touch: TouchProcessorManager;
213
424
  stats?: StatsCollector;
214
425
  backpressure?: BackpressureGate;
215
- memory?: MemoryGuard;
426
+ memory?: MemoryPressureMonitor;
427
+ concurrency?: {
428
+ ingest: ConcurrencyGate;
429
+ read: ConcurrencyGate;
430
+ search: ConcurrencyGate;
431
+ asyncIndex: ConcurrencyGate;
432
+ };
433
+ memorySampler?: RuntimeMemorySampler;
216
434
  };
217
435
  };
218
436
 
@@ -222,11 +440,15 @@ export type CreateAppRuntimeArgs = {
222
440
  ingest: IngestQueue;
223
441
  notifier: StreamNotifier;
224
442
  registry: SchemaRegistryStore;
225
- touch: TouchInterpreterManager;
443
+ profiles: StreamProfileStore;
444
+ touch: TouchProcessorManager;
226
445
  stats?: StatsCollector;
227
446
  backpressure?: BackpressureGate;
228
- memory: MemoryGuard;
447
+ memory: MemoryPressureMonitor;
448
+ asyncIndexGate: ConcurrencyGate;
449
+ foregroundActivity: ForegroundActivityTracker;
229
450
  metrics: Metrics;
451
+ memorySampler?: RuntimeMemorySampler;
230
452
  };
231
453
 
232
454
  type AppRuntimeDeps = {
@@ -234,8 +456,17 @@ type AppRuntimeDeps = {
234
456
  reader: StreamReader;
235
457
  segmenter: SegmenterController;
236
458
  uploader: UploaderController;
237
- indexer?: IndexManager;
459
+ indexer?: StreamIndexLookup;
460
+ segmentDiskCache?: SegmentDiskCache;
238
461
  uploadSchemaRegistry: (stream: string, registry: SchemaRegistry) => Promise<void>;
462
+ getRuntimeMemorySnapshot?: () => RuntimeMemorySubsystemSnapshot;
463
+ getLocalStorageUsage?: (stream: string) => {
464
+ segment_cache_bytes: number;
465
+ routing_index_cache_bytes: number;
466
+ exact_index_cache_bytes: number;
467
+ lexicon_index_cache_bytes: number;
468
+ companion_cache_bytes: number;
469
+ };
239
470
  start(): void;
240
471
  };
241
472
 
@@ -244,51 +475,582 @@ export type CreateAppCoreOptions = {
244
475
  createRuntime(args: CreateAppRuntimeArgs): AppRuntimeDeps;
245
476
  };
246
477
 
478
+ function reduceConcurrencyLimit(limit: number): number {
479
+ return Math.max(1, Math.ceil(Math.max(1, limit) / 2));
480
+ }
481
+
482
+ function gateSnapshot(configuredLimit: number, gate: ConcurrencyGate) {
483
+ return {
484
+ configured_limit: configuredLimit,
485
+ current_limit: gate.getLimit(),
486
+ active: gate.getActive(),
487
+ queued: gate.getQueued(),
488
+ };
489
+ }
490
+
247
491
  export function createAppCore(cfg: Config, opts: CreateAppCoreOptions): App {
248
492
  mkdirSync(cfg.rootDir, { recursive: true });
249
493
  cleanupTempSegments(cfg.rootDir);
250
494
 
251
495
  const db = new SqliteDurableStore(cfg.dbPath, { cacheBytes: cfg.sqliteCacheBytes });
252
496
  db.resetSegmentInProgress();
497
+ reconcileDeletedStreamAccelerationState(db);
253
498
  const stats = opts.stats;
499
+ const metrics = new Metrics();
254
500
  const backpressure =
255
501
  cfg.localBacklogMaxBytes > 0
256
502
  ? new BackpressureGate(cfg.localBacklogMaxBytes, db.sumPendingBytes() + db.sumPendingSegmentBytes())
257
503
  : undefined;
258
- const memory = new MemoryGuard(cfg.memoryLimitBytes, {
504
+ const memorySampler =
505
+ cfg.memorySamplerPath != null
506
+ ? new RuntimeMemorySampler(cfg.memorySamplerPath, {
507
+ intervalMs: cfg.memorySamplerIntervalMs,
508
+ scope: "main",
509
+ })
510
+ : undefined;
511
+ memorySampler?.start();
512
+ const ingestGate = new ConcurrencyGate(cfg.ingestConcurrency);
513
+ const readGate = new ConcurrencyGate(cfg.readConcurrency);
514
+ const searchGate = new ConcurrencyGate(cfg.searchConcurrency);
515
+ const asyncIndexGate = new ConcurrencyGate(cfg.asyncIndexConcurrency);
516
+ const foregroundActivity = new ForegroundActivityTracker();
517
+ const memory = new MemoryPressureMonitor(cfg.memoryLimitBytes, {
259
518
  onSample: (rss, overLimit) => {
260
519
  metrics.record("process.rss.bytes", rss, "bytes");
261
520
  if (overLimit) metrics.record("process.rss.over_limit", 1, "count");
521
+ searchGate.setLimit(overLimit ? reduceConcurrencyLimit(cfg.searchConcurrency) : cfg.searchConcurrency);
522
+ asyncIndexGate.setLimit(overLimit ? reduceConcurrencyLimit(cfg.asyncIndexConcurrency) : cfg.asyncIndexConcurrency);
262
523
  },
263
- heapSnapshotPath: `${cfg.rootDir}/heap.heapsnapshot`,
524
+ heapSnapshotPath: cfg.heapSnapshotPath ?? undefined,
264
525
  });
265
526
  memory.start();
266
- const metrics = new Metrics();
267
- const ingest = new IngestQueue(cfg, db, stats, backpressure, memory, metrics);
527
+ const ingest = new IngestQueue(cfg, db, stats, backpressure, metrics);
268
528
  const notifier = new StreamNotifier();
269
529
  const registry = new SchemaRegistryStore(db);
270
- const touch = new TouchInterpreterManager(cfg, db, ingest, notifier, registry, backpressure);
530
+ const profiles = new StreamProfileStore(db, registry);
531
+ const touch = new TouchProcessorManager(cfg, db, ingest, notifier, profiles, backpressure);
271
532
  const runtime = opts.createRuntime({
272
533
  config: cfg,
273
534
  db,
274
535
  ingest,
275
536
  notifier,
276
537
  registry,
538
+ profiles,
277
539
  touch,
278
540
  stats,
279
541
  backpressure,
280
542
  memory,
543
+ asyncIndexGate,
544
+ foregroundActivity,
281
545
  metrics,
546
+ memorySampler,
282
547
  });
283
- const { store, reader, segmenter, uploader, indexer, uploadSchemaRegistry } = runtime;
284
- const metricsEmitter = new MetricsEmitter(metrics, ingest, cfg.metricsFlushIntervalMs);
285
- const expirySweeper = new ExpirySweeper(cfg, db);
548
+ const { store, reader, segmenter, uploader, indexer, uploadSchemaRegistry, getRuntimeMemorySnapshot, getLocalStorageUsage } = runtime;
549
+ const runtimeHighWater: RuntimeMemoryHighWaterSnapshot = {
550
+ process: {},
551
+ process_breakdown: {},
552
+ sqlite: {},
553
+ runtime_bytes: {},
554
+ runtime_totals: {},
555
+ };
556
+
557
+ const observeHighWaterValue = (target: Record<string, RuntimeHighWaterMark>, key: string, value: number, at: string): void => {
558
+ const next = Math.max(0, Math.floor(value));
559
+ const existing = target[key];
560
+ if (!existing || next > existing.value) {
561
+ target[key] = {
562
+ value: next,
563
+ at,
564
+ };
565
+ }
566
+ };
567
+
568
+ const buildRuntimeBytes = (runtimeMemory: RuntimeMemorySnapshot): Record<string, Record<string, number>> => {
569
+ const groups: Record<string, Record<string, number>> = {};
570
+ for (const [kind, values] of Object.entries(runtimeMemory.subsystems)) {
571
+ if (kind === "counts") continue;
572
+ groups[kind] = values;
573
+ }
574
+ return groups;
575
+ };
576
+
577
+ const buildTopStreamContributors = (limit = 5) => {
578
+ const safeLimit = Math.max(1, Math.min(limit, 20));
579
+ const localStorageRows: Array<{
580
+ stream: string;
581
+ bytes: number;
582
+ wal_retained_bytes: number;
583
+ segment_cache_bytes: number;
584
+ index_cache_bytes: number;
585
+ }> = [];
586
+ const pendingWalRows: Array<{ stream: string; pending_wal_bytes: number; pending_rows: number }> = [];
587
+ let offset = 0;
588
+ const pageSize = 1000;
589
+ for (;;) {
590
+ const rows = db.listStreams(pageSize, offset);
591
+ if (rows.length === 0) break;
592
+ for (const row of rows) {
593
+ if (db.isDeleted(row)) continue;
594
+ const usage = getLocalStorageUsage?.(row.stream) ?? { segment_cache_bytes: 0 };
595
+ const walRetainedBytes = Number(row.pending_bytes);
596
+ const segmentCacheBytes = Math.max(0, Math.floor(Number((usage as Record<string, number>).segment_cache_bytes ?? 0)));
597
+ const indexCacheBytes = Math.max(
598
+ 0,
599
+ Math.floor(
600
+ Number((usage as Record<string, number>).routing_index_cache_bytes ?? 0) +
601
+ Number((usage as Record<string, number>).exact_index_cache_bytes ?? 0) +
602
+ Number((usage as Record<string, number>).lexicon_index_cache_bytes ?? 0) +
603
+ Number((usage as Record<string, number>).companion_cache_bytes ?? 0)
604
+ )
605
+ );
606
+ localStorageRows.push({
607
+ stream: row.stream,
608
+ bytes: Math.max(0, walRetainedBytes + segmentCacheBytes + indexCacheBytes),
609
+ wal_retained_bytes: Math.max(0, walRetainedBytes),
610
+ segment_cache_bytes: segmentCacheBytes,
611
+ index_cache_bytes: indexCacheBytes,
612
+ });
613
+ pendingWalRows.push({
614
+ stream: row.stream,
615
+ pending_wal_bytes: Math.max(0, walRetainedBytes),
616
+ pending_rows: Math.max(0, Number(row.pending_rows)),
617
+ });
618
+ }
619
+ if (rows.length < pageSize) break;
620
+ offset += rows.length;
621
+ }
622
+ localStorageRows.sort((a, b) => b.bytes - a.bytes || a.stream.localeCompare(b.stream));
623
+ pendingWalRows.sort((a, b) => b.pending_wal_bytes - a.pending_wal_bytes || a.stream.localeCompare(b.stream));
624
+ return {
625
+ local_storage_bytes: localStorageRows.slice(0, safeLimit),
626
+ pending_wal_bytes: pendingWalRows.slice(0, safeLimit),
627
+ touch_journal_filter_bytes: touch.getTopStreams(safeLimit),
628
+ notifier_waiters: notifier.getTopStreams(safeLimit),
629
+ };
630
+ };
631
+
632
+ const buildRuntimeMemorySnapshot = (): RuntimeMemorySnapshot => {
633
+ const processUsage = process.memoryUsage();
634
+ const subsystemSnapshot = getRuntimeMemorySnapshot?.() ?? {
635
+ subsystems: {
636
+ heap_estimates: {},
637
+ mapped_files: {},
638
+ disk_caches: {},
639
+ configured_budgets: {},
640
+ pipeline_buffers: {},
641
+ sqlite_runtime: {},
642
+ counts: {},
643
+ },
644
+ totals: {
645
+ heap_estimate_bytes: 0,
646
+ mapped_file_bytes: 0,
647
+ disk_cache_bytes: 0,
648
+ configured_budget_bytes: 0,
649
+ pipeline_buffer_bytes: 0,
650
+ sqlite_runtime_bytes: 0,
651
+ },
652
+ };
653
+ const sqliteRuntimeBytes = subsystemSnapshot.subsystems.sqlite_runtime ?? {};
654
+ const runtimeCounts = subsystemSnapshot.subsystems.counts ?? {};
655
+ const snapshot: RuntimeMemorySnapshot = {
656
+ process: {
657
+ rss_bytes: processUsage.rss,
658
+ heap_total_bytes: processUsage.heapTotal,
659
+ heap_used_bytes: processUsage.heapUsed,
660
+ external_bytes: processUsage.external,
661
+ array_buffers_bytes: processUsage.arrayBuffers,
662
+ },
663
+ process_breakdown: buildProcessMemoryBreakdown({
664
+ process: {
665
+ rss_bytes: processUsage.rss,
666
+ heap_total_bytes: processUsage.heapTotal,
667
+ heap_used_bytes: processUsage.heapUsed,
668
+ external_bytes: processUsage.external,
669
+ array_buffers_bytes: processUsage.arrayBuffers,
670
+ },
671
+ mappedFileBytes: subsystemSnapshot.totals.mapped_file_bytes,
672
+ sqliteRuntimeBytes: Number(sqliteRuntimeBytes["sqlite_memory_used_bytes"] ?? 0),
673
+ }),
674
+ sqlite: {
675
+ available: Number(runtimeCounts["sqlite_open_connections"] ?? 0) > 0 || Number(sqliteRuntimeBytes["sqlite_memory_used_bytes"] ?? 0) > 0,
676
+ source:
677
+ Number(runtimeCounts["sqlite_open_connections"] ?? 0) > 0 || Number(sqliteRuntimeBytes["sqlite_memory_used_bytes"] ?? 0) > 0
678
+ ? "sqlite3_status64"
679
+ : "unavailable",
680
+ memory_used_bytes: Math.max(0, Math.floor(Number(sqliteRuntimeBytes["sqlite_memory_used_bytes"] ?? 0))),
681
+ memory_highwater_bytes: Math.max(
682
+ 0,
683
+ Math.floor(Number(sqliteRuntimeBytes["sqlite_memory_highwater_bytes"] ?? 0))
684
+ ),
685
+ pagecache_used_slots: Math.max(0, Math.floor(Number(runtimeCounts["sqlite_pagecache_used_slots"] ?? 0))),
686
+ pagecache_used_slots_highwater: Math.max(
687
+ 0,
688
+ Math.floor(Number(runtimeCounts["sqlite_pagecache_used_slots_highwater"] ?? 0))
689
+ ),
690
+ pagecache_overflow_bytes: Math.max(
691
+ 0,
692
+ Math.floor(Number(sqliteRuntimeBytes["sqlite_pagecache_overflow_bytes"] ?? 0))
693
+ ),
694
+ pagecache_overflow_highwater_bytes: Math.max(
695
+ 0,
696
+ Math.floor(Number(sqliteRuntimeBytes["sqlite_pagecache_overflow_highwater_bytes"] ?? 0))
697
+ ),
698
+ malloc_count: Math.max(0, Math.floor(Number(runtimeCounts["sqlite_malloc_count"] ?? 0))),
699
+ malloc_count_highwater: Math.max(
700
+ 0,
701
+ Math.floor(Number(runtimeCounts["sqlite_malloc_count_highwater"] ?? 0))
702
+ ),
703
+ open_connections: Math.max(0, Math.floor(Number(runtimeCounts["sqlite_open_connections"] ?? 0))),
704
+ prepared_statements: Math.max(
705
+ 0,
706
+ Math.floor(Number(runtimeCounts["sqlite_prepared_statements"] ?? 0))
707
+ ),
708
+ },
709
+ gc: memory.getGcStats(),
710
+ subsystems: subsystemSnapshot.subsystems,
711
+ totals: subsystemSnapshot.totals,
712
+ };
713
+ const ts = new Date().toISOString();
714
+ for (const [key, value] of Object.entries(snapshot.process)) observeHighWaterValue(runtimeHighWater.process, key, value, ts);
715
+ for (const [key, value] of Object.entries(snapshot.process_breakdown)) {
716
+ if (typeof value === "number") observeHighWaterValue(runtimeHighWater.process_breakdown, key, value, ts);
717
+ }
718
+ for (const [key, value] of Object.entries(snapshot.sqlite)) {
719
+ if (typeof value === "number") observeHighWaterValue(runtimeHighWater.sqlite, key, value, ts);
720
+ }
721
+ for (const [kind, values] of Object.entries(buildRuntimeBytes(snapshot))) {
722
+ const bucket = (runtimeHighWater.runtime_bytes[kind] ??= {});
723
+ for (const [key, value] of Object.entries(values)) observeHighWaterValue(bucket, key, value, ts);
724
+ }
725
+ for (const [key, value] of Object.entries(snapshot.totals)) observeHighWaterValue(runtimeHighWater.runtime_totals, key, value, ts);
726
+ if (snapshot.sqlite.memory_used_bytes > 0) observeHighWaterValue(runtimeHighWater.sqlite, "memory_used_bytes", snapshot.sqlite.memory_used_bytes, ts);
727
+ if (snapshot.sqlite.pagecache_overflow_bytes > 0)
728
+ observeHighWaterValue(runtimeHighWater.sqlite, "pagecache_overflow_bytes", snapshot.sqlite.pagecache_overflow_bytes, ts);
729
+ if (snapshot.sqlite.pagecache_used_slots > 0)
730
+ observeHighWaterValue(runtimeHighWater.sqlite, "pagecache_used_slots", snapshot.sqlite.pagecache_used_slots, ts);
731
+ if (snapshot.sqlite.malloc_count > 0) observeHighWaterValue(runtimeHighWater.sqlite, "malloc_count", snapshot.sqlite.malloc_count, ts);
732
+ return snapshot;
733
+ };
734
+
735
+ const buildLeakCandidateCounters = (): Record<string, number> => {
736
+ const runtimeMemory = buildRuntimeMemorySnapshot();
737
+ const runtimeCounts = runtimeMemory.subsystems.counts ?? {};
738
+ const countValue = (name: string): number => {
739
+ const raw = Number(runtimeCounts[name] ?? 0);
740
+ if (!Number.isFinite(raw)) return 0;
741
+ return Math.max(0, Math.floor(raw));
742
+ };
743
+ const touchMemory = touch.getMemoryStats();
744
+ const notifierMemory = notifier.getMemoryStats();
745
+ const metricsMemory = metrics.getMemoryStats();
746
+ return {
747
+ "tieredstore.mem.leak_candidate.segment_cache.pinned_entries": countValue("segment_pinned_files"),
748
+ "tieredstore.mem.leak_candidate.lexicon_file_cache.pinned_entries": countValue("lexicon_pinned_files"),
749
+ "tieredstore.mem.leak_candidate.companion_file_cache.pinned_entries": countValue("companion_pinned_files"),
750
+ "tieredstore.mem.leak_candidate.routing_run_disk_cache.pinned_entries": countValue("routing_run_disk_cache_pinned_entries"),
751
+ "tieredstore.mem.leak_candidate.exact_run_disk_cache.pinned_entries": countValue("exact_run_disk_cache_pinned_entries"),
752
+ "tieredstore.mem.leak_candidate.touch.journals.active_count": touchMemory.journals,
753
+ "tieredstore.mem.leak_candidate.touch.journals.created_total": touchMemory.journalsCreatedTotal,
754
+ "tieredstore.mem.leak_candidate.touch.journals.filter_bytes_total": touchMemory.journalFilterBytesTotal,
755
+ "tieredstore.mem.leak_candidate.touch.journal.default_filter_bytes": DEFAULT_TOUCH_JOURNAL_FILTER_BYTES,
756
+ "tieredstore.mem.leak_candidate.touch.maps.fine_lag_coarse_only_streams": touchMemory.fineLagCoarseOnlyStreams,
757
+ "tieredstore.mem.leak_candidate.touch.maps.touch_mode_streams": touchMemory.touchModeStreams,
758
+ "tieredstore.mem.leak_candidate.touch.maps.fine_token_bucket_streams": touchMemory.fineTokenBucketStreams,
759
+ "tieredstore.mem.leak_candidate.touch.maps.hot_fine_streams": touchMemory.hotFineStreams,
760
+ "tieredstore.mem.leak_candidate.touch.maps.lag_source_offset_streams": touchMemory.lagSourceOffsetStreams,
761
+ "tieredstore.mem.leak_candidate.touch.maps.restricted_template_bucket_streams": touchMemory.restrictedTemplateBucketStreams,
762
+ "tieredstore.mem.leak_candidate.touch.maps.runtime_totals_streams": touchMemory.runtimeTotalsStreams,
763
+ "tieredstore.mem.leak_candidate.touch.maps.zero_row_backlog_streams": touchMemory.zeroRowBacklogStreakStreams,
764
+ "tieredstore.mem.leak_candidate.live_template.last_seen_entries": touchMemory.templateLastSeenEntries,
765
+ "tieredstore.mem.leak_candidate.live_template.dirty_last_seen_entries": touchMemory.templateDirtyLastSeenEntries,
766
+ "tieredstore.mem.leak_candidate.live_template.rate_state_streams": touchMemory.templateRateStateStreams,
767
+ "tieredstore.mem.leak_candidate.live_metrics.counter_streams": touchMemory.liveMetricsCounterStreams,
768
+ "tieredstore.mem.leak_candidate.notifier.latest_seq_streams": notifierMemory.latestSeqStreams,
769
+ "tieredstore.mem.leak_candidate.notifier.details_version_streams": notifierMemory.detailsVersionStreams,
770
+ "tieredstore.mem.leak_candidate.metrics.series": metricsMemory.seriesCount,
771
+ "tieredstore.mem.leak_candidate.secondary_index.stream_idle_ticks_streams": countValue("secondary_index_stream_idle_ticks"),
772
+ "tieredstore.mem.leak_candidate.mock_r2.in_memory_bytes": countValue("mock_r2_in_memory_bytes"),
773
+ "tieredstore.mem.leak_candidate.mock_r2.object_count": countValue("mock_r2_object_count"),
774
+ };
775
+ };
286
776
 
287
- db.ensureStream("__stream_metrics__", { contentType: "application/json" });
777
+ const buildServerMem = () => {
778
+ const runtimeMemory = buildRuntimeMemorySnapshot();
779
+ return {
780
+ ts: new Date().toISOString(),
781
+ process: runtimeMemory.process,
782
+ process_breakdown: runtimeMemory.process_breakdown,
783
+ sqlite: runtimeMemory.sqlite,
784
+ gc: runtimeMemory.gc,
785
+ high_water: runtimeHighWater,
786
+ counters: buildLeakCandidateCounters(),
787
+ runtime_counts: runtimeMemory.subsystems.counts,
788
+ runtime_bytes: buildRuntimeBytes(runtimeMemory),
789
+ runtime_totals: runtimeMemory.totals,
790
+ top_streams: buildTopStreamContributors(),
791
+ };
792
+ };
793
+ memorySampler?.setSubsystemProvider(() => buildRuntimeMemorySnapshot().subsystems);
794
+ const buildServerDetails = () => {
795
+ const runtimeMemory = buildRuntimeMemorySnapshot();
796
+ return {
797
+ auto_tune: {
798
+ enabled: cfg.autoTunePresetMb != null,
799
+ requested_memory_mb: cfg.autoTuneRequestedMemoryMb,
800
+ preset_mb: cfg.autoTunePresetMb,
801
+ effective_memory_limit_mb: cfg.autoTuneEffectiveMemoryLimitMb,
802
+ },
803
+ configured_limits: {
804
+ caches: {
805
+ sqlite_cache_bytes: cfg.sqliteCacheBytes,
806
+ worker_sqlite_cache_bytes: cfg.workerSqliteCacheBytes,
807
+ index_run_memory_cache_bytes: cfg.indexRunMemoryCacheBytes,
808
+ index_run_disk_cache_bytes: cfg.indexRunCacheMaxBytes,
809
+ lexicon_index_cache_bytes: cfg.lexiconIndexCacheMaxBytes,
810
+ segment_cache_bytes: cfg.segmentCacheMaxBytes,
811
+ companion_toc_cache_bytes: cfg.searchCompanionTocCacheBytes,
812
+ companion_section_cache_bytes: cfg.searchCompanionSectionCacheBytes,
813
+ companion_file_cache_bytes: cfg.searchCompanionFileCacheMaxBytes,
814
+ },
815
+ concurrency: {
816
+ ingest: cfg.ingestConcurrency,
817
+ read: cfg.readConcurrency,
818
+ search: cfg.searchConcurrency,
819
+ async_index: cfg.asyncIndexConcurrency,
820
+ upload: cfg.uploadConcurrency,
821
+ index_build: cfg.indexBuildConcurrency,
822
+ index_compact: cfg.indexCompactionConcurrency,
823
+ },
824
+ ingest: {
825
+ max_batch_requests: cfg.ingestMaxBatchRequests,
826
+ max_batch_bytes: cfg.ingestMaxBatchBytes,
827
+ max_queue_requests: cfg.ingestMaxQueueRequests,
828
+ max_queue_bytes: cfg.ingestMaxQueueBytes,
829
+ busy_timeout_ms: cfg.ingestBusyTimeoutMs,
830
+ local_backlog_max_bytes: cfg.localBacklogMaxBytes,
831
+ },
832
+ search: {
833
+ companion_batch_segments: cfg.searchCompanionBuildBatchSegments,
834
+ companion_yield_blocks: cfg.searchCompanionYieldBlocks,
835
+ wal_overlay_quiet_period_ms: cfg.searchWalOverlayQuietPeriodMs,
836
+ wal_overlay_max_bytes: cfg.searchWalOverlayMaxBytes,
837
+ },
838
+ segmenting: {
839
+ segment_max_bytes: cfg.segmentMaxBytes,
840
+ segment_target_rows: cfg.segmentTargetRows,
841
+ segmenter_workers: cfg.segmenterWorkers,
842
+ },
843
+ timeouts: {
844
+ append_request_timeout_ms: APPEND_REQUEST_TIMEOUT_MS,
845
+ search_request_timeout_ms: SEARCH_REQUEST_TIMEOUT_MS,
846
+ resolver_timeout_ms: HTTP_RESOLVER_TIMEOUT_MS,
847
+ object_store_timeout_ms: cfg.objectStoreTimeoutMs,
848
+ },
849
+ memory: {
850
+ pressure_limit_bytes: cfg.memoryLimitBytes,
851
+ },
852
+ },
853
+ runtime: {
854
+ memory: {
855
+ pressure_active: memory.isOverLimit(),
856
+ pressure_limit_bytes: memory.getLimitBytes(),
857
+ last_rss_bytes: memory.getLastRssBytes(),
858
+ max_rss_bytes: memory.getMaxRssBytes(),
859
+ process: runtimeMemory.process,
860
+ process_breakdown: runtimeMemory.process_breakdown,
861
+ sqlite: runtimeMemory.sqlite,
862
+ gc: runtimeMemory.gc,
863
+ subsystems: runtimeMemory.subsystems,
864
+ totals: runtimeMemory.totals,
865
+ high_water: runtimeHighWater,
866
+ },
867
+ ingest_queue: {
868
+ requests: ingest.getQueueStats().requests,
869
+ bytes: ingest.getQueueStats().bytes,
870
+ full: ingest.isQueueFull(),
871
+ },
872
+ local_backpressure: {
873
+ enabled: backpressure?.enabled() ?? false,
874
+ current_bytes: backpressure?.getCurrentBytes() ?? 0,
875
+ max_bytes: backpressure?.getMaxBytes() ?? 0,
876
+ over_limit: backpressure?.isOverLimit() ?? false,
877
+ },
878
+ uploads: {
879
+ pending_segments: uploader.countSegmentsWaiting(),
880
+ },
881
+ concurrency: {
882
+ ingest: gateSnapshot(cfg.ingestConcurrency, ingestGate),
883
+ read: gateSnapshot(cfg.readConcurrency, readGate),
884
+ search: gateSnapshot(cfg.searchConcurrency, searchGate),
885
+ async_index: gateSnapshot(cfg.asyncIndexConcurrency, asyncIndexGate),
886
+ },
887
+ top_streams: buildTopStreamContributors(),
888
+ },
889
+ };
890
+ };
891
+ const collectRuntimeMetrics = () => {
892
+ const queue = ingest.getQueueStats();
893
+ const emitGate = (name: string, configuredLimit: number, gate: ConcurrencyGate) => {
894
+ metrics.record("tieredstore.concurrency.limit", configuredLimit, "count", { gate: name, kind: "configured" });
895
+ metrics.record("tieredstore.concurrency.limit", gate.getLimit(), "count", { gate: name, kind: "effective" });
896
+ metrics.record("tieredstore.concurrency.active", gate.getActive(), "count", { gate: name });
897
+ metrics.record("tieredstore.concurrency.queued", gate.getQueued(), "count", { gate: name });
898
+ };
899
+ emitGate("ingest", cfg.ingestConcurrency, ingestGate);
900
+ emitGate("read", cfg.readConcurrency, readGate);
901
+ emitGate("search", cfg.searchConcurrency, searchGate);
902
+ emitGate("async_index", cfg.asyncIndexConcurrency, asyncIndexGate);
903
+ metrics.record("tieredstore.ingest.queue.capacity.requests", cfg.ingestMaxQueueRequests, "count");
904
+ metrics.record("tieredstore.ingest.queue.capacity.bytes", cfg.ingestMaxQueueBytes, "bytes");
905
+ metrics.record("tieredstore.upload.pending_segments", uploader.countSegmentsWaiting(), "count");
906
+ metrics.record("tieredstore.upload.concurrency.limit", cfg.uploadConcurrency, "count");
907
+ if (cfg.memoryLimitBytes > 0) metrics.record("process.memory.limit.bytes", cfg.memoryLimitBytes, "bytes");
908
+ const lastRss = memory.getLastRssBytes();
909
+ if (lastRss > 0) metrics.record("process.rss.current.bytes", lastRss, "bytes");
910
+ const maxRss = memory.snapshotMaxRssBytes();
911
+ if (maxRss > 0) metrics.record("process.rss.max_interval.bytes", maxRss, "bytes");
912
+ const runtimeMemory = buildRuntimeMemorySnapshot();
913
+ metrics.record("process.heap.total.bytes", runtimeMemory.process.heap_total_bytes, "bytes");
914
+ metrics.record("process.heap.used.bytes", runtimeMemory.process.heap_used_bytes, "bytes");
915
+ metrics.record("process.external.bytes", runtimeMemory.process.external_bytes, "bytes");
916
+ metrics.record("process.array_buffers.bytes", runtimeMemory.process.array_buffers_bytes, "bytes");
917
+ if (runtimeMemory.process_breakdown.rss_anon_bytes != null) {
918
+ metrics.record("process.memory.rss.anon.bytes", runtimeMemory.process_breakdown.rss_anon_bytes, "bytes");
919
+ }
920
+ if (runtimeMemory.process_breakdown.rss_file_bytes != null) {
921
+ metrics.record("process.memory.rss.file.bytes", runtimeMemory.process_breakdown.rss_file_bytes, "bytes");
922
+ }
923
+ if (runtimeMemory.process_breakdown.rss_shmem_bytes != null) {
924
+ metrics.record("process.memory.rss.shmem.bytes", runtimeMemory.process_breakdown.rss_shmem_bytes, "bytes");
925
+ }
926
+ if (runtimeMemory.process_breakdown.unattributed_anon_bytes != null) {
927
+ metrics.record("process.memory.unattributed_anon.bytes", runtimeMemory.process_breakdown.unattributed_anon_bytes, "bytes");
928
+ }
929
+ metrics.record("process.memory.js_managed.bytes", runtimeMemory.process_breakdown.js_managed_bytes, "bytes");
930
+ metrics.record(
931
+ "process.memory.js_external_non_array_buffers.bytes",
932
+ runtimeMemory.process_breakdown.js_external_non_array_buffers_bytes,
933
+ "bytes"
934
+ );
935
+ metrics.record("process.memory.unattributed.bytes", runtimeMemory.process_breakdown.unattributed_rss_bytes, "bytes");
936
+ metrics.record("tieredstore.sqlite.memory.used.bytes", runtimeMemory.sqlite.memory_used_bytes, "bytes");
937
+ metrics.record("tieredstore.sqlite.memory.high_water.bytes", runtimeMemory.sqlite.memory_highwater_bytes, "bytes");
938
+ metrics.record("tieredstore.sqlite.pagecache.used", runtimeMemory.sqlite.pagecache_used_slots, "count");
939
+ metrics.record("tieredstore.sqlite.pagecache.high_water", runtimeMemory.sqlite.pagecache_used_slots_highwater, "count");
940
+ metrics.record("tieredstore.sqlite.pagecache.overflow.bytes", runtimeMemory.sqlite.pagecache_overflow_bytes, "bytes");
941
+ metrics.record(
942
+ "tieredstore.sqlite.pagecache.overflow.high_water.bytes",
943
+ runtimeMemory.sqlite.pagecache_overflow_highwater_bytes,
944
+ "bytes"
945
+ );
946
+ metrics.record("tieredstore.sqlite.malloc.count", runtimeMemory.sqlite.malloc_count, "count");
947
+ metrics.record("tieredstore.sqlite.malloc.high_water.count", runtimeMemory.sqlite.malloc_count_highwater, "count");
948
+ metrics.record("tieredstore.sqlite.open_connections", runtimeMemory.sqlite.open_connections, "count");
949
+ metrics.record("tieredstore.sqlite.prepared_statements", runtimeMemory.sqlite.prepared_statements, "count");
950
+ for (const [kind, values] of Object.entries(runtimeMemory.subsystems)) {
951
+ if (kind === "counts") continue;
952
+ for (const [subsystem, value] of Object.entries(values)) {
953
+ metrics.record("tieredstore.memory.subsystem.bytes", value, "bytes", { kind, subsystem });
954
+ }
955
+ }
956
+ for (const [subsystem, value] of Object.entries(runtimeMemory.subsystems.counts)) {
957
+ metrics.record("tieredstore.memory.subsystem.count", value, "count", { subsystem });
958
+ }
959
+ metrics.record("tieredstore.memory.tracked.bytes", runtimeMemory.totals.heap_estimate_bytes, "bytes", { kind: "heap_estimate" });
960
+ metrics.record("tieredstore.memory.tracked.bytes", runtimeMemory.totals.mapped_file_bytes, "bytes", { kind: "mapped_file" });
961
+ metrics.record("tieredstore.memory.tracked.bytes", runtimeMemory.totals.disk_cache_bytes, "bytes", { kind: "disk_cache" });
962
+ metrics.record("tieredstore.memory.tracked.bytes", runtimeMemory.totals.configured_budget_bytes, "bytes", { kind: "configured_budget" });
963
+ metrics.record("tieredstore.memory.tracked.bytes", runtimeMemory.totals.pipeline_buffer_bytes, "bytes", { kind: "pipeline_buffer" });
964
+ metrics.record("tieredstore.memory.tracked.bytes", runtimeMemory.totals.sqlite_runtime_bytes, "bytes", { kind: "sqlite_runtime" });
965
+ const memLeakCounters = buildLeakCandidateCounters();
966
+ for (const [metricName, value] of Object.entries(memLeakCounters)) {
967
+ const unit = metricName.endsWith("_bytes") ? "bytes" : "count";
968
+ metrics.record(metricName, value, unit);
969
+ }
970
+ metrics.record("process.gc.forced.count", runtimeMemory.gc.forced_gc_count, "count");
971
+ metrics.record("process.gc.reclaimed.bytes", runtimeMemory.gc.forced_gc_reclaimed_bytes_total, "bytes", { kind: "total" });
972
+ if (runtimeMemory.gc.last_forced_gc_reclaimed_bytes != null) {
973
+ metrics.record("process.gc.reclaimed.bytes", runtimeMemory.gc.last_forced_gc_reclaimed_bytes, "bytes", { kind: "last" });
974
+ }
975
+ if (runtimeMemory.gc.last_forced_gc_at_ms != null) {
976
+ metrics.record("process.gc.last_forced_at_ms", runtimeMemory.gc.last_forced_gc_at_ms, "count");
977
+ }
978
+ metrics.record("process.heap.snapshot.count", runtimeMemory.gc.heap_snapshots_written, "count");
979
+ if (runtimeMemory.gc.last_heap_snapshot_at_ms != null) {
980
+ metrics.record("process.heap.snapshot.last_at_ms", runtimeMemory.gc.last_heap_snapshot_at_ms, "count");
981
+ }
982
+ for (const [metricName, entry] of Object.entries(runtimeHighWater.process)) {
983
+ metrics.record("process.memory.high_water.bytes", entry.value, "bytes", { metric: metricName });
984
+ }
985
+ for (const [metricName, entry] of Object.entries(runtimeHighWater.process_breakdown)) {
986
+ metrics.record("process.memory.high_water.bytes", entry.value, "bytes", { metric: metricName });
987
+ }
988
+ for (const [metricName, entry] of Object.entries(runtimeHighWater.runtime_totals)) {
989
+ metrics.record("tieredstore.memory.high_water.bytes", entry.value, "bytes", { kind: "runtime_total", metric: metricName });
990
+ }
991
+ for (const [kind, entries] of Object.entries(runtimeHighWater.runtime_bytes)) {
992
+ for (const [metricName, entry] of Object.entries(entries)) {
993
+ metrics.record("tieredstore.memory.high_water.bytes", entry.value, "bytes", {
994
+ kind: "runtime_subsystem",
995
+ subsystem_kind: kind,
996
+ metric: metricName,
997
+ });
998
+ }
999
+ }
1000
+ for (const [metricName, entry] of Object.entries(runtimeHighWater.sqlite)) {
1001
+ const unit =
1002
+ metricName.includes("bytes") || metricName.includes("memory") || metricName.includes("overflow") ? "bytes" : "count";
1003
+ metrics.record("tieredstore.sqlite.high_water", entry.value, unit, { metric: metricName });
1004
+ }
1005
+ metrics.record("process.memory.pressure", memory.isOverLimit() ? 1 : 0, "count");
1006
+ if (backpressure) {
1007
+ metrics.record("tieredstore.backpressure.current.bytes", backpressure.getCurrentBytes(), "bytes");
1008
+ metrics.record("tieredstore.backpressure.limit.bytes", backpressure.getMaxBytes(), "bytes");
1009
+ metrics.record("tieredstore.backpressure.pressure", backpressure.isOverLimit() ? 1 : 0, "count");
1010
+ }
1011
+ if (cfg.autoTunePresetMb != null) {
1012
+ metrics.record("tieredstore.auto_tune.preset_mb", cfg.autoTunePresetMb, "count");
1013
+ }
1014
+ if (cfg.autoTuneEffectiveMemoryLimitMb != null) {
1015
+ metrics.record("tieredstore.auto_tune.effective_memory_limit_mb", cfg.autoTuneEffectiveMemoryLimitMb, "count");
1016
+ }
1017
+ };
1018
+ const metricsEmitter = new MetricsEmitter(metrics, ingest, cfg.metricsFlushIntervalMs, {
1019
+ onAppended: ({ lastOffset, stream }) => {
1020
+ notifier.notify(stream, lastOffset);
1021
+ notifier.notifyDetailsChanged(stream);
1022
+ },
1023
+ collectRuntimeMetrics,
1024
+ });
1025
+ const expirySweeper = new ExpirySweeper(cfg, db);
1026
+ const streamSizeReconciler = new StreamSizeReconciler(
1027
+ db,
1028
+ store,
1029
+ runtime.segmentDiskCache,
1030
+ (stream) => notifier.notifyDetailsChanged(stream)
1031
+ );
1032
+
1033
+ const metricsStreamRow = db.ensureStream(INTERNAL_METRICS_STREAM, { contentType: "application/json", profile: "metrics" });
1034
+ const metricsProfileRes = profiles.updateProfileResult(INTERNAL_METRICS_STREAM, metricsStreamRow, { kind: "metrics" });
1035
+ if (Result.isError(metricsProfileRes)) {
1036
+ throw dsError(`failed to initialize ${INTERNAL_METRICS_STREAM} profile: ${metricsProfileRes.error.message}`);
1037
+ }
1038
+ clearInternalMetricsAccelerationState(db);
288
1039
  runtime.start();
1040
+ if (metricsProfileRes.value.schemaRegistry) {
1041
+ void (async () => {
1042
+ try {
1043
+ await uploadSchemaRegistry(INTERNAL_METRICS_STREAM, metricsProfileRes.value.schemaRegistry!);
1044
+ await uploader.publishManifest(INTERNAL_METRICS_STREAM);
1045
+ } catch {
1046
+ // background best-effort; next manifest publication will reconcile
1047
+ }
1048
+ })();
1049
+ }
289
1050
  metricsEmitter.start();
290
1051
  expirySweeper.start();
291
1052
  touch.start();
1053
+ streamSizeReconciler.start();
292
1054
 
293
1055
  const buildJsonRows = (
294
1056
  stream: string,
@@ -300,7 +1062,12 @@ export function createAppCore(cfg: Config, opts: CreateAppCoreOptions): App {
300
1062
  if (Result.isError(regRes)) {
301
1063
  return Result.err({ status: 500, message: regRes.error.message });
302
1064
  }
1065
+ const profileRes = profiles.getProfileResult(stream);
1066
+ if (Result.isError(profileRes)) {
1067
+ return Result.err({ status: 500, message: profileRes.error.message });
1068
+ }
303
1069
  const reg = regRes.value;
1070
+ const jsonIngest = resolveJsonIngestCapability(profileRes.value);
304
1071
  const text = new TextDecoder().decode(bodyBytes);
305
1072
  let arr: any;
306
1073
  try {
@@ -321,16 +1088,26 @@ export function createAppCore(cfg: Config, opts: CreateAppCoreOptions): App {
321
1088
 
322
1089
  const rows: AppendRow[] = [];
323
1090
  for (const v of arr) {
324
- if (validator && !validator(v)) {
1091
+ let value = v;
1092
+ let profileRoutingKey: Uint8Array | null = null;
1093
+ if (jsonIngest) {
1094
+ const preparedRes = jsonIngest.prepareRecordResult({ stream, profile: profileRes.value, value: v });
1095
+ if (Result.isError(preparedRes)) return Result.err({ status: 400, message: preparedRes.error.message });
1096
+ value = preparedRes.value.value;
1097
+ profileRoutingKey = keyBytesFromString(preparedRes.value.routingKey);
1098
+ }
1099
+ if (validator && !validator(value)) {
325
1100
  const msg = validator.errors ? validator.errors.map((e) => e.message).join("; ") : "schema validation failed";
326
1101
  return Result.err({ status: 400, message: msg });
327
1102
  }
328
- const rkRes = reg.routingKey ? extractRoutingKey(reg, v) : Result.ok(keyBytesFromString(routingKeyHeader));
1103
+ const rkRes = reg.routingKey
1104
+ ? extractRoutingKey(reg, value)
1105
+ : Result.ok(routingKeyHeader != null ? keyBytesFromString(routingKeyHeader) : profileRoutingKey);
329
1106
  if (Result.isError(rkRes)) return Result.err({ status: 400, message: rkRes.error.message });
330
1107
  rows.push({
331
1108
  routingKey: rkRes.value,
332
1109
  contentType: "application/json",
333
- payload: new TextEncoder().encode(JSON.stringify(v)),
1110
+ payload: new TextEncoder().encode(JSON.stringify(value)),
334
1111
  });
335
1112
  }
336
1113
  return Result.ok({ rows });
@@ -380,6 +1157,11 @@ export function createAppCore(cfg: Config, opts: CreateAppCoreOptions): App {
380
1157
  close: args.close,
381
1158
  });
382
1159
 
1160
+ const awaitAppendWithTimeout = async (appendPromise: Promise<AppendResult>): Promise<AppendResult | Response> => {
1161
+ const appendResult = await awaitWithCooperativeTimeout(appendPromise, APPEND_REQUEST_TIMEOUT_MS);
1162
+ return appendResult === TIMEOUT_SENTINEL ? appendTimeout() : appendResult;
1163
+ };
1164
+
383
1165
  const recordAppendOutcome = (args: {
384
1166
  stream: string;
385
1167
  lastOffset: bigint;
@@ -392,52 +1174,393 @@ export function createAppCore(cfg: Config, opts: CreateAppCoreOptions): App {
392
1174
  if (args.appendedRows > 0) {
393
1175
  metrics.recordAppend(args.metricsBytes, args.appendedRows);
394
1176
  notifier.notify(args.stream, args.lastOffset);
1177
+ notifier.notifyDetailsChanged(args.stream);
395
1178
  touch.notify(args.stream);
396
1179
  }
397
1180
  if (stats) {
398
1181
  if (args.touched) stats.recordStreamTouched(args.stream);
399
1182
  if (args.appendedRows > 0) stats.recordIngested(args.ingestedBytes);
400
1183
  }
401
- if (args.closed) notifier.notifyClose(args.stream);
1184
+ if (args.closed) {
1185
+ notifier.notifyDetailsChanged(args.stream);
1186
+ notifier.notifyClose(args.stream);
1187
+ }
402
1188
  };
403
1189
 
404
1190
  const decodeJsonRecords = (
405
1191
  stream: string,
406
1192
  records: Array<{ offset: bigint; payload: Uint8Array }>
407
1193
  ): Result<{ values: any[] }, { status: 400 | 500; message: string }> => {
408
- const regRes = registry.getRegistryResult(stream);
409
- if (Result.isError(regRes)) return Result.err({ status: 500, message: regRes.error.message });
410
- const reg = regRes.value;
411
1194
  const values: any[] = [];
412
1195
  for (const r of records) {
413
- try {
414
- const s = new TextDecoder().decode(r.payload);
415
- let value: any = JSON.parse(s);
416
- if (reg.currentVersion > 0) {
417
- const version = schemaVersionForOffset(reg, r.offset);
418
- if (version < reg.currentVersion) {
419
- const chainRes = registry.getLensChainResult(reg, version, reg.currentVersion);
420
- if (Result.isError(chainRes)) return Result.err({ status: 500, message: chainRes.error.message });
421
- const chain = chainRes.value;
422
- const transformedRes = applyLensChainResult(chain, value);
423
- if (Result.isError(transformedRes)) return Result.err({ status: 400, message: transformedRes.error.message });
424
- value = transformedRes.value;
425
- }
426
- }
427
- values.push(value);
428
- } catch (e: any) {
429
- return Result.err({ status: 400, message: String(e?.message ?? e) });
430
- }
1196
+ const valueRes = decodeJsonPayloadResult(registry, stream, r.offset, r.payload);
1197
+ if (Result.isError(valueRes)) return valueRes;
1198
+ values.push(valueRes.value);
431
1199
  }
432
1200
  return Result.ok({ values });
433
1201
  };
434
1202
 
1203
+ const encodeStoredJsonArrayResult = (
1204
+ stream: string,
1205
+ records: Array<{ payload: Uint8Array }>
1206
+ ): Result<Buffer | null, { status: 400 | 500; message: string }> => {
1207
+ const regRes = registry.getRegistryResult(stream);
1208
+ if (Result.isError(regRes)) return Result.err({ status: 500, message: regRes.error.message });
1209
+ if (regRes.value.currentVersion !== 0) return Result.ok(null);
1210
+ const parts: Buffer[] = [Buffer.from("[")];
1211
+ for (let i = 0; i < records.length; i++) {
1212
+ if (i > 0) parts.push(Buffer.from(","));
1213
+ const payload = records[i]!.payload;
1214
+ parts.push(Buffer.from(payload.buffer, payload.byteOffset, payload.byteLength));
1215
+ }
1216
+ parts.push(Buffer.from("]"));
1217
+ return Result.ok(Buffer.concat(parts));
1218
+ };
1219
+
1220
+ const buildStreamSummary = (stream: string, row: StreamRow, profileKind: string) => ({
1221
+ name: stream,
1222
+ content_type: normalizeContentType(row.content_type) ?? row.content_type,
1223
+ profile: profileKind,
1224
+ created_at: timestampToIsoString(row.created_at_ms),
1225
+ updated_at: timestampToIsoString(row.updated_at_ms),
1226
+ expires_at: timestampToIsoString(row.expires_at_ms),
1227
+ ttl_seconds: row.ttl_seconds,
1228
+ stream_seq: row.stream_seq,
1229
+ closed: row.closed !== 0,
1230
+ epoch: row.epoch,
1231
+ next_offset: row.next_offset.toString(),
1232
+ sealed_through: row.sealed_through.toString(),
1233
+ uploaded_through: row.uploaded_through.toString(),
1234
+ segment_count: db.countSegmentsForStream(stream),
1235
+ uploaded_segment_count: db.countUploadedSegments(stream),
1236
+ pending_rows: row.pending_rows.toString(),
1237
+ pending_bytes: row.pending_bytes.toString(),
1238
+ total_size_bytes: row.logical_size_bytes.toString(),
1239
+ wal_rows: row.wal_rows.toString(),
1240
+ wal_bytes: row.wal_bytes.toString(),
1241
+ last_append_at: timestampToIsoString(row.last_append_ms),
1242
+ last_segment_cut_at: timestampToIsoString(row.last_segment_cut_ms),
1243
+ });
1244
+
1245
+ const buildIndexLagMs = (stream: string, headRow: StreamRow, coveredSegmentCount: number): string | null => {
1246
+ if (coveredSegmentCount <= 0) return null;
1247
+ const coveredLastAppendMs = db.getSegmentLastAppendMsFromMeta(stream, coveredSegmentCount - 1);
1248
+ if (coveredLastAppendMs == null) return null;
1249
+ const lagMs = headRow.last_append_ms > coveredLastAppendMs ? headRow.last_append_ms - coveredLastAppendMs : 0n;
1250
+ return lagMs.toString();
1251
+ };
1252
+
1253
+ const buildStorageBreakdown = (
1254
+ stream: string,
1255
+ row: StreamRow,
1256
+ currentCompanionRows: Array<{
1257
+ sections_json: string;
1258
+ section_sizes_json: string;
1259
+ size_bytes: number;
1260
+ }>,
1261
+ indexStatus: any
1262
+ ) => {
1263
+ const manifest = db.getManifestRow(stream);
1264
+ const schemaRow = db.getSchemaRegistry(stream);
1265
+ const uploadedSegmentBytes = db.getUploadedSegmentBytes(stream);
1266
+ const pendingSealedSegmentBytes = db.getPendingSealedSegmentBytes(stream);
1267
+ const routingIndexStorage = db.getRoutingIndexStorage(stream);
1268
+ const routingLexiconStorage =
1269
+ db
1270
+ .getLexiconIndexStorage(stream)
1271
+ .find((entry) => entry.source_kind === "routing_key" && entry.source_name === "") ?? { object_count: 0, bytes: 0n };
1272
+ const secondaryIndexStorage = new Map(db.getSecondaryIndexStorage(stream).map((entry) => [entry.index_name, entry]));
1273
+ const companionStorage = db.getBundledCompanionStorage(stream);
1274
+ const localStorageUsage = {
1275
+ segment_cache_bytes: 0,
1276
+ routing_index_cache_bytes: 0,
1277
+ exact_index_cache_bytes: 0,
1278
+ lexicon_index_cache_bytes: 0,
1279
+ companion_cache_bytes: 0,
1280
+ ...(getLocalStorageUsage?.(stream) ?? {}),
1281
+ };
1282
+ const sqliteSharedBytes = BigInt(db.getWalDbSizeBytes() + db.getMetaDbSizeBytes());
1283
+ const exactIndexBytes = indexStatus.exact_indexes.reduce((sum: bigint, entry: any) => sum + BigInt(entry.bytes_at_rest ?? 0), 0n);
1284
+ const familyBytes = new Map<string, bigint>();
1285
+ for (const row of currentCompanionRows) {
1286
+ const sizes = parseCompanionSectionSizes(row.section_sizes_json);
1287
+ for (const [kind, size] of Object.entries(sizes)) {
1288
+ familyBytes.set(kind, (familyBytes.get(kind) ?? 0n) + BigInt(size));
1289
+ }
1290
+ }
1291
+ return {
1292
+ object_storage: {
1293
+ total_bytes: (
1294
+ uploadedSegmentBytes +
1295
+ routingIndexStorage.bytes +
1296
+ routingLexiconStorage.bytes +
1297
+ exactIndexBytes +
1298
+ companionStorage.bytes +
1299
+ (manifest.last_uploaded_size_bytes ?? 0n) +
1300
+ (schemaRow?.uploaded_size_bytes ?? 0n)
1301
+ ).toString(),
1302
+ segments_bytes: uploadedSegmentBytes.toString(),
1303
+ indexes_bytes: (routingIndexStorage.bytes + routingLexiconStorage.bytes + exactIndexBytes + companionStorage.bytes).toString(),
1304
+ manifest_and_meta_bytes: ((manifest.last_uploaded_size_bytes ?? 0n) + (schemaRow?.uploaded_size_bytes ?? 0n)).toString(),
1305
+ manifest_bytes: (manifest.last_uploaded_size_bytes ?? 0n).toString(),
1306
+ schema_registry_bytes: (schemaRow?.uploaded_size_bytes ?? 0n).toString(),
1307
+ segment_object_count: indexStatus.segments.uploaded_count,
1308
+ routing_index_object_count: routingIndexStorage.object_count,
1309
+ routing_lexicon_object_count: routingLexiconStorage.object_count,
1310
+ exact_index_object_count: indexStatus.exact_indexes.reduce((sum: number, entry: any) => sum + Number(entry.object_count ?? 0), 0),
1311
+ bundled_companion_object_count: companionStorage.object_count,
1312
+ },
1313
+ local_storage: {
1314
+ total_bytes: (
1315
+ row.wal_bytes +
1316
+ pendingSealedSegmentBytes +
1317
+ BigInt(localStorageUsage.segment_cache_bytes) +
1318
+ BigInt(localStorageUsage.routing_index_cache_bytes) +
1319
+ BigInt(localStorageUsage.exact_index_cache_bytes) +
1320
+ BigInt(localStorageUsage.lexicon_index_cache_bytes) +
1321
+ BigInt(localStorageUsage.companion_cache_bytes)
1322
+ ).toString(),
1323
+ wal_retained_bytes: row.wal_bytes.toString(),
1324
+ pending_tail_bytes: row.pending_bytes.toString(),
1325
+ pending_sealed_segment_bytes: pendingSealedSegmentBytes.toString(),
1326
+ segment_cache_bytes: String(localStorageUsage.segment_cache_bytes),
1327
+ routing_index_cache_bytes: String(localStorageUsage.routing_index_cache_bytes),
1328
+ exact_index_cache_bytes: String(localStorageUsage.exact_index_cache_bytes),
1329
+ lexicon_index_cache_bytes: String(localStorageUsage.lexicon_index_cache_bytes),
1330
+ companion_cache_bytes: String(localStorageUsage.companion_cache_bytes),
1331
+ sqlite_shared_total_bytes: sqliteSharedBytes.toString(),
1332
+ },
1333
+ companion_families: {
1334
+ col_bytes: String(familyBytes.get("col") ?? 0n),
1335
+ fts_bytes: String(familyBytes.get("fts") ?? 0n),
1336
+ agg_bytes: String(familyBytes.get("agg") ?? 0n),
1337
+ mblk_bytes: String(familyBytes.get("mblk") ?? 0n),
1338
+ },
1339
+ };
1340
+ };
1341
+
1342
+ const buildObjectStoreRequestSummary = (stream: string) => {
1343
+ const summary = db.getObjectStoreRequestSummaryByHash(streamHash16Hex(stream));
1344
+ return {
1345
+ puts: summary.puts.toString(),
1346
+ reads: summary.reads.toString(),
1347
+ gets: summary.gets.toString(),
1348
+ heads: summary.heads.toString(),
1349
+ lists: summary.lists.toString(),
1350
+ deletes: summary.deletes.toString(),
1351
+ by_artifact: summary.by_artifact.map((entry) => ({
1352
+ artifact: entry.artifact,
1353
+ puts: entry.puts.toString(),
1354
+ gets: entry.gets.toString(),
1355
+ heads: entry.heads.toString(),
1356
+ lists: entry.lists.toString(),
1357
+ deletes: entry.deletes.toString(),
1358
+ reads: entry.reads.toString(),
1359
+ })),
1360
+ };
1361
+ };
1362
+
1363
+ const buildIndexStatus = (stream: string, row: StreamRow, reg: SchemaRegistry, profileKind: string) => {
1364
+ const segmentCount = db.countSegmentsForStream(stream);
1365
+ const uploadedSegmentCount = db.countUploadedSegments(stream);
1366
+ const manifest = db.getManifestRow(stream);
1367
+
1368
+ const routingState = db.getIndexState(stream);
1369
+ const routingRuns = db.listIndexRuns(stream);
1370
+ const retiredRoutingRuns = db.listRetiredIndexRuns(stream);
1371
+ const routingStorage = db.getRoutingIndexStorage(stream);
1372
+ const routingLexiconState = db.getLexiconIndexState(stream, "routing_key", "");
1373
+ const routingLexiconRuns = db.listLexiconIndexRuns(stream, "routing_key", "");
1374
+ const retiredRoutingLexiconRuns = db.listRetiredLexiconIndexRuns(stream, "routing_key", "");
1375
+ const routingLexiconStorage =
1376
+ db
1377
+ .getLexiconIndexStorage(stream)
1378
+ .find((entry) => entry.source_kind === "routing_key" && entry.source_name === "") ?? { object_count: 0, bytes: 0n };
1379
+ const secondaryIndexStorage = new Map(db.getSecondaryIndexStorage(stream).map((entry) => [entry.index_name, entry]));
1380
+
1381
+ const exactIndexes = configuredExactIndexes(reg.search).map(({ name, kind, configHash }) => {
1382
+ const state = db.getSecondaryIndexState(stream, name);
1383
+ const configMatches = state?.config_hash === configHash;
1384
+ const indexedSegmentCount = configMatches ? (state?.indexed_through ?? 0) : 0;
1385
+ const storage = secondaryIndexStorage.get(name);
1386
+ return {
1387
+ name,
1388
+ kind,
1389
+ indexed_segment_count: indexedSegmentCount,
1390
+ lag_segments: Math.max(0, uploadedSegmentCount - indexedSegmentCount),
1391
+ lag_ms: buildIndexLagMs(stream, row, indexedSegmentCount),
1392
+ bytes_at_rest: String(storage?.bytes ?? 0n),
1393
+ object_count: storage?.object_count ?? 0,
1394
+ active_run_count: db.listSecondaryIndexRuns(stream, name).length,
1395
+ retired_run_count: db.listRetiredSecondaryIndexRuns(stream, name).length,
1396
+ fully_indexed_uploaded_segments: configMatches && indexedSegmentCount >= uploadedSegmentCount,
1397
+ stale_configuration: !configMatches,
1398
+ updated_at: timestampToIsoString(state?.updated_at_ms ?? null),
1399
+ };
1400
+ });
1401
+
1402
+ const desiredCompanionPlan = buildDesiredSearchCompanionPlan(reg);
1403
+ const desiredCompanionHash = hashSearchCompanionPlan(desiredCompanionPlan);
1404
+ const companionPlanRow = db.getSearchCompanionPlan(stream);
1405
+ const desiredIndexPlanGeneration =
1406
+ Object.values(desiredCompanionPlan.families).some(Boolean)
1407
+ ? companionPlanRow
1408
+ ? companionPlanRow.plan_hash === desiredCompanionHash
1409
+ ? companionPlanRow.generation
1410
+ : companionPlanRow.generation + 1
1411
+ : 1
1412
+ : 0;
1413
+ const companionRows = db.listSearchSegmentCompanions(stream);
1414
+ const currentCompanionRows = companionRows.filter((row) => row.plan_generation === desiredIndexPlanGeneration);
1415
+ const currentCompanionBytes = currentCompanionRows.reduce((sum, entry) => sum + BigInt(entry.size_bytes), 0n);
1416
+ const searchFamilies = configuredSearchFamilies(reg.search).map(({ family, fields }) => {
1417
+ const coveredSegmentCount = currentCompanionRows.filter((row) => parseCompanionSections(row.sections_json).has(family)).length;
1418
+ const contiguousCoveredCount = contiguousCoveredSegmentCount(currentCompanionRows, family);
1419
+ let familyBytes = 0n;
1420
+ let familyObjectCount = 0;
1421
+ for (const row of currentCompanionRows) {
1422
+ const size = parseCompanionSectionSizes(row.section_sizes_json)[family];
1423
+ if (size == null) continue;
1424
+ familyBytes += BigInt(size);
1425
+ familyObjectCount += 1;
1426
+ }
1427
+ return {
1428
+ family,
1429
+ fields,
1430
+ plan_generation: desiredIndexPlanGeneration,
1431
+ covered_segment_count: coveredSegmentCount,
1432
+ contiguous_covered_segment_count: contiguousCoveredCount,
1433
+ lag_segments: Math.max(0, uploadedSegmentCount - contiguousCoveredCount),
1434
+ lag_ms: buildIndexLagMs(stream, row, contiguousCoveredCount),
1435
+ bytes_at_rest: familyBytes.toString(),
1436
+ object_count: familyObjectCount,
1437
+ stale_segment_count: Math.max(0, uploadedSegmentCount - coveredSegmentCount),
1438
+ fully_indexed_uploaded_segments: coveredSegmentCount >= uploadedSegmentCount,
1439
+ updated_at: timestampToIsoString(companionPlanRow?.updated_at_ms ?? null),
1440
+ };
1441
+ });
1442
+
1443
+ return {
1444
+ stream,
1445
+ profile: profileKind,
1446
+ desired_index_plan_generation: desiredIndexPlanGeneration,
1447
+ segments: {
1448
+ total_count: segmentCount,
1449
+ uploaded_count: uploadedSegmentCount,
1450
+ },
1451
+ manifest: {
1452
+ generation: manifest.generation,
1453
+ uploaded_generation: manifest.uploaded_generation,
1454
+ last_uploaded_at: timestampToIsoString(manifest.last_uploaded_at_ms),
1455
+ last_uploaded_etag: manifest.last_uploaded_etag,
1456
+ last_uploaded_size_bytes: manifest.last_uploaded_size_bytes?.toString() ?? null,
1457
+ },
1458
+ routing_key_index: {
1459
+ configured: reg.routingKey != null,
1460
+ indexed_segment_count: routingState?.indexed_through ?? 0,
1461
+ lag_segments: Math.max(0, uploadedSegmentCount - (routingState?.indexed_through ?? 0)),
1462
+ lag_ms: buildIndexLagMs(stream, row, routingState?.indexed_through ?? 0),
1463
+ bytes_at_rest: routingStorage.bytes.toString(),
1464
+ object_count: routingStorage.object_count,
1465
+ active_run_count: routingRuns.length,
1466
+ retired_run_count: retiredRoutingRuns.length,
1467
+ fully_indexed_uploaded_segments: reg.routingKey == null ? true : (routingState?.indexed_through ?? 0) >= uploadedSegmentCount,
1468
+ updated_at: timestampToIsoString(routingState?.updated_at_ms ?? null),
1469
+ },
1470
+ routing_key_lexicon: {
1471
+ configured: reg.routingKey != null,
1472
+ indexed_segment_count: routingLexiconState?.indexed_through ?? 0,
1473
+ lag_segments: Math.max(0, uploadedSegmentCount - (routingLexiconState?.indexed_through ?? 0)),
1474
+ lag_ms: buildIndexLagMs(stream, row, routingLexiconState?.indexed_through ?? 0),
1475
+ bytes_at_rest: routingLexiconStorage.bytes.toString(),
1476
+ object_count: routingLexiconStorage.object_count,
1477
+ active_run_count: routingLexiconRuns.length,
1478
+ retired_run_count: retiredRoutingLexiconRuns.length,
1479
+ fully_indexed_uploaded_segments: reg.routingKey == null ? true : (routingLexiconState?.indexed_through ?? 0) >= uploadedSegmentCount,
1480
+ updated_at: timestampToIsoString(routingLexiconState?.updated_at_ms ?? null),
1481
+ },
1482
+ exact_indexes: exactIndexes,
1483
+ bundled_companions: {
1484
+ object_count: currentCompanionRows.length,
1485
+ bytes_at_rest: currentCompanionBytes.toString(),
1486
+ fully_indexed_uploaded_segments: currentCompanionRows.length >= uploadedSegmentCount,
1487
+ },
1488
+ search_families: searchFamilies,
1489
+ current_companion_rows: currentCompanionRows,
1490
+ };
1491
+ };
1492
+
1493
+ type DetailsSnapshot = { etag: string; body: string; version: bigint };
1494
+
1495
+ const buildDetailsSnapshotResult = (
1496
+ stream: string,
1497
+ mode: "details" | "index_status"
1498
+ ): Result<DetailsSnapshot, { status: 404 | 500; message: string }> => {
1499
+ for (let attempt = 0; attempt < 3; attempt++) {
1500
+ const beforeVersion = notifier.currentDetailsVersion(stream);
1501
+ const srow = db.getStream(stream);
1502
+ if (!srow || db.isDeleted(srow)) return Result.err({ status: 404, message: "not_found" });
1503
+ if (srow.expires_at_ms != null && db.nowMs() > srow.expires_at_ms) return Result.err({ status: 404, message: "stream expired" });
1504
+
1505
+ const regRes = registry.getRegistryResult(stream);
1506
+ if (Result.isError(regRes)) return Result.err({ status: 500, message: regRes.error.message });
1507
+ const profileRes = profiles.getProfileResourceResult(stream, srow);
1508
+ if (Result.isError(profileRes)) return Result.err({ status: 500, message: profileRes.error.message });
1509
+
1510
+ const profileKind = profileRes.value.profile.kind;
1511
+ const indexStatus = buildIndexStatus(stream, srow, regRes.value, profileKind);
1512
+ const storage = buildStorageBreakdown(stream, srow, indexStatus.current_companion_rows, indexStatus);
1513
+ const objectStoreRequests = buildObjectStoreRequestSummary(stream);
1514
+ delete (indexStatus as any).current_companion_rows;
1515
+ const payload =
1516
+ mode === "index_status"
1517
+ ? indexStatus
1518
+ : {
1519
+ stream: buildStreamSummary(stream, srow, profileKind),
1520
+ profile: profileRes.value,
1521
+ schema: regRes.value,
1522
+ index_status: indexStatus,
1523
+ storage,
1524
+ object_store_requests: objectStoreRequests,
1525
+ };
1526
+ const body = JSON.stringify(payload);
1527
+ const afterVersion = notifier.currentDetailsVersion(stream);
1528
+ if (beforeVersion === afterVersion) {
1529
+ return Result.ok({
1530
+ etag: weakEtag(mode, body),
1531
+ body,
1532
+ version: afterVersion,
1533
+ });
1534
+ }
1535
+ }
1536
+
1537
+ return Result.err({ status: 500, message: "details changed too quickly" });
1538
+ };
1539
+
435
1540
  let closing = false;
436
1541
  const fetch = async (req: Request): Promise<Response> => {
437
1542
  if (closing) {
438
- return json(503, { error: { code: "unavailable", message: "server shutting down" } });
1543
+ return unavailable();
439
1544
  }
1545
+ const requestAbortController = new AbortController();
1546
+ const abortFromClient = () => requestAbortController.abort(req.signal.reason);
1547
+ let timedOut = false;
1548
+ if (req.signal.aborted) requestAbortController.abort(req.signal.reason);
1549
+ else req.signal.addEventListener("abort", abortFromClient, { once: true });
440
1550
  try {
1551
+ const runWithGate = async <T>(gate: ConcurrencyGate, fn: () => Promise<T>): Promise<T> =>
1552
+ gate.run(fn, requestAbortController.signal);
1553
+ const runForeground = async <T>(fn: () => Promise<T>): Promise<T> => {
1554
+ const leaveForeground = foregroundActivity.enter();
1555
+ try {
1556
+ return await fn();
1557
+ } finally {
1558
+ leaveForeground();
1559
+ }
1560
+ };
1561
+ const runForegroundWithGate = async <T>(gate: ConcurrencyGate, fn: () => Promise<T>): Promise<T> =>
1562
+ runForeground(() => runWithGate(gate, fn));
1563
+ const requestPromise = (async (): Promise<Response> => {
441
1564
  let url: URL;
442
1565
  try {
443
1566
  url = new URL(req.url, "http://localhost");
@@ -452,33 +1575,38 @@ export function createAppCore(cfg: Config, opts: CreateAppCoreOptions): App {
452
1575
  if (path === "/metrics") {
453
1576
  return json(200, metrics.snapshot());
454
1577
  }
455
-
456
- const rejectIfMemoryLimited = (): Response | null => {
457
- if (!memory || memory.shouldAllow()) return null;
458
- memory.maybeGc("memory limit");
459
- memory.maybeHeapSnapshot("memory limit");
460
- metrics.record("tieredstore.backpressure.over_limit", 1, "count", { reason: "memory" });
461
- return json(429, { error: { code: "overloaded", message: "ingest queue full" } });
462
- };
1578
+ if (req.method === "GET" && path === "/v1/server/_details") {
1579
+ return json(200, buildServerDetails());
1580
+ }
1581
+ if (req.method === "GET" && path === "/v1/server/_mem") {
1582
+ return json(200, buildServerMem());
1583
+ }
463
1584
 
464
1585
  // /v1/streams
465
1586
  if (req.method === "GET" && path === "/v1/streams") {
466
1587
  const limit = Number(url.searchParams.get("limit") ?? "100");
467
1588
  const offset = Number(url.searchParams.get("offset") ?? "0");
468
1589
  const rows = db.listStreams(Math.max(0, Math.min(limit, 1000)), Math.max(0, offset));
469
- const out = rows.map((r) => ({
470
- name: r.stream,
471
- created_at: new Date(Number(r.created_at_ms)).toISOString(),
472
- expires_at: r.expires_at_ms == null ? null : new Date(Number(r.expires_at_ms)).toISOString(),
473
- epoch: r.epoch,
474
- next_offset: r.next_offset.toString(),
475
- sealed_through: r.sealed_through.toString(),
476
- uploaded_through: r.uploaded_through.toString(),
477
- }));
1590
+ const out = [];
1591
+ for (const r of rows) {
1592
+ const profileRes = profiles.getProfileResult(r.stream, r);
1593
+ if (Result.isError(profileRes)) return internalError("invalid stream profile");
1594
+ const profile = profileRes.value;
1595
+ out.push({
1596
+ name: r.stream,
1597
+ created_at: new Date(Number(r.created_at_ms)).toISOString(),
1598
+ expires_at: r.expires_at_ms == null ? null : new Date(Number(r.expires_at_ms)).toISOString(),
1599
+ epoch: r.epoch,
1600
+ next_offset: r.next_offset.toString(),
1601
+ sealed_through: r.sealed_through.toString(),
1602
+ uploaded_through: r.uploaded_through.toString(),
1603
+ profile: profile.kind,
1604
+ });
1605
+ }
478
1606
  return json(200, out);
479
1607
  }
480
1608
 
481
- // /v1/stream/:name[/_schema] (accept encoded or raw slashes in name)
1609
+ // /v1/stream/:name[/_schema|/_profile|/_details|/_index_status] (accept encoded or raw slashes in name)
482
1610
  const streamPrefix = "/v1/stream/";
483
1611
  if (path.startsWith(streamPrefix)) {
484
1612
  const rawRest = path.slice(streamPrefix.length);
@@ -486,15 +1614,35 @@ export function createAppCore(cfg: Config, opts: CreateAppCoreOptions): App {
486
1614
  if (rest.length === 0) return badRequest("missing stream name");
487
1615
  const segments = rest.split("/");
488
1616
  let isSchema = false;
1617
+ let isProfile = false;
1618
+ let isSearch = false;
1619
+ let isAggregate = false;
1620
+ let isDetails = false;
1621
+ let isIndexStatus = false;
1622
+ let isRoutingKeys = false;
489
1623
  let pathKeyParam: string | null = null;
490
- let touchMode:
491
- | null
492
- | { kind: "meta" }
493
- | { kind: "wait" }
494
- | { kind: "templates_activate" } = null;
1624
+ let touchMode: StreamTouchRoute | null = null;
495
1625
  if (segments[segments.length - 1] === "_schema") {
496
1626
  isSchema = true;
497
1627
  segments.pop();
1628
+ } else if (segments[segments.length - 1] === "_profile") {
1629
+ isProfile = true;
1630
+ segments.pop();
1631
+ } else if (segments[segments.length - 1] === "_search") {
1632
+ isSearch = true;
1633
+ segments.pop();
1634
+ } else if (segments[segments.length - 1] === "_aggregate") {
1635
+ isAggregate = true;
1636
+ segments.pop();
1637
+ } else if (segments[segments.length - 1] === "_details") {
1638
+ isDetails = true;
1639
+ segments.pop();
1640
+ } else if (segments[segments.length - 1] === "_index_status") {
1641
+ isIndexStatus = true;
1642
+ segments.pop();
1643
+ } else if (segments[segments.length - 1] === "_routing_keys") {
1644
+ isRoutingKeys = true;
1645
+ segments.pop();
498
1646
  } else if (
499
1647
  segments.length >= 3 &&
500
1648
  segments[segments.length - 3] === "touch" &&
@@ -528,74 +1676,16 @@ export function createAppCore(cfg: Config, opts: CreateAppCoreOptions): App {
528
1676
  return json(200, regRes.value);
529
1677
  }
530
1678
  if (req.method === "POST") {
531
- let body: any;
1679
+ let body: unknown;
532
1680
  try {
533
1681
  body = await req.json();
534
1682
  } catch {
535
1683
  return badRequest("schema update must be valid JSON");
536
1684
  }
537
- // Accept incremental update shape ({schema, lens, routingKey}),
538
- // full registry payload ({schemas, lenses, currentVersion, ...}),
539
- // and routingKey-only updates (used by the Bluesky demo).
540
- let update = body;
541
- const isSchemaObject =
542
- update &&
543
- (update.schema === true ||
544
- update.schema === false ||
545
- (typeof update.schema === "object" && update.schema !== null && !Array.isArray(update.schema)));
546
- if (!isSchemaObject && update && typeof update === "object" && update.schemas && typeof update.schemas === "object") {
547
- const versions = Object.keys(update.schemas)
548
- .map((v) => Number(v))
549
- .filter((v) => Number.isFinite(v) && v >= 0);
550
- const currentVersion =
551
- typeof update.currentVersion === "number" && Number.isFinite(update.currentVersion)
552
- ? update.currentVersion
553
- : versions.length > 0
554
- ? Math.max(...versions)
555
- : null;
556
- if (currentVersion != null) {
557
- const schema = update.schemas[String(currentVersion)];
558
- const lens =
559
- update.lens ??
560
- (update.lenses && typeof update.lenses === "object" ? update.lenses[String(currentVersion - 1)] : undefined);
561
- update = {
562
- schema,
563
- lens,
564
- routingKey: update.routingKey,
565
- interpreter: (update as any).interpreter,
566
- };
567
- }
568
- }
569
- if (update && typeof update === "object") {
570
- if (update.schema === null) {
571
- delete update.schema;
572
- }
573
- if (update.routingKey === undefined) {
574
- const raw = update as any;
575
- const candidate =
576
- raw.routing_key ?? raw.routingKeyPointer ?? raw.routing_key_pointer ?? raw.routingKey;
577
- if (typeof candidate === "string") {
578
- update.routingKey = { jsonPointer: candidate, required: true };
579
- } else if (candidate && typeof candidate === "object") {
580
- const jsonPointer = candidate.jsonPointer ?? candidate.json_pointer;
581
- if (typeof jsonPointer === "string") {
582
- update.routingKey = {
583
- jsonPointer,
584
- required: typeof candidate.required === "boolean" ? candidate.required : true,
585
- };
586
- }
587
- }
588
- } else if (update.routingKey && typeof update.routingKey === "object") {
589
- const rk = update.routingKey as any;
590
- if (rk.jsonPointer === undefined && typeof rk.json_pointer === "string") {
591
- update.routingKey = {
592
- jsonPointer: rk.json_pointer,
593
- required: typeof rk.required === "boolean" ? rk.required : true,
594
- };
595
- }
596
- }
597
- }
598
- if (update.schema === undefined && update.routingKey !== undefined && update.interpreter === undefined) {
1685
+ const updateRes = parseSchemaUpdateResult(body);
1686
+ if (Result.isError(updateRes)) return badRequest(updateRes.error.message);
1687
+ const update = updateRes.value;
1688
+ if (update.schema === undefined && update.routingKey !== undefined && update.search === undefined) {
599
1689
  const regRes = registry.updateRoutingKeyResult(stream, update.routingKey ?? null);
600
1690
  if (Result.isError(regRes)) return schemaMutationErrorResponse(regRes.error);
601
1691
  try {
@@ -603,408 +1693,354 @@ export function createAppCore(cfg: Config, opts: CreateAppCoreOptions): App {
603
1693
  } catch {
604
1694
  return json(500, { error: { code: "internal", message: "schema upload failed" } });
605
1695
  }
1696
+ indexer?.enqueue(stream);
1697
+ notifier.notifyDetailsChanged(stream);
606
1698
  return json(200, regRes.value);
607
1699
  }
608
- if (update.schema === undefined && update.interpreter !== undefined && update.routingKey === undefined) {
609
- const regRes = registry.updateInterpreterResult(stream, update.interpreter ?? null);
1700
+ if (update.schema === undefined && update.search !== undefined && update.routingKey === undefined) {
1701
+ const regRes = registry.updateSearchResult(stream, update.search ?? null);
610
1702
  if (Result.isError(regRes)) return schemaMutationErrorResponse(regRes.error);
611
1703
  try {
612
1704
  await uploadSchemaRegistry(stream, regRes.value);
613
1705
  } catch {
614
1706
  return json(500, { error: { code: "internal", message: "schema upload failed" } });
615
1707
  }
1708
+ indexer?.enqueue(stream);
1709
+ notifier.notifyDetailsChanged(stream);
616
1710
  return json(200, regRes.value);
617
1711
  }
618
- if (update.schema === undefined && update.routingKey !== undefined && update.interpreter !== undefined) {
619
- // Apply both updates, reusing the same endpoint semantics.
620
- const routingRes = registry.updateRoutingKeyResult(stream, update.routingKey ?? null);
621
- if (Result.isError(routingRes)) return schemaMutationErrorResponse(routingRes.error);
622
- const interpreterRes = registry.updateInterpreterResult(stream, update.interpreter ?? null);
623
- if (Result.isError(interpreterRes)) return schemaMutationErrorResponse(interpreterRes.error);
624
- try {
625
- await uploadSchemaRegistry(stream, interpreterRes.value);
626
- } catch {
627
- return json(500, { error: { code: "internal", message: "schema upload failed" } });
628
- }
629
- return json(200, interpreterRes.value);
630
- }
631
- const regRes = registry.updateRegistryResult(stream, srow, update);
1712
+ const regRes = registry.updateRegistryResult(stream, srow, {
1713
+ schema: update.schema,
1714
+ lens: update.lens,
1715
+ routingKey: update.routingKey ?? undefined,
1716
+ search: update.search,
1717
+ });
632
1718
  if (Result.isError(regRes)) return schemaMutationErrorResponse(regRes.error);
633
1719
  try {
634
1720
  await uploadSchemaRegistry(stream, regRes.value);
635
1721
  } catch {
636
1722
  return json(500, { error: { code: "internal", message: "schema upload failed" } });
637
1723
  }
1724
+ indexer?.enqueue(stream);
1725
+ notifier.notifyDetailsChanged(stream);
638
1726
  return json(200, regRes.value);
639
1727
  }
640
1728
  return badRequest("unsupported method");
641
1729
  }
642
1730
 
643
- if (touchMode) {
1731
+ if (isProfile) {
644
1732
  const srow = db.getStream(stream);
645
1733
  if (!srow || db.isDeleted(srow)) return notFound();
646
1734
  if (srow.expires_at_ms != null && db.nowMs() > srow.expires_at_ms) return notFound("stream expired");
647
1735
 
648
- const regRes = registry.getRegistryResult(stream);
649
- if (Result.isError(regRes)) return schemaReadErrorResponse(regRes.error);
650
- const reg = regRes.value;
651
- if (!isTouchEnabled(reg.interpreter)) return notFound("touch not enabled");
652
-
653
- const touchCfg = reg.interpreter.touch;
1736
+ if (req.method === "GET") {
1737
+ const profileRes = profiles.getProfileResourceResult(stream, srow);
1738
+ if (Result.isError(profileRes)) return internalError("invalid stream profile");
1739
+ return json(200, profileRes.value);
1740
+ }
654
1741
 
655
- if (touchMode.kind === "templates_activate") {
656
- if (req.method !== "POST") return badRequest("unsupported method");
1742
+ if (req.method === "POST") {
657
1743
  let body: any;
658
1744
  try {
659
1745
  body = await req.json();
660
1746
  } catch {
661
- return badRequest("activate body must be valid JSON");
662
- }
663
- const templatesRaw = body?.templates;
664
- if (!Array.isArray(templatesRaw) || templatesRaw.length === 0) {
665
- return badRequest("activate.templates must be a non-empty array");
1747
+ return badRequest("profile update must be valid JSON");
666
1748
  }
667
- if (templatesRaw.length > 256) return badRequest("activate.templates too large (max 256)");
668
-
669
- const ttlRaw = body?.inactivityTtlMs;
670
- const inactivityTtlMs =
671
- ttlRaw === undefined
672
- ? touchCfg.templates?.defaultInactivityTtlMs ?? 60 * 60 * 1000
673
- : typeof ttlRaw === "number" && Number.isFinite(ttlRaw) && ttlRaw >= 0
674
- ? Math.floor(ttlRaw)
675
- : null;
676
- if (inactivityTtlMs == null) return badRequest("activate.inactivityTtlMs must be a non-negative number (ms)");
677
-
678
- const templates: Array<{ entity: string; fields: Array<{ name: string; encoding: any }> }> = [];
679
- for (const t of templatesRaw) {
680
- const entity = typeof t?.entity === "string" ? t.entity.trim() : "";
681
- const fieldsRaw = t?.fields;
682
- if (entity === "" || !Array.isArray(fieldsRaw) || fieldsRaw.length === 0 || fieldsRaw.length > 3) continue;
683
- const fields: Array<{ name: string; encoding: any }> = [];
684
- for (const f of fieldsRaw) {
685
- const name = typeof f?.name === "string" ? f.name.trim() : "";
686
- const encoding = f?.encoding;
687
- if (name === "") continue;
688
- fields.push({ name, encoding });
1749
+ const nextProfileRes = parseProfileUpdateResult(body);
1750
+ if (Result.isError(nextProfileRes)) return badRequest(nextProfileRes.error.message);
1751
+ const profileRes = profiles.updateProfileResult(stream, srow, nextProfileRes.value);
1752
+ if (Result.isError(profileRes)) return badRequest(profileRes.error.message);
1753
+ try {
1754
+ if (profileRes.value.schemaRegistry) {
1755
+ await uploadSchemaRegistry(stream, profileRes.value.schemaRegistry);
689
1756
  }
690
- if (fields.length !== fieldsRaw.length) continue;
691
- templates.push({ entity, fields });
1757
+ await uploader.publishManifest(stream);
1758
+ } catch {
1759
+ return json(500, { error: { code: "internal", message: "profile upload failed" } });
692
1760
  }
693
- if (templates.length !== templatesRaw.length) return badRequest("activate.templates contains invalid template definitions");
694
-
695
- const limits = {
696
- maxActiveTemplatesPerStream: touchCfg.templates?.maxActiveTemplatesPerStream ?? 2048,
697
- maxActiveTemplatesPerEntity: touchCfg.templates?.maxActiveTemplatesPerEntity ?? 256,
698
- };
699
-
700
- const activeFromTouchOffset = touch.getOrCreateJournal(stream, touchCfg).getCursor();
1761
+ indexer?.enqueue(stream);
1762
+ notifier.notifyDetailsChanged(stream);
1763
+ return json(200, profileRes.value.resource);
1764
+ }
701
1765
 
702
- const res = touch.activateTemplates({
703
- stream,
704
- touchCfg,
705
- baseStreamNextOffset: srow.next_offset,
706
- activeFromTouchOffset,
707
- templates,
708
- inactivityTtlMs,
709
- });
1766
+ return badRequest("unsupported method");
1767
+ }
710
1768
 
711
- return json(200, { activated: res.activated, denied: res.denied, limits });
712
- }
1769
+ if (isDetails || isIndexStatus) {
1770
+ if (req.method !== "GET") return badRequest("unsupported method");
1771
+ const liveParam = url.searchParams.get("live") ?? "";
1772
+ let longPoll = false;
1773
+ if (liveParam === "" || liveParam === "false" || liveParam === "0") longPoll = false;
1774
+ else if (liveParam === "long-poll" || liveParam === "true" || liveParam === "1") longPoll = true;
1775
+ else return badRequest("invalid live mode");
713
1776
 
714
- if (touchMode.kind === "meta") {
715
- if (req.method !== "GET") return badRequest("unsupported method");
716
- let activeTemplates = 0;
717
- try {
718
- const row = db.db.query(`SELECT COUNT(*) as cnt FROM live_templates WHERE stream=? AND state='active';`).get(stream) as any;
719
- activeTemplates = Number(row?.cnt ?? 0);
720
- } catch {
721
- activeTemplates = 0;
1777
+ const timeout = url.searchParams.get("timeout") ?? url.searchParams.get("timeout_ms");
1778
+ let timeoutMs: number | null = null;
1779
+ if (timeout) {
1780
+ if (/^[0-9]+$/.test(timeout)) {
1781
+ timeoutMs = Number(timeout);
1782
+ } else {
1783
+ const timeoutRes = parseDurationMsResult(timeout);
1784
+ if (Result.isError(timeoutRes)) return badRequest("invalid timeout");
1785
+ timeoutMs = timeoutRes.value;
722
1786
  }
723
- const meta = touch.getOrCreateJournal(stream, touchCfg).getMeta();
724
- const runtime = touch.getTouchRuntimeSnapshot({ stream, touchCfg });
725
- const interp = db.getStreamInterpreter(stream);
726
- return json(200, {
727
- ...meta,
728
- coarseIntervalMs: touchCfg.coarseIntervalMs ?? 100,
729
- touchCoalesceWindowMs: touchCfg.touchCoalesceWindowMs ?? 100,
730
- activeTemplates,
731
- lagSourceOffsets: runtime.lagSourceOffsets,
732
- touchMode: runtime.touchMode,
733
- walScannedThrough: interp ? encodeOffset(srow.epoch, interp.interpreted_through) : null,
734
- bucketMaxSourceOffsetSeq: meta.bucketMaxSourceOffsetSeq,
735
- hotFineKeys: runtime.hotFineKeys,
736
- hotTemplates: runtime.hotTemplates,
737
- hotFineKeysActive: runtime.hotFineKeysActive,
738
- hotFineKeysGrace: runtime.hotFineKeysGrace,
739
- hotTemplatesActive: runtime.hotTemplatesActive,
740
- hotTemplatesGrace: runtime.hotTemplatesGrace,
741
- fineWaitersActive: runtime.fineWaitersActive,
742
- coarseWaitersActive: runtime.coarseWaitersActive,
743
- broadFineWaitersActive: runtime.broadFineWaitersActive,
744
- hotKeyFilteringEnabled: runtime.hotKeyFilteringEnabled,
745
- hotTemplateFilteringEnabled: runtime.hotTemplateFilteringEnabled,
746
- scanRowsTotal: runtime.scanRowsTotal,
747
- scanBatchesTotal: runtime.scanBatchesTotal,
748
- scannedButEmitted0BatchesTotal: runtime.scannedButEmitted0BatchesTotal,
749
- interpretedThroughDeltaTotal: runtime.interpretedThroughDeltaTotal,
750
- touchesEmittedTotal: runtime.touchesEmittedTotal,
751
- touchesTableTotal: runtime.touchesTableTotal,
752
- touchesTemplateTotal: runtime.touchesTemplateTotal,
753
- fineTouchesDroppedDueToBudgetTotal: runtime.fineTouchesDroppedDueToBudgetTotal,
754
- fineTouchesSkippedColdTemplateTotal: runtime.fineTouchesSkippedColdTemplateTotal,
755
- fineTouchesSkippedColdKeyTotal: runtime.fineTouchesSkippedColdKeyTotal,
756
- fineTouchesSkippedTemplateBucketTotal: runtime.fineTouchesSkippedTemplateBucketTotal,
757
- waitTouchedTotal: runtime.waitTouchedTotal,
758
- waitTimeoutTotal: runtime.waitTimeoutTotal,
759
- waitStaleTotal: runtime.waitStaleTotal,
760
- journalFlushesTotal: runtime.journalFlushesTotal,
761
- journalNotifyWakeupsTotal: runtime.journalNotifyWakeupsTotal,
762
- journalNotifyWakeMsTotal: runtime.journalNotifyWakeMsTotal,
763
- journalNotifyWakeMsMax: runtime.journalNotifyWakeMsMax,
764
- journalTimeoutsFiredTotal: runtime.journalTimeoutsFiredTotal,
765
- journalTimeoutSweepMsTotal: runtime.journalTimeoutSweepMsTotal,
766
- });
767
1787
  }
768
1788
 
769
- if (touchMode.kind === "wait") {
770
- if (req.method !== "POST") return badRequest("unsupported method");
771
- const waitStartMs = Date.now();
772
- let body: any;
773
- try {
774
- body = await req.json();
775
- } catch {
776
- return badRequest("wait body must be valid JSON");
777
- }
778
- const keysRaw = body?.keys;
779
- const cursorRaw = body?.cursor;
780
- const timeoutMsRaw = body?.timeoutMs;
781
- if (keysRaw !== undefined && (!Array.isArray(keysRaw) || !keysRaw.every((k: any) => typeof k === "string" && k.trim() !== ""))) {
782
- return badRequest("wait.keys must be a non-empty string array when provided");
783
- }
784
- const keys = Array.isArray(keysRaw) ? Array.from(new Set(keysRaw.map((k: string) => k.trim()))) : [];
785
- if (keys.length > 1024) return badRequest("wait.keys too large (max 1024)");
786
- const keyIdsRaw = body?.keyIds;
787
- const keyIds =
788
- Array.isArray(keyIdsRaw) && keyIdsRaw.length > 0
789
- ? Array.from(
790
- new Set(
791
- keyIdsRaw.map((x: any) => Number(x)).filter((n: number) => Number.isFinite(n) && Number.isInteger(n) && n >= 0 && n <= 0xffffffff)
792
- )
793
- ).map((n) => n >>> 0)
794
- : [];
795
- if (Array.isArray(keyIdsRaw) && keyIds.length !== keyIdsRaw.length) {
796
- return badRequest("wait.keyIds must be a non-empty uint32 array when provided");
797
- }
798
- if (keys.length === 0 && keyIds.length === 0) return badRequest("wait requires keys or keyIds");
799
- if (keyIds.length > 1024) return badRequest("wait.keyIds too large (max 1024)");
800
- if (typeof cursorRaw !== "string" || cursorRaw.trim() === "") return badRequest("wait.cursor must be a non-empty string");
801
- const cursor = cursorRaw.trim();
802
-
803
- const timeoutMs =
804
- timeoutMsRaw === undefined ? 30_000 : typeof timeoutMsRaw === "number" && Number.isFinite(timeoutMsRaw) ? Math.max(0, Math.min(120_000, timeoutMsRaw)) : null;
805
- if (timeoutMs == null) return badRequest("wait.timeoutMs must be a number (ms)");
806
-
807
- const templateIdsUsedRaw = body?.templateIdsUsed;
808
- if (Array.isArray(templateIdsUsedRaw) && !templateIdsUsedRaw.every((x: any) => typeof x === "string" && x.trim() !== "")) {
809
- return badRequest("wait.templateIdsUsed must be a string array");
810
- }
811
- const templateIdsUsed =
812
- Array.isArray(templateIdsUsedRaw) && templateIdsUsedRaw.length > 0
813
- ? Array.from(new Set(templateIdsUsedRaw.map((s: any) => (typeof s === "string" ? s.trim() : "")).filter((s: string) => s !== "")))
814
- : [];
815
- const interestModeRaw = body?.interestMode;
816
- if (interestModeRaw !== undefined && interestModeRaw !== "fine" && interestModeRaw !== "coarse") {
817
- return badRequest("wait.interestMode must be 'fine' or 'coarse'");
1789
+ const loadSnapshot = (): Response | DetailsSnapshot => {
1790
+ const snapshotRes = buildDetailsSnapshotResult(stream, isIndexStatus ? "index_status" : "details");
1791
+ if (Result.isError(snapshotRes)) {
1792
+ if (snapshotRes.error.status === 404) {
1793
+ return snapshotRes.error.message === "stream expired" ? notFound("stream expired") : notFound();
1794
+ }
1795
+ return internalError(snapshotRes.error.message);
818
1796
  }
819
- const interestMode: "fine" | "coarse" = interestModeRaw === "coarse" ? "coarse" : "fine";
1797
+ return snapshotRes.value;
1798
+ };
820
1799
 
821
- if (interestMode === "fine" && templateIdsUsed.length > 0) {
822
- touch.heartbeatTemplates({ stream, touchCfg, templateIdsUsed });
823
- }
1800
+ let snapshotOrResponse = loadSnapshot();
1801
+ if (snapshotOrResponse instanceof Response) return snapshotOrResponse;
1802
+ let snapshot = snapshotOrResponse;
1803
+ const ifNoneMatch = req.headers.get("if-none-match");
824
1804
 
825
- const declareTemplatesRaw = body?.declareTemplates;
826
- if (Array.isArray(declareTemplatesRaw) && declareTemplatesRaw.length > 0) {
827
- if (declareTemplatesRaw.length > 256) return badRequest("wait.declareTemplates too large (max 256)");
828
- const ttlRaw = body?.inactivityTtlMs;
829
- const inactivityTtlMs =
830
- ttlRaw === undefined
831
- ? touchCfg.templates?.defaultInactivityTtlMs ?? 60 * 60 * 1000
832
- : typeof ttlRaw === "number" && Number.isFinite(ttlRaw) && ttlRaw >= 0
833
- ? Math.floor(ttlRaw)
834
- : null;
835
- if (inactivityTtlMs == null) return badRequest("wait.inactivityTtlMs must be a non-negative number (ms)");
836
-
837
- const templates: Array<{ entity: string; fields: Array<{ name: string; encoding: any }> }> = [];
838
- for (const t of declareTemplatesRaw) {
839
- const entity = typeof t?.entity === "string" ? t.entity.trim() : "";
840
- const fieldsRaw = t?.fields;
841
- if (entity === "" || !Array.isArray(fieldsRaw) || fieldsRaw.length === 0 || fieldsRaw.length > 3) continue;
842
- const fields: Array<{ name: string; encoding: any }> = [];
843
- for (const f of fieldsRaw) {
844
- const name = typeof f?.name === "string" ? f.name.trim() : "";
845
- const encoding = f?.encoding;
846
- if (name === "") continue;
847
- fields.push({ name, encoding });
848
- }
849
- if (fields.length !== fieldsRaw.length) continue;
850
- templates.push({ entity, fields });
851
- }
852
- if (templates.length !== declareTemplatesRaw.length) return badRequest("wait.declareTemplates contains invalid template definitions");
853
- const activeFromTouchOffset = touch.getOrCreateJournal(stream, touchCfg).getCursor();
854
- touch.activateTemplates({
855
- stream,
856
- touchCfg,
857
- baseStreamNextOffset: srow.next_offset,
858
- activeFromTouchOffset,
859
- templates,
860
- inactivityTtlMs,
1805
+ if (!longPoll) {
1806
+ if (ifNoneMatch && ifNoneMatch === snapshot.etag) {
1807
+ return new Response(null, {
1808
+ status: 304,
1809
+ headers: withNosniff({ "cache-control": "no-store", etag: snapshot.etag }),
861
1810
  });
862
1811
  }
1812
+ return new Response(snapshot.body, {
1813
+ status: 200,
1814
+ headers: withNosniff({
1815
+ "content-type": "application/json; charset=utf-8",
1816
+ "cache-control": "no-store",
1817
+ etag: snapshot.etag,
1818
+ }),
1819
+ });
1820
+ }
863
1821
 
864
- const j = touch.getOrCreateJournal(stream, touchCfg);
865
- const runtime = touch.getTouchRuntimeSnapshot({ stream, touchCfg });
866
- let rawFineKeyIds = keyIds;
867
- if (keyIds.length === 0) {
868
- const parsedKeyIds: number[] = [];
869
- for (const key of keys) {
870
- const keyIdRes = touchKeyIdFromRoutingKeyResult(key);
871
- if (Result.isError(keyIdRes)) return internalError();
872
- parsedKeyIds.push(keyIdRes.value);
873
- }
874
- rawFineKeyIds = parsedKeyIds;
875
- }
876
- const templateWaitKeyIds =
877
- templateIdsUsed.length > 0
878
- ? Array.from(new Set(templateIdsUsed.map((templateId) => templateKeyIdFor(templateId) >>> 0)))
879
- : [];
880
- let waitKeyIds = rawFineKeyIds;
881
- let effectiveWaitKind: "fineKey" | "templateKey" | "tableKey" = "fineKey";
882
-
883
- if (interestMode === "coarse") {
884
- effectiveWaitKind = "tableKey";
885
- } else if (runtime.touchMode === "restricted" && templateIdsUsed.length > 0) {
886
- effectiveWaitKind = "templateKey";
887
- } else if (runtime.touchMode === "coarseOnly" && templateIdsUsed.length > 0) {
888
- effectiveWaitKind = "tableKey";
889
- }
1822
+ if (!ifNoneMatch || ifNoneMatch !== snapshot.etag) {
1823
+ return new Response(snapshot.body, {
1824
+ status: 200,
1825
+ headers: withNosniff({
1826
+ "content-type": "application/json; charset=utf-8",
1827
+ "cache-control": "no-store",
1828
+ etag: snapshot.etag,
1829
+ }),
1830
+ });
1831
+ }
890
1832
 
891
- if (effectiveWaitKind === "templateKey") {
892
- waitKeyIds = templateWaitKeyIds;
893
- } else if (effectiveWaitKind === "tableKey" && templateIdsUsed.length > 0) {
894
- const entities = touch.resolveTemplateEntitiesForWait({ stream, templateIdsUsed });
895
- waitKeyIds = Array.from(new Set(entities.map((entity) => tableKeyIdFor(entity) >>> 0)));
1833
+ const deadline = Date.now() + (timeoutMs ?? 3000);
1834
+ while (ifNoneMatch === snapshot.etag) {
1835
+ const remaining = deadline - Date.now();
1836
+ if (remaining <= 0) {
1837
+ return new Response(null, {
1838
+ status: 304,
1839
+ headers: withNosniff({ "cache-control": "no-store", etag: snapshot.etag }),
1840
+ });
896
1841
  }
1842
+ await notifier.waitForDetailsChange(stream, snapshot.version, remaining, req.signal);
1843
+ if (req.signal.aborted) return new Response(null, { status: 204 });
1844
+ snapshotOrResponse = loadSnapshot();
1845
+ if (snapshotOrResponse instanceof Response) return snapshotOrResponse;
1846
+ snapshot = snapshotOrResponse;
1847
+ }
897
1848
 
898
- // Keep fine waits resilient to runtime mode flips: include template-key
899
- // fallbacks even when the current mode is fine. This avoids starvation
900
- // when a long-poll starts in fine mode but DS degrades to restricted
901
- // before that waiter naturally re-issues.
902
- if (interestMode === "fine" && effectiveWaitKind === "fineKey" && templateWaitKeyIds.length > 0) {
903
- const merged = new Set<number>();
904
- for (const keyId of waitKeyIds) merged.add(keyId >>> 0);
905
- for (const keyId of templateWaitKeyIds) merged.add(keyId >>> 0);
906
- waitKeyIds = Array.from(merged);
907
- }
1849
+ return new Response(snapshot.body, {
1850
+ status: 200,
1851
+ headers: withNosniff({
1852
+ "content-type": "application/json; charset=utf-8",
1853
+ "cache-control": "no-store",
1854
+ etag: snapshot.etag,
1855
+ }),
1856
+ });
1857
+ }
908
1858
 
909
- if (waitKeyIds.length === 0) {
910
- waitKeyIds = rawFineKeyIds;
911
- effectiveWaitKind = "fineKey";
912
- }
913
- const hotInterestKeyIds = interestMode === "fine" ? rawFineKeyIds : waitKeyIds;
914
- const releaseHotInterest = touch.beginHotWaitInterest({
1859
+ if (isRoutingKeys) {
1860
+ const srow = db.getStream(stream);
1861
+ if (!srow || db.isDeleted(srow)) return notFound();
1862
+ if (srow.expires_at_ms != null && db.nowMs() > srow.expires_at_ms) return notFound("stream expired");
1863
+ if (req.method !== "GET") return badRequest("unsupported method");
1864
+ const regRes = registry.getRegistryResult(stream);
1865
+ if (Result.isError(regRes)) return internalError();
1866
+ if (regRes.value.routingKey == null) return badRequest("routing key not configured");
1867
+ const limitRaw = url.searchParams.get("limit");
1868
+ const limit = limitRaw == null ? 100 : Number(limitRaw);
1869
+ if (!Number.isFinite(limit) || limit <= 0 || !Number.isInteger(limit)) return badRequest("invalid limit");
1870
+ const after = url.searchParams.get("after");
1871
+ const listRes = indexer?.listRoutingKeysResult
1872
+ ? await runForeground(() => indexer.listRoutingKeysResult!(stream, after, limit))
1873
+ : Result.err({ kind: "invalid_lexicon_index", message: "routing key lexicon unavailable" });
1874
+ if (Result.isError(listRes)) return internalError(listRes.error.message);
1875
+ return json(200, {
1876
+ stream,
1877
+ source: {
1878
+ kind: "routing_key",
1879
+ name: "",
1880
+ },
1881
+ took_ms: listRes.value.tookMs,
1882
+ coverage: {
1883
+ complete: listRes.value.coverage.complete,
1884
+ indexed_segments: listRes.value.coverage.indexedSegments,
1885
+ scanned_uploaded_segments: listRes.value.coverage.scannedUploadedSegments,
1886
+ scanned_local_segments: listRes.value.coverage.scannedLocalSegments,
1887
+ scanned_wal_rows: listRes.value.coverage.scannedWalRows,
1888
+ possible_missing_uploaded_segments: listRes.value.coverage.possibleMissingUploadedSegments,
1889
+ possible_missing_local_segments: listRes.value.coverage.possibleMissingLocalSegments,
1890
+ },
1891
+ timing: {
1892
+ lexicon_run_get_ms: listRes.value.timing.lexiconRunGetMs,
1893
+ lexicon_decode_ms: listRes.value.timing.lexiconDecodeMs,
1894
+ lexicon_enumerate_ms: listRes.value.timing.lexiconEnumerateMs,
1895
+ lexicon_merge_ms: listRes.value.timing.lexiconMergeMs,
1896
+ fallback_scan_ms: listRes.value.timing.fallbackScanMs,
1897
+ fallback_segment_get_ms: listRes.value.timing.fallbackSegmentGetMs,
1898
+ fallback_wal_scan_ms: listRes.value.timing.fallbackWalScanMs,
1899
+ lexicon_runs_loaded: listRes.value.timing.lexiconRunsLoaded,
1900
+ },
1901
+ keys: listRes.value.keys,
1902
+ next_after: listRes.value.nextAfter,
1903
+ });
1904
+ }
1905
+
1906
+ if (isSearch) {
1907
+ const srow = db.getStream(stream);
1908
+ if (!srow || db.isDeleted(srow)) return notFound();
1909
+ if (srow.expires_at_ms != null && db.nowMs() > srow.expires_at_ms) return notFound("stream expired");
1910
+
1911
+ const regRes = registry.getRegistryResult(stream);
1912
+ if (Result.isError(regRes)) return internalError();
1913
+
1914
+ const respondSearch = async (requestBody: unknown, fromQuery: boolean): Promise<Response> => {
1915
+ const requestRes = fromQuery
1916
+ ? parseSearchRequestQueryResult(regRes.value, url.searchParams)
1917
+ : parseSearchRequestBodyResult(regRes.value, requestBody);
1918
+ if (Result.isError(requestRes)) return badRequest(requestRes.error.message);
1919
+ const request = {
1920
+ ...requestRes.value,
1921
+ timeoutMs: clampSearchRequestTimeoutMs(requestRes.value.timeoutMs),
1922
+ };
1923
+ const searchRes = await runForegroundWithGate(searchGate, () => reader.searchResult({ stream, request }));
1924
+ if (Result.isError(searchRes)) return readerErrorResponse(searchRes.error);
1925
+ const status = searchRes.value.timedOut ? 408 : 200;
1926
+ return json(status, {
915
1927
  stream,
916
- touchCfg,
917
- keyIds: hotInterestKeyIds,
918
- templateIdsUsed,
919
- interestMode,
920
- });
921
- try {
922
- let sinceGen: number;
923
- if (cursor === "now") {
924
- sinceGen = j.getGeneration();
925
- } else {
926
- const parsed = parseTouchCursor(cursor);
927
- if (!parsed) return badRequest("wait.cursor must be in the form <epochHex>:<generation> or 'now'");
928
- if (parsed.epoch !== j.getEpoch()) {
929
- const latencyMs = Date.now() - waitStartMs;
930
- touch.recordWaitMetrics({ stream, touchCfg, keysCount: waitKeyIds.length, outcome: "stale", latencyMs });
931
- return json(200, {
932
- stale: true,
933
- cursor: j.getCursor(),
934
- epoch: j.getEpoch(),
935
- generation: j.getGeneration(),
936
- effectiveWaitKind,
937
- bucketMaxSourceOffsetSeq: j.getLastFlushedSourceOffsetSeq().toString(),
938
- flushAtMs: j.getLastFlushAtMs(),
939
- bucketStartMs: j.getLastBucketStartMs(),
940
- error: { code: "stale", message: "cursor epoch mismatch; rerun/re-subscribe and start from cursor" },
941
- });
942
- }
943
- sinceGen = parsed.generation;
944
- }
1928
+ snapshot_end_offset: searchRes.value.snapshotEndOffset,
1929
+ took_ms: searchRes.value.tookMs,
1930
+ timed_out: searchRes.value.timedOut,
1931
+ timeout_ms: searchRes.value.timeoutMs,
1932
+ coverage: {
1933
+ mode: searchRes.value.coverage.mode,
1934
+ complete: searchRes.value.coverage.complete,
1935
+ stream_head_offset: searchRes.value.coverage.streamHeadOffset,
1936
+ visible_through_offset: searchRes.value.coverage.visibleThroughOffset,
1937
+ visible_through_primary_timestamp_max: searchRes.value.coverage.visibleThroughPrimaryTimestampMax,
1938
+ oldest_omitted_append_at: searchRes.value.coverage.oldestOmittedAppendAt,
1939
+ possible_missing_events_upper_bound: searchRes.value.coverage.possibleMissingEventsUpperBound,
1940
+ possible_missing_uploaded_segments: searchRes.value.coverage.possibleMissingUploadedSegments,
1941
+ possible_missing_sealed_rows: searchRes.value.coverage.possibleMissingSealedRows,
1942
+ possible_missing_wal_rows: searchRes.value.coverage.possibleMissingWalRows,
1943
+ indexed_segments: searchRes.value.coverage.indexedSegments,
1944
+ indexed_segment_time_ms: searchRes.value.coverage.indexedSegmentTimeMs,
1945
+ fts_section_get_ms: searchRes.value.coverage.ftsSectionGetMs,
1946
+ fts_decode_ms: searchRes.value.coverage.ftsDecodeMs,
1947
+ fts_clause_estimate_ms: searchRes.value.coverage.ftsClauseEstimateMs,
1948
+ scanned_segments: searchRes.value.coverage.scannedSegments,
1949
+ scanned_segment_time_ms: searchRes.value.coverage.scannedSegmentTimeMs,
1950
+ scanned_tail_docs: searchRes.value.coverage.scannedTailDocs,
1951
+ scanned_tail_time_ms: searchRes.value.coverage.scannedTailTimeMs,
1952
+ exact_candidate_time_ms: searchRes.value.coverage.exactCandidateTimeMs,
1953
+ index_families_used: searchRes.value.coverage.indexFamiliesUsed,
1954
+ },
1955
+ total: searchRes.value.total,
1956
+ hits: searchRes.value.hits,
1957
+ next_search_after: searchRes.value.nextSearchAfter,
1958
+ }, searchResponseHeaders(searchRes.value));
1959
+ };
945
1960
 
946
- const nowGen = j.getGeneration();
947
- if (sinceGen > nowGen) sinceGen = nowGen;
948
-
949
- if (j.maybeTouchedSinceAny(waitKeyIds, sinceGen)) {
950
- const latencyMs = Date.now() - waitStartMs;
951
- touch.recordWaitMetrics({ stream, touchCfg, keysCount: waitKeyIds.length, outcome: "touched", latencyMs });
952
- return json(200, {
953
- touched: true,
954
- cursor: j.getCursor(),
955
- effectiveWaitKind,
956
- bucketMaxSourceOffsetSeq: j.getLastFlushedSourceOffsetSeq().toString(),
957
- flushAtMs: j.getLastFlushAtMs(),
958
- bucketStartMs: j.getLastBucketStartMs(),
959
- });
960
- }
1961
+ if (req.method === "GET") {
1962
+ return respondSearch(null, true);
1963
+ }
961
1964
 
962
- const deadline = Date.now() + timeoutMs;
963
- const remaining = deadline - Date.now();
964
- if (remaining <= 0) {
965
- const latencyMs = Date.now() - waitStartMs;
966
- touch.recordWaitMetrics({ stream, touchCfg, keysCount: waitKeyIds.length, outcome: "timeout", latencyMs });
967
- return json(200, {
968
- touched: false,
969
- cursor: j.getCursor(),
970
- effectiveWaitKind,
971
- bucketMaxSourceOffsetSeq: j.getLastFlushedSourceOffsetSeq().toString(),
972
- flushAtMs: j.getLastFlushAtMs(),
973
- bucketStartMs: j.getLastBucketStartMs(),
974
- });
975
- }
1965
+ if (req.method === "POST") {
1966
+ let body: unknown;
1967
+ try {
1968
+ body = await req.json();
1969
+ } catch {
1970
+ return badRequest("search request must be valid JSON");
1971
+ }
1972
+ return respondSearch(body, false);
1973
+ }
976
1974
 
977
- const afterGen = j.getGeneration();
978
- const hit = await j.waitForAny({ keys: waitKeyIds, afterGeneration: afterGen, timeoutMs: remaining, signal: req.signal });
979
- if (req.signal.aborted) return new Response(null, { status: 204 });
1975
+ return badRequest("unsupported method");
1976
+ }
980
1977
 
981
- if (hit == null) {
982
- const latencyMs = Date.now() - waitStartMs;
983
- touch.recordWaitMetrics({ stream, touchCfg, keysCount: waitKeyIds.length, outcome: "timeout", latencyMs });
984
- return json(200, {
985
- touched: false,
986
- cursor: j.getCursor(),
987
- effectiveWaitKind,
988
- bucketMaxSourceOffsetSeq: j.getLastFlushedSourceOffsetSeq().toString(),
989
- flushAtMs: j.getLastFlushAtMs(),
990
- bucketStartMs: j.getLastBucketStartMs(),
991
- });
992
- }
1978
+ if (isAggregate) {
1979
+ const srow = db.getStream(stream);
1980
+ if (!srow || db.isDeleted(srow)) return notFound();
1981
+ if (srow.expires_at_ms != null && db.nowMs() > srow.expires_at_ms) return notFound("stream expired");
1982
+ if (req.method !== "POST") return badRequest("unsupported method");
993
1983
 
994
- const latencyMs = Date.now() - waitStartMs;
995
- touch.recordWaitMetrics({ stream, touchCfg, keysCount: waitKeyIds.length, outcome: "touched", latencyMs });
996
- return json(200, {
997
- touched: true,
998
- cursor: j.getCursor(),
999
- effectiveWaitKind,
1000
- bucketMaxSourceOffsetSeq: hit.bucketMaxSourceOffsetSeq.toString(),
1001
- flushAtMs: hit.flushAtMs,
1002
- bucketStartMs: hit.bucketStartMs,
1003
- });
1004
- } finally {
1005
- releaseHotInterest();
1006
- }
1984
+ const regRes = registry.getRegistryResult(stream);
1985
+ if (Result.isError(regRes)) return internalError();
1986
+
1987
+ let body: unknown;
1988
+ try {
1989
+ body = await req.json();
1990
+ } catch {
1991
+ return badRequest("aggregate request must be valid JSON");
1007
1992
  }
1993
+
1994
+ const requestRes = parseAggregateRequestBodyResult(regRes.value, body);
1995
+ if (Result.isError(requestRes)) return badRequest(requestRes.error.message);
1996
+ const aggregateRes = await runForegroundWithGate(searchGate, () => reader.aggregateResult({ stream, request: requestRes.value }));
1997
+ if (Result.isError(aggregateRes)) return readerErrorResponse(aggregateRes.error);
1998
+ return json(200, {
1999
+ stream,
2000
+ rollup: aggregateRes.value.rollup,
2001
+ from: aggregateRes.value.from,
2002
+ to: aggregateRes.value.to,
2003
+ interval: aggregateRes.value.interval,
2004
+ coverage: {
2005
+ mode: aggregateRes.value.coverage.mode,
2006
+ complete: aggregateRes.value.coverage.complete,
2007
+ stream_head_offset: aggregateRes.value.coverage.streamHeadOffset,
2008
+ visible_through_offset: aggregateRes.value.coverage.visibleThroughOffset,
2009
+ visible_through_primary_timestamp_max: aggregateRes.value.coverage.visibleThroughPrimaryTimestampMax,
2010
+ oldest_omitted_append_at: aggregateRes.value.coverage.oldestOmittedAppendAt,
2011
+ possible_missing_events_upper_bound: aggregateRes.value.coverage.possibleMissingEventsUpperBound,
2012
+ possible_missing_uploaded_segments: aggregateRes.value.coverage.possibleMissingUploadedSegments,
2013
+ possible_missing_sealed_rows: aggregateRes.value.coverage.possibleMissingSealedRows,
2014
+ possible_missing_wal_rows: aggregateRes.value.coverage.possibleMissingWalRows,
2015
+ used_rollups: aggregateRes.value.coverage.usedRollups,
2016
+ indexed_segments: aggregateRes.value.coverage.indexedSegments,
2017
+ scanned_segments: aggregateRes.value.coverage.scannedSegments,
2018
+ scanned_tail_docs: aggregateRes.value.coverage.scannedTailDocs,
2019
+ index_families_used: aggregateRes.value.coverage.indexFamiliesUsed,
2020
+ },
2021
+ buckets: aggregateRes.value.buckets,
2022
+ });
2023
+ }
2024
+
2025
+ if (touchMode) {
2026
+ const srow = db.getStream(stream);
2027
+ if (!srow || db.isDeleted(srow)) return notFound();
2028
+ if (srow.expires_at_ms != null && db.nowMs() > srow.expires_at_ms) return notFound("stream expired");
2029
+
2030
+ const profileRes = profiles.getProfileResult(stream, srow);
2031
+ if (Result.isError(profileRes)) return internalError("invalid stream profile");
2032
+ const touchCapability = resolveTouchCapability(profileRes.value);
2033
+ if (!touchCapability?.handleRoute) return notFound("touch not enabled");
2034
+ return touchCapability.handleRoute({
2035
+ route: touchMode,
2036
+ req,
2037
+ stream,
2038
+ streamRow: srow,
2039
+ profile: profileRes.value,
2040
+ db,
2041
+ touchManager: touch,
2042
+ respond: { json, badRequest, internalError, notFound },
2043
+ });
1008
2044
  }
1009
2045
 
1010
2046
  // Stream lifecycle.
@@ -1029,115 +2065,130 @@ export function createAppCore(cfg: Config, opts: CreateAppCoreOptions): App {
1029
2065
 
1030
2066
  const contentType = normalizeContentType(req.headers.get("content-type")) ?? "application/octet-stream";
1031
2067
  const routingKeyHeader = req.headers.get("stream-key");
2068
+ const leaveAppendPhase = memorySampler?.enter("append", {
2069
+ route: "put",
2070
+ stream,
2071
+ content_type: contentType,
2072
+ });
2073
+ try {
2074
+ return await runWithGate(ingestGate, async () => {
2075
+ const ab = await req.arrayBuffer();
2076
+ if (ab.byteLength > cfg.appendMaxBodyBytes) return tooLarge(`body too large (max ${cfg.appendMaxBodyBytes})`);
2077
+ const bodyBytes = new Uint8Array(ab);
2078
+
2079
+ let srow = db.getStream(stream);
2080
+ if (srow && db.isDeleted(srow)) {
2081
+ db.hardDeleteStream(stream);
2082
+ srow = null;
2083
+ }
2084
+ if (srow && srow.expires_at_ms != null && db.nowMs() > srow.expires_at_ms) {
2085
+ db.hardDeleteStream(stream);
2086
+ srow = null;
2087
+ }
1032
2088
 
1033
- const memReject = rejectIfMemoryLimited();
1034
- if (memReject) return memReject;
1035
- const ab = await req.arrayBuffer();
1036
- if (ab.byteLength > cfg.appendMaxBodyBytes) return tooLarge(`body too large (max ${cfg.appendMaxBodyBytes})`);
1037
- const bodyBytes = new Uint8Array(ab);
1038
-
1039
- let srow = db.getStream(stream);
1040
- if (srow && db.isDeleted(srow)) {
1041
- db.hardDeleteStream(stream);
1042
- srow = null;
1043
- }
1044
- if (srow && srow.expires_at_ms != null && db.nowMs() > srow.expires_at_ms) {
1045
- db.hardDeleteStream(stream);
1046
- srow = null;
1047
- }
1048
-
1049
- if (srow) {
1050
- const existingClosed = srow.closed !== 0;
1051
- const existingContentType = normalizeContentType(srow.content_type) ?? srow.content_type;
1052
- const ttlMatch =
1053
- ttlSeconds != null
1054
- ? srow.ttl_seconds != null && srow.ttl_seconds === ttlSeconds
1055
- : expiresAtMs != null
1056
- ? srow.ttl_seconds == null && srow.expires_at_ms != null && srow.expires_at_ms === expiresAtMs
1057
- : srow.ttl_seconds == null && srow.expires_at_ms == null;
1058
- if (existingContentType !== contentType || existingClosed !== streamClosed || !ttlMatch) {
1059
- return conflict("stream config mismatch");
1060
- }
2089
+ if (srow) {
2090
+ const existingClosed = srow.closed !== 0;
2091
+ const existingContentType = normalizeContentType(srow.content_type) ?? srow.content_type;
2092
+ const ttlMatch =
2093
+ ttlSeconds != null
2094
+ ? srow.ttl_seconds != null && srow.ttl_seconds === ttlSeconds
2095
+ : expiresAtMs != null
2096
+ ? srow.ttl_seconds == null && srow.expires_at_ms != null && srow.expires_at_ms === expiresAtMs
2097
+ : srow.ttl_seconds == null && srow.expires_at_ms == null;
2098
+ if (existingContentType !== contentType || existingClosed !== streamClosed || !ttlMatch) {
2099
+ return conflict("stream config mismatch");
2100
+ }
1061
2101
 
1062
- const tailOffset = encodeOffset(srow.epoch, srow.next_offset - 1n);
1063
- const headers: Record<string, string> = {
1064
- "content-type": existingContentType,
1065
- "stream-next-offset": tailOffset,
1066
- };
1067
- if (existingClosed) headers["stream-closed"] = "true";
1068
- if (srow.expires_at_ms != null) headers["stream-expires-at"] = new Date(Number(srow.expires_at_ms)).toISOString();
1069
- return new Response(null, { status: 200, headers: withNosniff(headers) });
1070
- }
2102
+ const tailOffset = encodeOffset(srow.epoch, srow.next_offset - 1n);
2103
+ const headers: Record<string, string> = {
2104
+ "content-type": existingContentType,
2105
+ "stream-next-offset": tailOffset,
2106
+ };
2107
+ if (existingClosed) headers["stream-closed"] = "true";
2108
+ if (srow.expires_at_ms != null) headers["stream-expires-at"] = new Date(Number(srow.expires_at_ms)).toISOString();
2109
+ return new Response(null, { status: 200, headers: withNosniff(headers) });
2110
+ }
1071
2111
 
1072
- db.ensureStream(stream, { contentType, expiresAtMs, ttlSeconds, closed: false });
1073
- let lastOffset = -1n;
1074
- let appendedRows = 0;
1075
- let closedNow = false;
2112
+ db.ensureStream(stream, { contentType, expiresAtMs, ttlSeconds, closed: false });
2113
+ notifier.notifyDetailsChanged(stream);
2114
+ let lastOffset = -1n;
2115
+ let appendedRows = 0;
2116
+ let closedNow = false;
2117
+
2118
+ if (bodyBytes.byteLength > 0) {
2119
+ const rowsRes = buildAppendRowsResult(stream, bodyBytes, contentType, routingKeyHeader, true);
2120
+ if (Result.isError(rowsRes)) {
2121
+ if (rowsRes.error.status === 500) return internalError();
2122
+ return badRequest(rowsRes.error.message);
2123
+ }
2124
+ const rows = rowsRes.value.rows;
2125
+ appendedRows = rows.length;
2126
+ if (rows.length > 0 || streamClosed) {
2127
+ const appendResOrResponse = await awaitAppendWithTimeout(enqueueAppend({
2128
+ stream,
2129
+ baseAppendMs: db.nowMs(),
2130
+ rows,
2131
+ contentType,
2132
+ close: streamClosed,
2133
+ }));
2134
+ if (appendResOrResponse instanceof Response) return appendResOrResponse;
2135
+ const appendRes = appendResOrResponse;
2136
+ if (Result.isError(appendRes)) {
2137
+ if (appendRes.error.kind === "overloaded") return overloaded();
2138
+ return json(500, { error: { code: "internal", message: "append failed" } });
2139
+ }
2140
+ lastOffset = appendRes.value.lastOffset;
2141
+ closedNow = appendRes.value.closed;
2142
+ }
2143
+ } else if (streamClosed) {
2144
+ const appendResOrResponse = await awaitAppendWithTimeout(enqueueAppend({
2145
+ stream,
2146
+ baseAppendMs: db.nowMs(),
2147
+ rows: [],
2148
+ contentType,
2149
+ close: true,
2150
+ }));
2151
+ if (appendResOrResponse instanceof Response) return appendResOrResponse;
2152
+ const appendRes = appendResOrResponse;
2153
+ if (Result.isError(appendRes)) {
2154
+ if (appendRes.error.kind === "overloaded") return overloaded();
2155
+ return json(500, { error: { code: "internal", message: "close failed" } });
2156
+ }
2157
+ lastOffset = appendRes.value.lastOffset;
2158
+ closedNow = appendRes.value.closed;
2159
+ }
1076
2160
 
1077
- if (bodyBytes.byteLength > 0) {
1078
- const rowsRes = buildAppendRowsResult(stream, bodyBytes, contentType, routingKeyHeader, true);
1079
- if (Result.isError(rowsRes)) {
1080
- if (rowsRes.error.status === 500) return internalError();
1081
- return badRequest(rowsRes.error.message);
1082
- }
1083
- const rows = rowsRes.value.rows;
1084
- appendedRows = rows.length;
1085
- if (rows.length > 0 || streamClosed) {
1086
- const appendRes = await enqueueAppend({
2161
+ recordAppendOutcome({
1087
2162
  stream,
1088
- baseAppendMs: db.nowMs(),
1089
- rows,
1090
- contentType,
1091
- close: streamClosed,
2163
+ lastOffset,
2164
+ appendedRows,
2165
+ metricsBytes: bodyBytes.byteLength,
2166
+ ingestedBytes: bodyBytes.byteLength,
2167
+ touched: bodyBytes.byteLength > 0 || streamClosed,
2168
+ closed: closedNow,
1092
2169
  });
1093
- if (Result.isError(appendRes)) {
1094
- if (appendRes.error.kind === "overloaded") return json(429, { error: { code: "overloaded", message: "ingest queue full" } });
1095
- return json(500, { error: { code: "internal", message: "append failed" } });
1096
- }
1097
- lastOffset = appendRes.value.lastOffset;
1098
- closedNow = appendRes.value.closed;
1099
- }
1100
- } else if (streamClosed) {
1101
- const appendRes = await enqueueAppend({
1102
- stream,
1103
- baseAppendMs: db.nowMs(),
1104
- rows: [],
1105
- contentType,
1106
- close: true,
2170
+
2171
+ const createdRow = db.getStream(stream)!;
2172
+ const tailOffset = encodeOffset(createdRow.epoch, createdRow.next_offset - 1n);
2173
+ const headers: Record<string, string> = {
2174
+ "content-type": contentType,
2175
+ "stream-next-offset": appendedRows > 0 || streamClosed ? encodeOffset(createdRow.epoch, lastOffset) : tailOffset,
2176
+ location: req.url,
2177
+ };
2178
+ if (streamClosed || closedNow) headers["stream-closed"] = "true";
2179
+ if (createdRow.expires_at_ms != null) headers["stream-expires-at"] = new Date(Number(createdRow.expires_at_ms)).toISOString();
2180
+ return new Response(null, { status: 201, headers: withNosniff(headers) });
1107
2181
  });
1108
- if (Result.isError(appendRes)) {
1109
- if (appendRes.error.kind === "overloaded") return json(429, { error: { code: "overloaded", message: "ingest queue full" } });
1110
- return json(500, { error: { code: "internal", message: "close failed" } });
1111
- }
1112
- lastOffset = appendRes.value.lastOffset;
1113
- closedNow = appendRes.value.closed;
2182
+ } finally {
2183
+ leaveAppendPhase?.();
1114
2184
  }
1115
-
1116
- recordAppendOutcome({
1117
- stream,
1118
- lastOffset,
1119
- appendedRows,
1120
- metricsBytes: bodyBytes.byteLength,
1121
- ingestedBytes: bodyBytes.byteLength,
1122
- touched: bodyBytes.byteLength > 0 || streamClosed,
1123
- closed: closedNow,
1124
- });
1125
-
1126
- const createdRow = db.getStream(stream)!;
1127
- const tailOffset = encodeOffset(createdRow.epoch, createdRow.next_offset - 1n);
1128
- const headers: Record<string, string> = {
1129
- "content-type": contentType,
1130
- "stream-next-offset": appendedRows > 0 || streamClosed ? encodeOffset(createdRow.epoch, lastOffset) : tailOffset,
1131
- location: req.url,
1132
- };
1133
- if (streamClosed || closedNow) headers["stream-closed"] = "true";
1134
- if (createdRow.expires_at_ms != null) headers["stream-expires-at"] = new Date(Number(createdRow.expires_at_ms)).toISOString();
1135
- return new Response(null, { status: 201, headers: withNosniff(headers) });
1136
2185
  }
1137
2186
 
1138
2187
  if (req.method === "DELETE") {
1139
2188
  const deleted = db.deleteStream(stream);
1140
2189
  if (!deleted) return notFound();
2190
+ notifier.notifyDetailsChanged(stream);
2191
+ notifier.notifyClose(stream);
1141
2192
  await uploader.publishManifest(stream);
1142
2193
  return new Response(null, { status: 204, headers: withNosniff() });
1143
2194
  }
@@ -1197,100 +2248,111 @@ export function createAppCore(cfg: Config, opts: CreateAppCoreOptions): App {
1197
2248
  baseAppendMs = tsRes.value;
1198
2249
  }
1199
2250
 
1200
- const memReject = rejectIfMemoryLimited();
1201
- if (memReject) return memReject;
1202
- const ab = await req.arrayBuffer();
1203
- if (ab.byteLength > cfg.appendMaxBodyBytes) return tooLarge(`body too large (max ${cfg.appendMaxBodyBytes})`);
1204
- const bodyBytes = new Uint8Array(ab);
1205
-
1206
- const isCloseOnly = streamClosed && bodyBytes.byteLength === 0;
1207
- if (bodyBytes.byteLength === 0 && !streamClosed) return badRequest("empty body");
1208
-
1209
- let reqContentType = normalizeContentType(req.headers.get("content-type"));
1210
- if (!isCloseOnly && !reqContentType) return badRequest("missing content-type");
1211
-
1212
- const routingKeyHeader = req.headers.get("stream-key");
1213
- let rows: AppendRow[] = [];
1214
- if (!isCloseOnly) {
1215
- const rowsRes = buildAppendRowsResult(stream, bodyBytes, reqContentType!, routingKeyHeader, false);
1216
- if (Result.isError(rowsRes)) {
1217
- if (rowsRes.error.status === 500) return internalError();
1218
- return badRequest(rowsRes.error.message);
1219
- }
1220
- rows = rowsRes.value.rows;
1221
- }
1222
-
1223
- const appendRes = await enqueueAppend({
2251
+ const leaveAppendPhase = memorySampler?.enter("append", {
2252
+ route: "post",
1224
2253
  stream,
1225
- baseAppendMs,
1226
- rows,
1227
- contentType: reqContentType ?? streamContentType,
1228
- streamSeq,
1229
- producer,
1230
- close: streamClosed,
2254
+ stream_content_type: streamContentType,
1231
2255
  });
2256
+ try {
2257
+ return await runWithGate(ingestGate, async () => {
2258
+ const ab = await req.arrayBuffer();
2259
+ if (ab.byteLength > cfg.appendMaxBodyBytes) return tooLarge(`body too large (max ${cfg.appendMaxBodyBytes})`);
2260
+ const bodyBytes = new Uint8Array(ab);
2261
+
2262
+ const isCloseOnly = streamClosed && bodyBytes.byteLength === 0;
2263
+ if (bodyBytes.byteLength === 0 && !streamClosed) return badRequest("empty body");
2264
+
2265
+ let reqContentType = normalizeContentType(req.headers.get("content-type"));
2266
+ if (!isCloseOnly && !reqContentType) return badRequest("missing content-type");
2267
+
2268
+ const routingKeyHeader = req.headers.get("stream-key");
2269
+ let rows: AppendRow[] = [];
2270
+ if (!isCloseOnly) {
2271
+ const rowsRes = buildAppendRowsResult(stream, bodyBytes, reqContentType!, routingKeyHeader, false);
2272
+ if (Result.isError(rowsRes)) {
2273
+ if (rowsRes.error.status === 500) return internalError();
2274
+ return badRequest(rowsRes.error.message);
2275
+ }
2276
+ rows = rowsRes.value.rows;
2277
+ }
2278
+
2279
+ const appendResOrResponse = await awaitAppendWithTimeout(enqueueAppend({
2280
+ stream,
2281
+ baseAppendMs,
2282
+ rows,
2283
+ contentType: reqContentType ?? streamContentType,
2284
+ streamSeq,
2285
+ producer,
2286
+ close: streamClosed,
2287
+ }));
2288
+ if (appendResOrResponse instanceof Response) return appendResOrResponse;
2289
+ const appendRes = appendResOrResponse;
1232
2290
 
1233
- if (Result.isError(appendRes)) {
1234
- const err = appendRes.error;
1235
- if (err.kind === "overloaded") return json(429, { error: { code: "overloaded", message: "ingest queue full" } });
1236
- if (err.kind === "gone") return notFound("stream expired");
1237
- if (err.kind === "not_found") return notFound();
1238
- if (err.kind === "content_type_mismatch") return conflict("content-type mismatch");
1239
- if (err.kind === "stream_seq") {
1240
- return conflict("sequence mismatch", {
1241
- "stream-expected-seq": err.expected,
1242
- "stream-received-seq": err.received,
2291
+ if (Result.isError(appendRes)) {
2292
+ const err = appendRes.error;
2293
+ if (err.kind === "overloaded") return overloaded();
2294
+ if (err.kind === "gone") return notFound("stream expired");
2295
+ if (err.kind === "not_found") return notFound();
2296
+ if (err.kind === "content_type_mismatch") return conflict("content-type mismatch");
2297
+ if (err.kind === "stream_seq") {
2298
+ return conflict("sequence mismatch", {
2299
+ "stream-expected-seq": err.expected,
2300
+ "stream-received-seq": err.received,
2301
+ });
2302
+ }
2303
+ if (err.kind === "closed") {
2304
+ const headers: Record<string, string> = {
2305
+ "stream-next-offset": encodeOffset(srow.epoch, err.lastOffset),
2306
+ "stream-closed": "true",
2307
+ };
2308
+ return new Response(null, { status: 409, headers: withNosniff(headers) });
2309
+ }
2310
+ if (err.kind === "producer_stale_epoch") {
2311
+ return new Response(null, {
2312
+ status: 403,
2313
+ headers: withNosniff({ "producer-epoch": String(err.producerEpoch) }),
2314
+ });
2315
+ }
2316
+ if (err.kind === "producer_gap") {
2317
+ return new Response(null, {
2318
+ status: 409,
2319
+ headers: withNosniff({
2320
+ "producer-expected-seq": String(err.expected),
2321
+ "producer-received-seq": String(err.received),
2322
+ }),
2323
+ });
2324
+ }
2325
+ if (err.kind === "producer_epoch_seq") return badRequest("invalid producer sequence");
2326
+ return json(500, { error: { code: "internal", message: "append failed" } });
2327
+ }
2328
+ const res = appendRes.value;
2329
+
2330
+ const appendBytes = rows.reduce((acc, r) => acc + r.payload.byteLength, 0);
2331
+ recordAppendOutcome({
2332
+ stream,
2333
+ lastOffset: res.lastOffset,
2334
+ appendedRows: res.appendedRows,
2335
+ metricsBytes: appendBytes,
2336
+ ingestedBytes: bodyBytes.byteLength,
2337
+ touched: true,
2338
+ closed: res.closed,
1243
2339
  });
1244
- }
1245
- if (err.kind === "closed") {
2340
+
1246
2341
  const headers: Record<string, string> = {
1247
- "stream-next-offset": encodeOffset(srow.epoch, err.lastOffset),
1248
- "stream-closed": "true",
2342
+ "stream-next-offset": encodeOffset(srow.epoch, res.lastOffset),
1249
2343
  };
1250
- return new Response(null, { status: 409, headers: withNosniff(headers) });
1251
- }
1252
- if (err.kind === "producer_stale_epoch") {
1253
- return new Response(null, {
1254
- status: 403,
1255
- headers: withNosniff({ "producer-epoch": String(err.producerEpoch) }),
1256
- });
1257
- }
1258
- if (err.kind === "producer_gap") {
1259
- return new Response(null, {
1260
- status: 409,
1261
- headers: withNosniff({
1262
- "producer-expected-seq": String(err.expected),
1263
- "producer-received-seq": String(err.received),
1264
- }),
1265
- });
1266
- }
1267
- if (err.kind === "producer_epoch_seq") return badRequest("invalid producer sequence");
1268
- return json(500, { error: { code: "internal", message: "append failed" } });
1269
- }
1270
- const res = appendRes.value;
1271
-
1272
- const appendBytes = rows.reduce((acc, r) => acc + r.payload.byteLength, 0);
1273
- recordAppendOutcome({
1274
- stream,
1275
- lastOffset: res.lastOffset,
1276
- appendedRows: res.appendedRows,
1277
- metricsBytes: appendBytes,
1278
- ingestedBytes: bodyBytes.byteLength,
1279
- touched: true,
1280
- closed: res.closed,
1281
- });
2344
+ if (res.closed) headers["stream-closed"] = "true";
2345
+ if (producer && res.producer) {
2346
+ headers["producer-epoch"] = String(res.producer.epoch);
2347
+ headers["producer-seq"] = String(res.producer.seq);
2348
+ }
1282
2349
 
1283
- const headers: Record<string, string> = {
1284
- "stream-next-offset": encodeOffset(srow.epoch, res.lastOffset),
1285
- };
1286
- if (res.closed) headers["stream-closed"] = "true";
1287
- if (producer && res.producer) {
1288
- headers["producer-epoch"] = String(res.producer.epoch);
1289
- headers["producer-seq"] = String(res.producer.seq);
2350
+ const status = producer && res.appendedRows > 0 ? 200 : 204;
2351
+ return new Response(null, { status, headers: withNosniff(headers) });
2352
+ });
2353
+ } finally {
2354
+ leaveAppendPhase?.();
1290
2355
  }
1291
-
1292
- const status = producer && res.appendedRows > 0 ? 200 : 204;
1293
- return new Response(null, { status, headers: withNosniff(headers) });
1294
2356
  }
1295
2357
 
1296
2358
  if (req.method === "GET") {
@@ -1311,6 +2373,18 @@ export function createAppCore(cfg: Config, opts: CreateAppCoreOptions): App {
1311
2373
 
1312
2374
  const pathKey = pathKeyParam ?? null;
1313
2375
  const key = pathKey ?? url.searchParams.get("key");
2376
+ const rawFilter = url.searchParams.get("filter");
2377
+ let filterInput: string | null = null;
2378
+ let filter = null;
2379
+ if (rawFilter != null) {
2380
+ if (!isJsonStream) return badRequest("filter requires application/json stream content-type");
2381
+ filterInput = rawFilter.trim();
2382
+ const regRes = registry.getRegistryResult(stream);
2383
+ if (Result.isError(regRes)) return internalError();
2384
+ const filterRes = parseReadFilterResult(regRes.value, filterInput);
2385
+ if (Result.isError(filterRes)) return badRequest(filterRes.error.message);
2386
+ filter = filterRes.value;
2387
+ }
1314
2388
 
1315
2389
  const liveParam = url.searchParams.get("live") ?? "";
1316
2390
  const cursorParam = url.searchParams.get("cursor");
@@ -1319,6 +2393,7 @@ export function createAppCore(cfg: Config, opts: CreateAppCoreOptions): App {
1319
2393
  else if (liveParam === "long-poll" || liveParam === "true" || liveParam === "1") mode = "long-poll";
1320
2394
  else if (liveParam === "sse") mode = "sse";
1321
2395
  else return badRequest("invalid live mode");
2396
+ if (filter && mode === "sse") return badRequest("filter does not support live=sse");
1322
2397
 
1323
2398
  const timeout = url.searchParams.get("timeout") ?? url.searchParams.get("timeout_ms");
1324
2399
  let timeoutMs: number | null = null;
@@ -1362,7 +2437,7 @@ export function createAppCore(cfg: Config, opts: CreateAppCoreOptions): App {
1362
2437
  const upToDate = batch.nextOffsetSeq === batch.endOffsetSeq;
1363
2438
  const closedAtTail = srow.closed !== 0 && upToDate;
1364
2439
  const etag = includeEtag
1365
- ? `W/\"slice:${canonicalizeOffset(offset!)}:${batch.nextOffset}:key=${key ?? ""}:fmt=${format}\"`
2440
+ ? `W/\"slice:${canonicalizeOffset(offset!)}:${batch.nextOffset}:key=${key ?? ""}:fmt=${format}:filter=${filterInput ? encodeURIComponent(filterInput) : ""}\"`
1366
2441
  : null;
1367
2442
  const baseHeaders: Record<string, string> = {
1368
2443
  "stream-next-offset": batch.nextOffset,
@@ -1374,12 +2449,31 @@ export function createAppCore(cfg: Config, opts: CreateAppCoreOptions): App {
1374
2449
  if (cacheControl) baseHeaders["cache-control"] = cacheControl;
1375
2450
  if (etag) baseHeaders["etag"] = etag;
1376
2451
  if (srow.expires_at_ms != null) baseHeaders["stream-expires-at"] = new Date(Number(srow.expires_at_ms)).toISOString();
2452
+ if (batch.filterScanLimitReached) {
2453
+ baseHeaders["stream-filter-scan-limit-reached"] = "true";
2454
+ baseHeaders["stream-filter-scan-limit-bytes"] = String(batch.filterScanLimitBytes ?? 0);
2455
+ baseHeaders["stream-filter-scanned-bytes"] = String(batch.filterScannedBytes ?? 0);
2456
+ }
1377
2457
 
1378
2458
  if (etag && ifNoneMatch && ifNoneMatch === etag) {
1379
2459
  return new Response(null, { status: 304, headers: withNosniff(baseHeaders) });
1380
2460
  }
1381
2461
 
1382
2462
  if (format === "json") {
2463
+ const encodedRes = encodeStoredJsonArrayResult(stream, batch.records);
2464
+ if (Result.isError(encodedRes)) {
2465
+ if (encodedRes.error.status === 500) return internalError();
2466
+ return badRequest(encodedRes.error.message);
2467
+ }
2468
+ if (encodedRes.value) {
2469
+ metrics.recordRead(encodedRes.value.byteLength, batch.records.length);
2470
+ const headers: Record<string, string> = {
2471
+ "content-type": "application/json",
2472
+ ...baseHeaders,
2473
+ };
2474
+ return new Response(bodyBufferFromBytes(encodedRes.value), { status: 200, headers: withNosniff(headers) });
2475
+ }
2476
+
1383
2477
  const decoded = decodeJsonRecords(stream, batch.records);
1384
2478
  if (Result.isError(decoded)) {
1385
2479
  if (decoded.error.status === 500) return internalError();
@@ -1400,9 +2494,7 @@ export function createAppCore(cfg: Config, opts: CreateAppCoreOptions): App {
1400
2494
  "content-type": streamContentType,
1401
2495
  ...baseHeaders,
1402
2496
  };
1403
- const outBody = new Uint8Array(outBytes.byteLength);
1404
- outBody.set(outBytes);
1405
- return new Response(outBody, { status: 200, headers: withNosniff(headers) });
2497
+ return new Response(bodyBufferFromBytes(outBytes), { status: 200, headers: withNosniff(headers) });
1406
2498
  };
1407
2499
 
1408
2500
  if (mode === "sse") {
@@ -1441,7 +2533,9 @@ export function createAppCore(cfg: Config, opts: CreateAppCoreOptions): App {
1441
2533
  records: [],
1442
2534
  };
1443
2535
  } else {
1444
- const batchRes = await reader.readResult({ stream, offset: currentOffset, key: key ?? null, format });
2536
+ const batchRes = await runForegroundWithGate(readGate, () =>
2537
+ reader.readResult({ stream, offset: currentOffset, key: key ?? null, format, filter })
2538
+ );
1445
2539
  if (Result.isError(batchRes)) {
1446
2540
  fail(batchRes.error.message);
1447
2541
  return;
@@ -1455,12 +2549,21 @@ export function createAppCore(cfg: Config, opts: CreateAppCoreOptions): App {
1455
2549
  if (batch.records.length > 0) {
1456
2550
  let dataPayload = "";
1457
2551
  if (format === "json") {
1458
- const decoded = decodeJsonRecords(stream, batch.records);
1459
- if (Result.isError(decoded)) {
1460
- fail(decoded.error.message);
2552
+ const encodedRes = encodeStoredJsonArrayResult(stream, batch.records);
2553
+ if (Result.isError(encodedRes)) {
2554
+ fail(encodedRes.error.message);
1461
2555
  return;
1462
2556
  }
1463
- dataPayload = JSON.stringify(decoded.value.values);
2557
+ if (encodedRes.value) {
2558
+ dataPayload = new TextDecoder().decode(encodedRes.value);
2559
+ } else {
2560
+ const decoded = decodeJsonRecords(stream, batch.records);
2561
+ if (Result.isError(decoded)) {
2562
+ fail(decoded.error.message);
2563
+ return;
2564
+ }
2565
+ dataPayload = JSON.stringify(decoded.value.values);
2566
+ }
1464
2567
  } else {
1465
2568
  const outBytes = concatPayloads(batch.records.map((r) => r.payload));
1466
2569
  dataPayload =
@@ -1546,10 +2649,12 @@ export function createAppCore(cfg: Config, opts: CreateAppCoreOptions): App {
1546
2649
  const deadline = Date.now() + (timeoutMs ?? defaultLongPollTimeoutMs);
1547
2650
  let currentOffset = tailOffset;
1548
2651
  while (true) {
1549
- const batchRes = await reader.readResult({ stream, offset: currentOffset, key: key ?? null, format });
2652
+ const batchRes = await runForegroundWithGate(readGate, () =>
2653
+ reader.readResult({ stream, offset: currentOffset, key: key ?? null, format, filter })
2654
+ );
1550
2655
  if (Result.isError(batchRes)) return readerErrorResponse(batchRes.error);
1551
2656
  const batch = batchRes.value;
1552
- if (batch.records.length > 0) {
2657
+ if (batch.records.length > 0 || batch.filterScanLimitReached) {
1553
2658
  const cursor = computeCursor(Date.now(), cursorParam);
1554
2659
  const resp = await sendBatch(batch, "no-store", false);
1555
2660
  const headers = new Headers(resp.headers);
@@ -1605,10 +2710,12 @@ export function createAppCore(cfg: Config, opts: CreateAppCoreOptions): App {
1605
2710
  const deadline = Date.now() + (timeoutMs ?? defaultLongPollTimeoutMs);
1606
2711
  let currentOffset = offset;
1607
2712
  while (true) {
1608
- const batchRes = await reader.readResult({ stream, offset: currentOffset, key: key ?? null, format });
2713
+ const batchRes = await runForegroundWithGate(readGate, () =>
2714
+ reader.readResult({ stream, offset: currentOffset, key: key ?? null, format, filter })
2715
+ );
1609
2716
  if (Result.isError(batchRes)) return readerErrorResponse(batchRes.error);
1610
2717
  const batch = batchRes.value;
1611
- if (batch.records.length > 0) {
2718
+ if (batch.records.length > 0 || batch.filterScanLimitReached) {
1612
2719
  const cursor = computeCursor(Date.now(), cursorParam);
1613
2720
  const resp = await sendBatch(batch, "no-store", false);
1614
2721
  const headers = new Headers(resp.headers);
@@ -1648,7 +2755,9 @@ export function createAppCore(cfg: Config, opts: CreateAppCoreOptions): App {
1648
2755
  return new Response(null, { status: 204, headers: withNosniff(headers) });
1649
2756
  }
1650
2757
 
1651
- const batchRes = await reader.readResult({ stream, offset, key: key ?? null, format });
2758
+ const batchRes = await runForegroundWithGate(readGate, () =>
2759
+ reader.readResult({ stream, offset, key: key ?? null, format, filter })
2760
+ );
1652
2761
  if (Result.isError(batchRes)) return readerErrorResponse(batchRes.error);
1653
2762
  const batch = batchRes.value;
1654
2763
  const cacheControl = "immutable, max-age=31536000";
@@ -1659,13 +2768,29 @@ export function createAppCore(cfg: Config, opts: CreateAppCoreOptions): App {
1659
2768
  }
1660
2769
 
1661
2770
  return notFound();
2771
+ })();
2772
+ const resolved = await awaitWithCooperativeTimeout(requestPromise, HTTP_RESOLVER_TIMEOUT_MS);
2773
+ if (resolved === TIMEOUT_SENTINEL) {
2774
+ timedOut = true;
2775
+ requestAbortController.abort(new Error("request timed out"));
2776
+ void requestPromise.catch(() => {});
2777
+ await cancelRequestBody(req);
2778
+ return requestTimeout();
2779
+ }
2780
+ return resolved;
1662
2781
  } catch (e: any) {
2782
+ if (isAbortLikeError(e)) {
2783
+ if (timedOut) return requestTimeout();
2784
+ return new Response(null, { status: 204 });
2785
+ }
1663
2786
  const msg = String(e?.message ?? e);
1664
2787
  if (!closing && !msg.includes("Statement has finalized")) {
1665
2788
  // eslint-disable-next-line no-console
1666
2789
  console.error("request failed", e);
1667
2790
  }
1668
2791
  return internalError();
2792
+ } finally {
2793
+ req.signal.removeEventListener("abort", abortFromClient);
1669
2794
  }
1670
2795
  };
1671
2796
 
@@ -1677,7 +2802,9 @@ export function createAppCore(cfg: Config, opts: CreateAppCoreOptions): App {
1677
2802
  segmenter.stop(true);
1678
2803
  metricsEmitter.stop();
1679
2804
  expirySweeper.stop();
2805
+ streamSizeReconciler.stop();
1680
2806
  ingest.stop();
2807
+ memorySampler?.stop();
1681
2808
  memory.stop();
1682
2809
  db.close();
1683
2810
  };
@@ -1697,10 +2824,18 @@ export function createAppCore(cfg: Config, opts: CreateAppCoreOptions): App {
1697
2824
  indexer,
1698
2825
  metrics,
1699
2826
  registry,
2827
+ profiles,
1700
2828
  touch,
1701
2829
  stats,
1702
2830
  backpressure,
1703
2831
  memory,
2832
+ concurrency: {
2833
+ ingest: ingestGate,
2834
+ read: readGate,
2835
+ search: searchGate,
2836
+ asyncIndex: asyncIndexGate,
2837
+ },
2838
+ memorySampler,
1704
2839
  },
1705
2840
  };
1706
2841
  }