@tungthedev/streams-server 0.2.0

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 (183) hide show
  1. package/CODE_OF_CONDUCT.md +45 -0
  2. package/CONTRIBUTING.md +76 -0
  3. package/LICENSE +201 -0
  4. package/README.md +58 -0
  5. package/SECURITY.md +42 -0
  6. package/bin/prisma-streams-server +2 -0
  7. package/package.json +46 -0
  8. package/src/app.ts +583 -0
  9. package/src/app_core.ts +3144 -0
  10. package/src/app_local.ts +206 -0
  11. package/src/auth.ts +124 -0
  12. package/src/auto_tune.ts +69 -0
  13. package/src/backpressure.ts +66 -0
  14. package/src/bootstrap.ts +613 -0
  15. package/src/compute/demo_entry.ts +415 -0
  16. package/src/compute/demo_site.ts +1242 -0
  17. package/src/compute/entry.ts +19 -0
  18. package/src/compute/package_entry.ts +4 -0
  19. package/src/compute/virtual-modules.d.ts +15 -0
  20. package/src/compute/worker_module_url.ts +9 -0
  21. package/src/concurrency_gate.ts +108 -0
  22. package/src/config.ts +402 -0
  23. package/src/db/bootstrap_store.ts +9 -0
  24. package/src/db/db.ts +2424 -0
  25. package/src/db/schema.ts +925 -0
  26. package/src/db/sqlite_manifest_snapshot.ts +81 -0
  27. package/src/db/sqlite_touch_store.ts +491 -0
  28. package/src/db/sqlite_wal_store.ts +472 -0
  29. package/src/details/full_mode_details.ts +568 -0
  30. package/src/expiry_sweeper.ts +47 -0
  31. package/src/foreground_activity.ts +55 -0
  32. package/src/hist.ts +169 -0
  33. package/src/index/binary_fuse.ts +379 -0
  34. package/src/index/indexer.ts +947 -0
  35. package/src/index/lexicon_file_cache.ts +261 -0
  36. package/src/index/lexicon_format.ts +93 -0
  37. package/src/index/lexicon_indexer.ts +863 -0
  38. package/src/index/run_cache.ts +84 -0
  39. package/src/index/run_format.ts +213 -0
  40. package/src/index/schedule.ts +28 -0
  41. package/src/index/secondary_indexer.ts +901 -0
  42. package/src/index/secondary_schema.ts +105 -0
  43. package/src/ingest.ts +309 -0
  44. package/src/lens/lens.ts +501 -0
  45. package/src/manifest.ts +249 -0
  46. package/src/memory.ts +334 -0
  47. package/src/metrics.ts +147 -0
  48. package/src/metrics_emitter.ts +83 -0
  49. package/src/notifier.ts +180 -0
  50. package/src/objectstore/accounting.ts +151 -0
  51. package/src/objectstore/interface.ts +13 -0
  52. package/src/objectstore/mock_r2.ts +269 -0
  53. package/src/objectstore/null.ts +32 -0
  54. package/src/objectstore/r2.ts +318 -0
  55. package/src/observe/pairing.ts +61 -0
  56. package/src/observe/request.ts +772 -0
  57. package/src/offset.ts +70 -0
  58. package/src/postgres/bootstrap.ts +269 -0
  59. package/src/postgres/companions.ts +197 -0
  60. package/src/postgres/control_restore.ts +109 -0
  61. package/src/postgres/details.ts +189 -0
  62. package/src/postgres/lexicon_index.ts +260 -0
  63. package/src/postgres/routing_index.ts +189 -0
  64. package/src/postgres/rows.ts +132 -0
  65. package/src/postgres/schema.ts +355 -0
  66. package/src/postgres/secondary_index.ts +238 -0
  67. package/src/postgres/segments.ts +900 -0
  68. package/src/postgres/stats.ts +103 -0
  69. package/src/postgres/store.ts +947 -0
  70. package/src/postgres/touch.ts +591 -0
  71. package/src/postgres/types.ts +32 -0
  72. package/src/profiles/evlog/schema.ts +234 -0
  73. package/src/profiles/evlog.ts +473 -0
  74. package/src/profiles/generic.ts +51 -0
  75. package/src/profiles/index.ts +237 -0
  76. package/src/profiles/metrics/block_format.ts +109 -0
  77. package/src/profiles/metrics/normalize.ts +366 -0
  78. package/src/profiles/metrics/schema.ts +319 -0
  79. package/src/profiles/metrics.ts +83 -0
  80. package/src/profiles/otelTraces/normalize.ts +955 -0
  81. package/src/profiles/otelTraces/otlp.ts +1002 -0
  82. package/src/profiles/otelTraces/schema.ts +408 -0
  83. package/src/profiles/otelTraces.ts +390 -0
  84. package/src/profiles/profile.ts +284 -0
  85. package/src/profiles/stateProtocol/change_event_conformance.typecheck.ts +35 -0
  86. package/src/profiles/stateProtocol/changes.ts +24 -0
  87. package/src/profiles/stateProtocol/ingest.ts +115 -0
  88. package/src/profiles/stateProtocol/routes.ts +511 -0
  89. package/src/profiles/stateProtocol/types.ts +6 -0
  90. package/src/profiles/stateProtocol/validation.ts +51 -0
  91. package/src/profiles/stateProtocol.ts +107 -0
  92. package/src/read_filter.ts +468 -0
  93. package/src/reader.ts +2986 -0
  94. package/src/runtime/hash.ts +156 -0
  95. package/src/runtime/hash_vendor/LICENSE.hash-wasm +38 -0
  96. package/src/runtime/hash_vendor/NOTICE.md +8 -0
  97. package/src/runtime/hash_vendor/xxhash3.umd.min.cjs +7 -0
  98. package/src/runtime/hash_vendor/xxhash32.umd.min.cjs +7 -0
  99. package/src/runtime/hash_vendor/xxhash64.umd.min.cjs +7 -0
  100. package/src/runtime/host_runtime.ts +5 -0
  101. package/src/runtime_memory.ts +200 -0
  102. package/src/runtime_memory_sampler.ts +237 -0
  103. package/src/schema/lens_schema.ts +290 -0
  104. package/src/schema/proof.ts +547 -0
  105. package/src/schema/read_json.ts +51 -0
  106. package/src/schema/registry.ts +966 -0
  107. package/src/search/agg_format.ts +638 -0
  108. package/src/search/aggregate.ts +409 -0
  109. package/src/search/binary/codec.ts +162 -0
  110. package/src/search/binary/docset.ts +67 -0
  111. package/src/search/binary/restart_strings.ts +181 -0
  112. package/src/search/binary/varint.ts +34 -0
  113. package/src/search/bitset.ts +19 -0
  114. package/src/search/col_format.ts +382 -0
  115. package/src/search/col_runtime.ts +59 -0
  116. package/src/search/column_encoding.ts +43 -0
  117. package/src/search/companion_file_cache.ts +319 -0
  118. package/src/search/companion_format.ts +327 -0
  119. package/src/search/companion_manager.ts +1305 -0
  120. package/src/search/companion_plan.ts +229 -0
  121. package/src/search/exact_format.ts +281 -0
  122. package/src/search/exact_runtime.ts +55 -0
  123. package/src/search/fts_format.ts +423 -0
  124. package/src/search/fts_runtime.ts +333 -0
  125. package/src/search/query.ts +875 -0
  126. package/src/search/schema.ts +245 -0
  127. package/src/segment/cache.ts +270 -0
  128. package/src/segment/cached_segment.ts +89 -0
  129. package/src/segment/format.ts +403 -0
  130. package/src/segment/segmenter.ts +412 -0
  131. package/src/segment/segmenter_worker.ts +72 -0
  132. package/src/segment/segmenter_workers.ts +130 -0
  133. package/src/server.ts +264 -0
  134. package/src/server_auto_tune.ts +158 -0
  135. package/src/sqlite/adapter.ts +335 -0
  136. package/src/sqlite/runtime_stats.ts +163 -0
  137. package/src/stats.ts +205 -0
  138. package/src/store/append.ts +50 -0
  139. package/src/store/bootstrap_restore_store.ts +71 -0
  140. package/src/store/capabilities.ts +86 -0
  141. package/src/store/full_mode_details_store.ts +71 -0
  142. package/src/store/index_store.ts +104 -0
  143. package/src/store/profile_touch_store.ts +1 -0
  144. package/src/store/rows.ts +144 -0
  145. package/src/store/schema_profile_store.ts +73 -0
  146. package/src/store/schema_publication.ts +6 -0
  147. package/src/store/segment_manifest_store.ts +129 -0
  148. package/src/store/segment_read_store.ts +22 -0
  149. package/src/store/stats_accounting_store.ts +83 -0
  150. package/src/store/touch_store.ts +98 -0
  151. package/src/store/wal_store.ts +21 -0
  152. package/src/stream_size_reconciler.ts +100 -0
  153. package/src/touch/canonical_change.ts +7 -0
  154. package/src/touch/live_keys.ts +158 -0
  155. package/src/touch/live_metrics.ts +841 -0
  156. package/src/touch/live_templates.ts +449 -0
  157. package/src/touch/manager.ts +1292 -0
  158. package/src/touch/process_batch.ts +576 -0
  159. package/src/touch/processor_worker.ts +85 -0
  160. package/src/touch/spec.ts +459 -0
  161. package/src/touch/touch_journal.ts +771 -0
  162. package/src/touch/touch_key_id.ts +20 -0
  163. package/src/touch/worker_pool.ts +191 -0
  164. package/src/touch/worker_protocol.ts +57 -0
  165. package/src/types/proper-lockfile.d.ts +1 -0
  166. package/src/uploader.ts +358 -0
  167. package/src/util/base32_crockford.ts +81 -0
  168. package/src/util/bloom256.ts +67 -0
  169. package/src/util/byte_lru.ts +73 -0
  170. package/src/util/cleanup.ts +22 -0
  171. package/src/util/crc32c.ts +29 -0
  172. package/src/util/ds_error.ts +15 -0
  173. package/src/util/duration.ts +17 -0
  174. package/src/util/endian.ts +53 -0
  175. package/src/util/json_pointer.ts +148 -0
  176. package/src/util/log.ts +25 -0
  177. package/src/util/lru.ts +53 -0
  178. package/src/util/retry.ts +35 -0
  179. package/src/util/siphash.ts +71 -0
  180. package/src/util/stream_paths.ts +50 -0
  181. package/src/util/time.ts +14 -0
  182. package/src/util/yield.ts +3 -0
  183. package/src/util/zstd.ts +24 -0
package/src/metrics.ts ADDED
@@ -0,0 +1,147 @@
1
+ import { buildInternalMetricsRecord } from "./profiles/metrics/normalize";
2
+
3
+ type Tags = Record<string, string>;
4
+
5
+ class Histogram {
6
+ private readonly maxSamples: number;
7
+ private samples: number[] = [];
8
+ count = 0;
9
+ sum = 0;
10
+ min = Number.POSITIVE_INFINITY;
11
+ max = Number.NEGATIVE_INFINITY;
12
+ buckets: Record<string, number> = {};
13
+
14
+ constructor(maxSamples = 1024) {
15
+ this.maxSamples = maxSamples;
16
+ }
17
+
18
+ add(value: number): void {
19
+ this.count++;
20
+ this.sum += value;
21
+ if (value < this.min) this.min = value;
22
+ if (value > this.max) this.max = value;
23
+ const bucket = Math.floor(Math.log2(Math.max(1, value)));
24
+ const key = String(2 ** bucket);
25
+ this.buckets[key] = (this.buckets[key] ?? 0) + 1;
26
+ if (this.samples.length < this.maxSamples) {
27
+ this.samples.push(value);
28
+ } else {
29
+ const idx = Math.floor(Math.random() * this.count);
30
+ if (idx < this.maxSamples) this.samples[idx] = value;
31
+ }
32
+ }
33
+
34
+ snapshotAndReset(): { count: number; sum: number; min: number; max: number; avg: number; p50: number; p95: number; p99: number; buckets: Record<string, number> } {
35
+ const count = this.count;
36
+ const sum = this.sum;
37
+ const min = count === 0 ? 0 : this.min;
38
+ const max = count === 0 ? 0 : this.max;
39
+ const avg = count === 0 ? 0 : sum / count;
40
+ const sorted = this.samples.slice().sort((a, b) => a - b);
41
+ const p = (q: number) => (sorted.length === 0 ? 0 : sorted[Math.min(sorted.length - 1, Math.floor(q * (sorted.length - 1)))]);
42
+ const p50 = p(0.5);
43
+ const p95 = p(0.95);
44
+ const p99 = p(0.99);
45
+ const buckets = { ...this.buckets };
46
+ this.samples = [];
47
+ this.count = 0;
48
+ this.sum = 0;
49
+ this.min = Number.POSITIVE_INFINITY;
50
+ this.max = Number.NEGATIVE_INFINITY;
51
+ this.buckets = {};
52
+ return { count, sum, min, max, avg, p50, p95, p99, buckets };
53
+ }
54
+ }
55
+
56
+ type SeriesKey = string;
57
+
58
+ export type MetricsMemoryStats = {
59
+ seriesCount: number;
60
+ };
61
+
62
+ class MetricSeries {
63
+ readonly metric: string;
64
+ readonly unit: "ns" | "bytes" | "count";
65
+ readonly stream?: string;
66
+ readonly tags?: Tags;
67
+ readonly hist = new Histogram();
68
+
69
+ constructor(metric: string, unit: "ns" | "bytes" | "count", stream?: string, tags?: Tags) {
70
+ this.metric = metric;
71
+ this.unit = unit;
72
+ this.stream = stream;
73
+ this.tags = tags;
74
+ }
75
+ }
76
+
77
+ function keyFor(metric: string, unit: string, stream?: string, tags?: Tags): SeriesKey {
78
+ const tagStr = tags ? JSON.stringify(tags) : "";
79
+ return `${metric}|${unit}|${stream ?? ""}|${tagStr}`;
80
+ }
81
+
82
+ function instanceId(): string {
83
+ const host = typeof process !== "undefined" ? process.pid.toString() : "node";
84
+ const rand = Math.random().toString(16).slice(2, 8);
85
+ return `${host}-${rand}`;
86
+ }
87
+
88
+ export class Metrics {
89
+ private readonly startMs = Date.now();
90
+ private windowStartMs = Date.now();
91
+ private readonly series = new Map<SeriesKey, MetricSeries>();
92
+ private readonly instance = instanceId();
93
+
94
+ record(metric: string, value: number, unit: "ns" | "bytes" | "count", tags?: Tags, stream?: string): void {
95
+ const key = keyFor(metric, unit, stream, tags);
96
+ let s = this.series.get(key);
97
+ if (!s) {
98
+ s = new MetricSeries(metric, unit, stream, tags);
99
+ this.series.set(key, s);
100
+ }
101
+ s.hist.add(value);
102
+ }
103
+
104
+ recordAppend(bytes: number, entries: number): void {
105
+ this.record("tieredstore.append.bytes", bytes, "bytes");
106
+ this.record("tieredstore.append.entries", entries, "count");
107
+ }
108
+
109
+ recordRead(bytes: number, entries: number): void {
110
+ this.record("tieredstore.read.bytes", bytes, "bytes");
111
+ this.record("tieredstore.read.entries", entries, "count");
112
+ }
113
+
114
+ snapshot(): any {
115
+ return {
116
+ uptime_ms: Date.now() - this.startMs,
117
+ series: this.series.size,
118
+ };
119
+ }
120
+
121
+ getMemoryStats(): MetricsMemoryStats {
122
+ return { seriesCount: this.series.size };
123
+ }
124
+
125
+ flushInterval(): Record<string, unknown>[] {
126
+ const windowEnd = Date.now();
127
+ const intervalMs = windowEnd - this.windowStartMs;
128
+ const events: Record<string, unknown>[] = [];
129
+ for (const s of this.series.values()) {
130
+ const snap = s.hist.snapshotAndReset();
131
+ if (snap.count === 0) continue;
132
+ events.push(buildInternalMetricsRecord({
133
+ metric: s.metric,
134
+ unit: s.unit,
135
+ windowStart: this.windowStartMs,
136
+ windowEnd,
137
+ intervalMs,
138
+ instance: this.instance,
139
+ stream: s.stream,
140
+ tags: s.tags,
141
+ ...snap,
142
+ }));
143
+ }
144
+ this.windowStartMs = windowEnd;
145
+ return events;
146
+ }
147
+ }
@@ -0,0 +1,83 @@
1
+ import { Result } from "better-result";
2
+ import type { IngestQueue } from "./ingest";
3
+ import type { Metrics } from "./metrics";
4
+
5
+ export class MetricsEmitter {
6
+ private readonly metrics: Metrics;
7
+ private readonly ingest: IngestQueue;
8
+ private readonly intervalMs: number;
9
+ private readonly onAppended?: (args: {
10
+ lastOffset: bigint;
11
+ stream: string;
12
+ }) => void;
13
+ private readonly collectRuntimeMetrics?: () => void;
14
+ private timer: any | null = null;
15
+ private flushPromise: Promise<void> | null = null;
16
+
17
+ constructor(
18
+ metrics: Metrics,
19
+ ingest: IngestQueue,
20
+ intervalMs: number,
21
+ opts?: {
22
+ onAppended?: (args: { lastOffset: bigint; stream: string }) => void;
23
+ collectRuntimeMetrics?: () => void;
24
+ },
25
+ ) {
26
+ this.metrics = metrics;
27
+ this.ingest = ingest;
28
+ this.intervalMs = intervalMs;
29
+ this.onAppended = opts?.onAppended;
30
+ this.collectRuntimeMetrics = opts?.collectRuntimeMetrics;
31
+ }
32
+
33
+ start(): void {
34
+ if (this.intervalMs <= 0 || this.timer) return;
35
+ this.timer = setInterval(() => {
36
+ void this.flush();
37
+ }, this.intervalMs);
38
+ }
39
+
40
+ async stop(): Promise<void> {
41
+ if (this.timer) clearInterval(this.timer);
42
+ this.timer = null;
43
+ await this.flushPromise;
44
+ }
45
+
46
+ private async flush(): Promise<void> {
47
+ if (this.flushPromise) return this.flushPromise;
48
+ this.flushPromise = this.runFlush().finally(() => {
49
+ this.flushPromise = null;
50
+ });
51
+ return this.flushPromise;
52
+ }
53
+
54
+ private async runFlush(): Promise<void> {
55
+ const queue = this.ingest.getQueueStats();
56
+ this.metrics.record("tieredstore.ingest.queue.bytes", queue.bytes, "bytes");
57
+ this.metrics.record("tieredstore.ingest.queue.requests", queue.requests, "count");
58
+ this.collectRuntimeMetrics?.();
59
+ const events = this.metrics.flushInterval();
60
+ if (events.length === 0) return;
61
+ const rows = events.map((e) => ({
62
+ routingKey: typeof e.seriesKey === "string" ? new TextEncoder().encode(e.seriesKey) : null,
63
+ contentType: "application/json",
64
+ payload: new TextEncoder().encode(JSON.stringify(e)),
65
+ }));
66
+ try {
67
+ const appendRes = await this.ingest.appendInternal({
68
+ stream: "__stream_metrics__",
69
+ baseAppendMs: BigInt(Date.now()),
70
+ rows,
71
+ contentType: "application/json",
72
+ });
73
+ if (!Result.isError(appendRes)) {
74
+ this.onAppended?.({
75
+ lastOffset: appendRes.value.lastOffset,
76
+ stream: "__stream_metrics__",
77
+ });
78
+ }
79
+ } catch {
80
+ // best-effort; drop on failure
81
+ }
82
+ }
83
+ }
@@ -0,0 +1,180 @@
1
+ type Waiter = { afterSeq: bigint; resolve: () => void };
2
+ type DetailsWaiter = { afterVersion: bigint; resolve: () => void };
3
+
4
+ export type StreamNotifierMemoryStats = {
5
+ waiterStreams: number;
6
+ waiters: number;
7
+ latestSeqStreams: number;
8
+ detailsWaiterStreams: number;
9
+ detailsWaiters: number;
10
+ detailsVersionStreams: number;
11
+ };
12
+
13
+ export type StreamNotifierTopStreamEntry = {
14
+ stream: string;
15
+ waiters: number;
16
+ details_waiters: number;
17
+ total_waiters: number;
18
+ };
19
+
20
+ export class StreamNotifier {
21
+ private readonly waiters = new Map<string, Set<Waiter>>();
22
+ private readonly latestSeq = new Map<string, bigint>();
23
+ private readonly detailsWaiters = new Map<string, Set<DetailsWaiter>>();
24
+ private readonly detailsVersion = new Map<string, bigint>();
25
+
26
+ notify(stream: string, newEndSeq: bigint): void {
27
+ this.latestSeq.set(stream, newEndSeq);
28
+ const set = this.waiters.get(stream);
29
+ if (!set || set.size === 0) return;
30
+ for (const w of Array.from(set)) {
31
+ if (newEndSeq > w.afterSeq) {
32
+ set.delete(w);
33
+ w.resolve();
34
+ }
35
+ }
36
+ if (set.size === 0) this.waiters.delete(stream);
37
+ }
38
+
39
+ waitFor(stream: string, afterSeq: bigint, timeoutMs: number, signal?: AbortSignal): Promise<void> {
40
+ if (signal?.aborted) return Promise.resolve();
41
+ const latest = this.latestSeq.get(stream);
42
+ if (latest != null && latest > afterSeq) return Promise.resolve();
43
+ return new Promise((resolve) => {
44
+ let done = false;
45
+ const set = this.waiters.get(stream) ?? new Set();
46
+ const cleanup = () => {
47
+ if (done) return;
48
+ done = true;
49
+ const s = this.waiters.get(stream);
50
+ if (s) {
51
+ s.delete(waiter);
52
+ if (s.size === 0) this.waiters.delete(stream);
53
+ }
54
+ if (timeoutId) clearTimeout(timeoutId);
55
+ if (signal) signal.removeEventListener("abort", onAbort);
56
+ resolve();
57
+ };
58
+ const waiter: Waiter = { afterSeq, resolve: cleanup };
59
+ set.add(waiter);
60
+ this.waiters.set(stream, set);
61
+
62
+ const onAbort = () => cleanup();
63
+ if (signal) signal.addEventListener("abort", onAbort, { once: true });
64
+
65
+ let timeoutId: any | null = null;
66
+ if (timeoutMs > 0) {
67
+ timeoutId = setTimeout(() => {
68
+ cleanup();
69
+ }, timeoutMs);
70
+ }
71
+ });
72
+ }
73
+
74
+ currentDetailsVersion(stream: string): bigint {
75
+ return this.detailsVersion.get(stream) ?? 0n;
76
+ }
77
+
78
+ notifyDetailsChanged(stream: string): void {
79
+ const nextVersion = (this.detailsVersion.get(stream) ?? 0n) + 1n;
80
+ this.detailsVersion.set(stream, nextVersion);
81
+ const set = this.detailsWaiters.get(stream);
82
+ if (!set || set.size === 0) return;
83
+ for (const w of Array.from(set)) {
84
+ if (nextVersion > w.afterVersion) {
85
+ set.delete(w);
86
+ w.resolve();
87
+ }
88
+ }
89
+ if (set.size === 0) this.detailsWaiters.delete(stream);
90
+ }
91
+
92
+ waitForDetailsChange(stream: string, afterVersion: bigint, timeoutMs: number, signal?: AbortSignal): Promise<void> {
93
+ if (signal?.aborted) return Promise.resolve();
94
+ const latest = this.detailsVersion.get(stream);
95
+ if (latest != null && latest > afterVersion) return Promise.resolve();
96
+ return new Promise((resolve) => {
97
+ let done = false;
98
+ const set = this.detailsWaiters.get(stream) ?? new Set();
99
+ const cleanup = () => {
100
+ if (done) return;
101
+ done = true;
102
+ const s = this.detailsWaiters.get(stream);
103
+ if (s) {
104
+ s.delete(waiter);
105
+ if (s.size === 0) this.detailsWaiters.delete(stream);
106
+ }
107
+ if (timeoutId) clearTimeout(timeoutId);
108
+ if (signal) signal.removeEventListener("abort", onAbort);
109
+ resolve();
110
+ };
111
+ const waiter: DetailsWaiter = { afterVersion, resolve: cleanup };
112
+ set.add(waiter);
113
+ this.detailsWaiters.set(stream, set);
114
+
115
+ const onAbort = () => cleanup();
116
+ if (signal) signal.addEventListener("abort", onAbort, { once: true });
117
+
118
+ let timeoutId: any | null = null;
119
+ if (timeoutMs > 0) {
120
+ timeoutId = setTimeout(() => {
121
+ cleanup();
122
+ }, timeoutMs);
123
+ }
124
+ });
125
+ }
126
+
127
+ notifyClose(stream: string): void {
128
+ const set = this.waiters.get(stream);
129
+ if (set && set.size > 0) {
130
+ for (const w of Array.from(set)) {
131
+ set.delete(w);
132
+ w.resolve();
133
+ }
134
+ if (set.size === 0) this.waiters.delete(stream);
135
+ }
136
+
137
+ const detailsSet = this.detailsWaiters.get(stream);
138
+ if (detailsSet && detailsSet.size > 0) {
139
+ for (const w of Array.from(detailsSet)) {
140
+ detailsSet.delete(w);
141
+ w.resolve();
142
+ }
143
+ if (detailsSet.size === 0) this.detailsWaiters.delete(stream);
144
+ }
145
+ }
146
+
147
+ getMemoryStats(): StreamNotifierMemoryStats {
148
+ let waiters = 0;
149
+ for (const set of this.waiters.values()) waiters += set.size;
150
+ let detailsWaiters = 0;
151
+ for (const set of this.detailsWaiters.values()) detailsWaiters += set.size;
152
+ return {
153
+ waiterStreams: this.waiters.size,
154
+ waiters,
155
+ latestSeqStreams: this.latestSeq.size,
156
+ detailsWaiterStreams: this.detailsWaiters.size,
157
+ detailsWaiters,
158
+ detailsVersionStreams: this.detailsVersion.size,
159
+ };
160
+ }
161
+
162
+ getTopStreams(limit = 5): StreamNotifierTopStreamEntry[] {
163
+ const totals = new Map<string, StreamNotifierTopStreamEntry>();
164
+ for (const [stream, waiters] of this.waiters) {
165
+ const row = totals.get(stream) ?? { stream, waiters: 0, details_waiters: 0, total_waiters: 0 };
166
+ row.waiters = waiters.size;
167
+ row.total_waiters = row.waiters + row.details_waiters;
168
+ totals.set(stream, row);
169
+ }
170
+ for (const [stream, detailsWaiters] of this.detailsWaiters) {
171
+ const row = totals.get(stream) ?? { stream, waiters: 0, details_waiters: 0, total_waiters: 0 };
172
+ row.details_waiters = detailsWaiters.size;
173
+ row.total_waiters = row.waiters + row.details_waiters;
174
+ totals.set(stream, row);
175
+ }
176
+ return Array.from(totals.values())
177
+ .sort((a, b) => b.total_waiters - a.total_waiters || a.stream.localeCompare(b.stream))
178
+ .slice(0, Math.max(0, Math.floor(limit)));
179
+ }
180
+ }
@@ -0,0 +1,151 @@
1
+ import type { Metrics } from "../metrics";
2
+ import type { ObjectStoreAccountingRecorder } from "../store/stats_accounting_store";
3
+ import type { GetOptions, ObjectStore, PutResult } from "./interface";
4
+
5
+ type ClassifiedRequest = {
6
+ streamHash: string;
7
+ artifact: string;
8
+ };
9
+
10
+ function classifyKey(key: string): ClassifiedRequest | null {
11
+ const match = /^streams\/([0-9a-f]{32})\/(.+)$/.exec(key);
12
+ if (!match) return null;
13
+ const [, streamHash, rest] = match;
14
+ if (rest === "manifest.json") return { streamHash, artifact: "manifest" };
15
+ if (rest === "schema-registry.json") return { streamHash, artifact: "schema_registry" };
16
+ if (rest.startsWith("index/")) return { streamHash, artifact: "routing_index" };
17
+ if (rest.startsWith("lexicon/")) return { streamHash, artifact: "routing_key_lexicon" };
18
+ if (rest.startsWith("secondary-index/")) return { streamHash, artifact: "exact_index" };
19
+ if (rest.startsWith("segments/") && rest.endsWith(".bin")) return { streamHash, artifact: "segment" };
20
+ if (rest.startsWith("segments/") && rest.endsWith(".cix")) return { streamHash, artifact: "bundled_companion" };
21
+ return { streamHash, artifact: "meta" };
22
+ }
23
+
24
+ function classifyListPrefix(prefix: string): ClassifiedRequest | null {
25
+ const exact = classifyKey(prefix.replace(/\/+$/, ""));
26
+ if (exact) return exact;
27
+ const match = /^streams\/([0-9a-f]{32})(?:\/(.+))?\/?$/.exec(prefix);
28
+ if (!match) return null;
29
+ const [, streamHash, rest = ""] = match;
30
+ if (rest === "" || rest === "segments") return { streamHash, artifact: "segment" };
31
+ if (rest === "index") return { streamHash, artifact: "routing_index" };
32
+ if (rest.startsWith("lexicon")) return { streamHash, artifact: "routing_key_lexicon" };
33
+ if (rest.startsWith("secondary-index")) return { streamHash, artifact: "exact_index" };
34
+ return { streamHash, artifact: "meta" };
35
+ }
36
+
37
+ export class AccountingObjectStore implements ObjectStore {
38
+ constructor(
39
+ private readonly inner: ObjectStore,
40
+ private readonly accounting: ObjectStoreAccountingRecorder,
41
+ private readonly metrics?: Metrics
42
+ ) {}
43
+
44
+ private recordLatency(op: "put" | "get" | "head" | "delete" | "list", artifact: string, startedNs: bigint, outcome: "ok" | "miss" | "error"): void {
45
+ if (!this.metrics) return;
46
+ const elapsedNs = Number(process.hrtime.bigint() - startedNs);
47
+ this.metrics.record(`tieredstore.objectstore.${op}.latency`, elapsedNs, "ns", {
48
+ artifact,
49
+ outcome,
50
+ });
51
+ }
52
+
53
+ async put(key: string, data: Uint8Array, opts?: { contentType?: string; contentLength?: number }): Promise<PutResult> {
54
+ const startedNs = process.hrtime.bigint();
55
+ const classified = classifyKey(key);
56
+ const artifact = classified?.artifact ?? "unknown";
57
+ try {
58
+ const res = await this.inner.put(key, data, opts);
59
+ if (classified) await this.accounting.recordObjectStoreRequestByHash(classified.streamHash, classified.artifact, "put", data.byteLength);
60
+ this.recordLatency("put", artifact, startedNs, "ok");
61
+ return res;
62
+ } catch (error) {
63
+ this.recordLatency("put", artifact, startedNs, "error");
64
+ throw error;
65
+ }
66
+ }
67
+
68
+ async putFile(key: string, path: string, size: number, opts?: { contentType?: string }): Promise<PutResult> {
69
+ const startedNs = process.hrtime.bigint();
70
+ const classified = classifyKey(key);
71
+ const artifact = classified?.artifact ?? "unknown";
72
+ try {
73
+ if (!this.inner.putFile) {
74
+ const bytes = await Bun.file(path).bytes();
75
+ const res = await this.inner.put(key, bytes, {
76
+ contentType: opts?.contentType,
77
+ contentLength: size,
78
+ });
79
+ if (classified) await this.accounting.recordObjectStoreRequestByHash(classified.streamHash, classified.artifact, "put", size);
80
+ this.recordLatency("put", artifact, startedNs, "ok");
81
+ return res;
82
+ }
83
+ const res = await this.inner.putFile(key, path, size, opts);
84
+ if (classified) await this.accounting.recordObjectStoreRequestByHash(classified.streamHash, classified.artifact, "put", size);
85
+ this.recordLatency("put", artifact, startedNs, "ok");
86
+ return res;
87
+ } catch (error) {
88
+ this.recordLatency("put", artifact, startedNs, "error");
89
+ throw error;
90
+ }
91
+ }
92
+
93
+ async get(key: string, opts?: GetOptions): Promise<Uint8Array | null> {
94
+ const startedNs = process.hrtime.bigint();
95
+ const classified = classifyKey(key);
96
+ const artifact = classified?.artifact ?? "unknown";
97
+ try {
98
+ const res = await this.inner.get(key, opts);
99
+ if (classified) await this.accounting.recordObjectStoreRequestByHash(classified.streamHash, classified.artifact, "get", res?.byteLength ?? 0);
100
+ this.recordLatency("get", artifact, startedNs, res == null ? "miss" : "ok");
101
+ return res;
102
+ } catch (error) {
103
+ this.recordLatency("get", artifact, startedNs, "error");
104
+ throw error;
105
+ }
106
+ }
107
+
108
+ async head(key: string): Promise<{ etag: string; size: number } | null> {
109
+ const startedNs = process.hrtime.bigint();
110
+ const classified = classifyKey(key);
111
+ const artifact = classified?.artifact ?? "unknown";
112
+ try {
113
+ const res = await this.inner.head(key);
114
+ if (classified) await this.accounting.recordObjectStoreRequestByHash(classified.streamHash, classified.artifact, "head", res?.size ?? 0);
115
+ this.recordLatency("head", artifact, startedNs, res == null ? "miss" : "ok");
116
+ return res;
117
+ } catch (error) {
118
+ this.recordLatency("head", artifact, startedNs, "error");
119
+ throw error;
120
+ }
121
+ }
122
+
123
+ async delete(key: string): Promise<void> {
124
+ const startedNs = process.hrtime.bigint();
125
+ const classified = classifyKey(key);
126
+ const artifact = classified?.artifact ?? "unknown";
127
+ try {
128
+ await this.inner.delete(key);
129
+ if (classified) await this.accounting.recordObjectStoreRequestByHash(classified.streamHash, classified.artifact, "delete", 0);
130
+ this.recordLatency("delete", artifact, startedNs, "ok");
131
+ } catch (error) {
132
+ this.recordLatency("delete", artifact, startedNs, "error");
133
+ throw error;
134
+ }
135
+ }
136
+
137
+ async list(prefix: string): Promise<string[]> {
138
+ const startedNs = process.hrtime.bigint();
139
+ const classified = classifyListPrefix(prefix);
140
+ const artifact = classified?.artifact ?? (prefix.replace(/\/+$/, "") === "streams" ? "stream_catalog" : "unknown");
141
+ try {
142
+ const res = await this.inner.list(prefix);
143
+ if (classified) await this.accounting.recordObjectStoreRequestByHash(classified.streamHash, classified.artifact, "list", 0);
144
+ this.recordLatency("list", artifact, startedNs, "ok");
145
+ return res;
146
+ } catch (error) {
147
+ this.recordLatency("list", artifact, startedNs, "error");
148
+ throw error;
149
+ }
150
+ }
151
+ }
@@ -0,0 +1,13 @@
1
+ export type PutResult = { etag: string };
2
+
3
+ export type GetRange = { start: number; end?: number }; // end is inclusive; omit for EOF
4
+ export type GetOptions = { range?: GetRange };
5
+
6
+ export interface ObjectStore {
7
+ put(key: string, data: Uint8Array, opts?: { contentType?: string; contentLength?: number }): Promise<PutResult>;
8
+ putFile?(key: string, path: string, size: number, opts?: { contentType?: string }): Promise<PutResult>;
9
+ get(key: string, opts?: GetOptions): Promise<Uint8Array | null>;
10
+ head(key: string): Promise<{ etag: string; size: number } | null>;
11
+ delete(key: string): Promise<void>;
12
+ list(prefix: string): Promise<string[]>;
13
+ }