@prisma/streams-server 0.0.1 → 0.1.1

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 (83) hide show
  1. package/CODE_OF_CONDUCT.md +45 -0
  2. package/CONTRIBUTING.md +68 -0
  3. package/LICENSE +201 -0
  4. package/README.md +39 -2
  5. package/SECURITY.md +33 -0
  6. package/bin/prisma-streams-server +2 -0
  7. package/package.json +29 -34
  8. package/src/app.ts +74 -0
  9. package/src/app_core.ts +1706 -0
  10. package/src/app_local.ts +46 -0
  11. package/src/backpressure.ts +66 -0
  12. package/src/bootstrap.ts +239 -0
  13. package/src/config.ts +251 -0
  14. package/src/db/db.ts +1386 -0
  15. package/src/db/schema.ts +625 -0
  16. package/src/expiry_sweeper.ts +44 -0
  17. package/src/hist.ts +169 -0
  18. package/src/index/binary_fuse.ts +379 -0
  19. package/src/index/indexer.ts +745 -0
  20. package/src/index/run_cache.ts +84 -0
  21. package/src/index/run_format.ts +213 -0
  22. package/src/ingest.ts +655 -0
  23. package/src/lens/lens.ts +501 -0
  24. package/src/manifest.ts +114 -0
  25. package/src/memory.ts +155 -0
  26. package/src/metrics.ts +161 -0
  27. package/src/metrics_emitter.ts +50 -0
  28. package/src/notifier.ts +64 -0
  29. package/src/objectstore/interface.ts +13 -0
  30. package/src/objectstore/mock_r2.ts +269 -0
  31. package/src/objectstore/null.ts +32 -0
  32. package/src/objectstore/r2.ts +128 -0
  33. package/src/offset.ts +70 -0
  34. package/src/reader.ts +454 -0
  35. package/src/runtime/hash.ts +156 -0
  36. package/src/runtime/hash_vendor/LICENSE.hash-wasm +38 -0
  37. package/src/runtime/hash_vendor/NOTICE.md +8 -0
  38. package/src/runtime/hash_vendor/xxhash3.umd.min.cjs +7 -0
  39. package/src/runtime/hash_vendor/xxhash32.umd.min.cjs +7 -0
  40. package/src/runtime/hash_vendor/xxhash64.umd.min.cjs +7 -0
  41. package/src/schema/lens_schema.ts +290 -0
  42. package/src/schema/proof.ts +547 -0
  43. package/src/schema/registry.ts +405 -0
  44. package/src/segment/cache.ts +179 -0
  45. package/src/segment/format.ts +331 -0
  46. package/src/segment/segmenter.ts +326 -0
  47. package/src/segment/segmenter_worker.ts +43 -0
  48. package/src/segment/segmenter_workers.ts +94 -0
  49. package/src/server.ts +326 -0
  50. package/src/sqlite/adapter.ts +164 -0
  51. package/src/stats.ts +205 -0
  52. package/src/touch/engine.ts +41 -0
  53. package/src/touch/interpreter_worker.ts +442 -0
  54. package/src/touch/live_keys.ts +118 -0
  55. package/src/touch/live_metrics.ts +827 -0
  56. package/src/touch/live_templates.ts +619 -0
  57. package/src/touch/manager.ts +1199 -0
  58. package/src/touch/spec.ts +456 -0
  59. package/src/touch/touch_journal.ts +671 -0
  60. package/src/touch/touch_key_id.ts +20 -0
  61. package/src/touch/worker_pool.ts +189 -0
  62. package/src/touch/worker_protocol.ts +56 -0
  63. package/src/types/proper-lockfile.d.ts +1 -0
  64. package/src/uploader.ts +317 -0
  65. package/src/util/base32_crockford.ts +81 -0
  66. package/src/util/bloom256.ts +67 -0
  67. package/src/util/cleanup.ts +22 -0
  68. package/src/util/crc32c.ts +29 -0
  69. package/src/util/ds_error.ts +15 -0
  70. package/src/util/duration.ts +17 -0
  71. package/src/util/endian.ts +53 -0
  72. package/src/util/json_pointer.ts +148 -0
  73. package/src/util/log.ts +25 -0
  74. package/src/util/lru.ts +45 -0
  75. package/src/util/retry.ts +35 -0
  76. package/src/util/siphash.ts +71 -0
  77. package/src/util/stream_paths.ts +31 -0
  78. package/src/util/time.ts +14 -0
  79. package/src/util/yield.ts +3 -0
  80. package/build/index.d.mts +0 -1
  81. package/build/index.d.ts +0 -1
  82. package/build/index.js +0 -0
  83. package/build/index.mjs +0 -1
package/src/memory.ts ADDED
@@ -0,0 +1,155 @@
1
+ export class MemoryGuard {
2
+ private readonly limitBytes: number;
3
+ private readonly resumeBytes: number;
4
+ private readonly intervalMs: number;
5
+ private readonly onSample?: (rssBytes: number, overLimit: boolean, limitBytes: number) => void;
6
+ private readonly heapSnapshotPath?: string;
7
+ private readonly heapSnapshotMinIntervalMs: number;
8
+ private timer: any | null = null;
9
+ private overLimit = false;
10
+ private maxRssBytes = 0;
11
+ private lastRssBytes = 0;
12
+ private lastGcMs = 0;
13
+ private lastSnapshotMs = 0;
14
+
15
+ constructor(
16
+ limitBytes: number,
17
+ opts: {
18
+ resumeFraction?: number;
19
+ intervalMs?: number;
20
+ onSample?: (rssBytes: number, overLimit: boolean, limitBytes: number) => void;
21
+ heapSnapshotPath?: string;
22
+ heapSnapshotMinIntervalMs?: number;
23
+ } = {}
24
+ ) {
25
+ this.limitBytes = Math.max(0, limitBytes);
26
+ // Resume as soon as RSS drops back below the limit by default (no hysteresis),
27
+ // so the server doesn't "deadlock" itself under a stable high-water mark.
28
+ const resumeFraction = Math.min(1.0, Math.max(0.5, opts.resumeFraction ?? 1.0));
29
+ this.resumeBytes = Math.floor(this.limitBytes * resumeFraction);
30
+ this.intervalMs = Math.max(50, opts.intervalMs ?? 1000);
31
+ this.onSample = opts.onSample;
32
+ this.heapSnapshotPath = opts.heapSnapshotPath;
33
+ this.heapSnapshotMinIntervalMs = Math.max(1000, opts.heapSnapshotMinIntervalMs ?? 60_000);
34
+ }
35
+
36
+ start(): void {
37
+ if (this.timer) return;
38
+ this.sample();
39
+ this.timer = setInterval(() => this.sample(), this.intervalMs);
40
+ }
41
+
42
+ stop(): void {
43
+ if (this.timer) clearInterval(this.timer);
44
+ this.timer = null;
45
+ }
46
+
47
+ private sample(): void {
48
+ const rss = process.memoryUsage().rss;
49
+ this.lastRssBytes = rss;
50
+ if (rss > this.maxRssBytes) this.maxRssBytes = rss;
51
+ if (this.onSample) {
52
+ const overLimit = this.limitBytes > 0 && rss > this.limitBytes;
53
+ try {
54
+ this.onSample(rss, overLimit, this.limitBytes);
55
+ } catch {
56
+ // ignore
57
+ }
58
+ }
59
+ if (this.limitBytes <= 0) return;
60
+ if (this.overLimit) {
61
+ if (rss <= this.resumeBytes) this.overLimit = false;
62
+ } else if (rss > this.limitBytes) {
63
+ this.overLimit = true;
64
+ }
65
+ }
66
+
67
+ shouldAllow(): boolean {
68
+ if (this.limitBytes <= 0) return true;
69
+ return !this.overLimit;
70
+ }
71
+
72
+ isOverLimit(): boolean {
73
+ return this.overLimit;
74
+ }
75
+
76
+ getMaxRssBytes(): number {
77
+ return this.maxRssBytes;
78
+ }
79
+
80
+ snapshotMaxRssBytes(reset = true): number {
81
+ const max = this.maxRssBytes;
82
+ if (reset) this.maxRssBytes = this.lastRssBytes;
83
+ return max;
84
+ }
85
+
86
+ getLastRssBytes(): number {
87
+ return this.lastRssBytes;
88
+ }
89
+
90
+ getLimitBytes(): number {
91
+ return this.limitBytes;
92
+ }
93
+
94
+ maybeGc(reason: string): void {
95
+ const gcFn = (globalThis as any)?.Bun?.gc;
96
+ if (typeof gcFn !== "function") return;
97
+ const now = Date.now();
98
+ if (now - this.lastGcMs < 10_000) return;
99
+ this.lastGcMs = now;
100
+ const before = process.memoryUsage().rss;
101
+ try {
102
+ gcFn(true);
103
+ } catch {
104
+ try {
105
+ gcFn();
106
+ } catch {
107
+ return;
108
+ }
109
+ }
110
+ const after = process.memoryUsage().rss;
111
+ // eslint-disable-next-line no-console
112
+ console.warn(`[gc] forced GC (${reason}) rss ${formatBytes(before)} -> ${formatBytes(after)}`);
113
+ }
114
+
115
+ maybeHeapSnapshot(reason: string): void {
116
+ if (!this.heapSnapshotPath) return;
117
+ const now = Date.now();
118
+ if (now - this.lastSnapshotMs < this.heapSnapshotMinIntervalMs) return;
119
+ this.lastSnapshotMs = now;
120
+ void this.writeHeapSnapshot(reason);
121
+ }
122
+
123
+ private async writeHeapSnapshot(reason: string): Promise<void> {
124
+ try {
125
+ const v8 = await import("v8");
126
+ if (typeof v8.writeHeapSnapshot !== "function") return;
127
+ const fs = await import("node:fs");
128
+ try {
129
+ fs.unlinkSync(this.heapSnapshotPath!);
130
+ } catch {
131
+ // ignore
132
+ }
133
+ const before = process.memoryUsage().rss;
134
+ v8.writeHeapSnapshot(this.heapSnapshotPath);
135
+ const after = process.memoryUsage().rss;
136
+ // eslint-disable-next-line no-console
137
+ console.warn(`[heap] snapshot (${reason}) rss ${formatBytes(before)} -> ${formatBytes(after)} path=${this.heapSnapshotPath}`);
138
+ } catch (err) {
139
+ // eslint-disable-next-line no-console
140
+ console.warn(`[heap] snapshot failed (${reason}): ${String(err)}`);
141
+ }
142
+ }
143
+ }
144
+
145
+ export function formatBytes(bytes: number): string {
146
+ const units = ["b", "kb", "mb", "gb"];
147
+ let value = bytes;
148
+ let idx = 0;
149
+ while (value >= 1024 && idx < units.length - 1) {
150
+ value /= 1024;
151
+ idx += 1;
152
+ }
153
+ const digits = idx === 0 ? 0 : 1;
154
+ return `${value.toFixed(digits)}${units[idx]}`;
155
+ }
package/src/metrics.ts ADDED
@@ -0,0 +1,161 @@
1
+ type Tags = Record<string, string>;
2
+
3
+ type MetricEvent = {
4
+ apiVersion: "durable.streams/metrics/v1";
5
+ kind: "interval";
6
+ metric: string;
7
+ unit: "ns" | "bytes" | "count";
8
+ windowStart: number;
9
+ windowEnd: number;
10
+ intervalMs: number;
11
+ instance: string;
12
+ stream?: string;
13
+ tags?: Tags;
14
+ count: number;
15
+ sum: number;
16
+ min: number;
17
+ max: number;
18
+ avg: number;
19
+ p50: number;
20
+ p95: number;
21
+ p99: number;
22
+ buckets: Record<string, number>;
23
+ };
24
+
25
+ class Histogram {
26
+ private readonly maxSamples: number;
27
+ private samples: number[] = [];
28
+ count = 0;
29
+ sum = 0;
30
+ min = Number.POSITIVE_INFINITY;
31
+ max = Number.NEGATIVE_INFINITY;
32
+ buckets: Record<string, number> = {};
33
+
34
+ constructor(maxSamples = 1024) {
35
+ this.maxSamples = maxSamples;
36
+ }
37
+
38
+ add(value: number): void {
39
+ this.count++;
40
+ this.sum += value;
41
+ if (value < this.min) this.min = value;
42
+ if (value > this.max) this.max = value;
43
+ const bucket = Math.floor(Math.log2(Math.max(1, value)));
44
+ const key = String(1 << bucket);
45
+ this.buckets[key] = (this.buckets[key] ?? 0) + 1;
46
+ if (this.samples.length < this.maxSamples) {
47
+ this.samples.push(value);
48
+ } else {
49
+ const idx = Math.floor(Math.random() * this.count);
50
+ if (idx < this.maxSamples) this.samples[idx] = value;
51
+ }
52
+ }
53
+
54
+ snapshotAndReset(): { count: number; sum: number; min: number; max: number; avg: number; p50: number; p95: number; p99: number; buckets: Record<string, number> } {
55
+ const count = this.count;
56
+ const sum = this.sum;
57
+ const min = count === 0 ? 0 : this.min;
58
+ const max = count === 0 ? 0 : this.max;
59
+ const avg = count === 0 ? 0 : sum / count;
60
+ const sorted = this.samples.slice().sort((a, b) => a - b);
61
+ const p = (q: number) => (sorted.length === 0 ? 0 : sorted[Math.min(sorted.length - 1, Math.floor(q * (sorted.length - 1)))]);
62
+ const p50 = p(0.5);
63
+ const p95 = p(0.95);
64
+ const p99 = p(0.99);
65
+ const buckets = { ...this.buckets };
66
+ this.samples = [];
67
+ this.count = 0;
68
+ this.sum = 0;
69
+ this.min = Number.POSITIVE_INFINITY;
70
+ this.max = Number.NEGATIVE_INFINITY;
71
+ this.buckets = {};
72
+ return { count, sum, min, max, avg, p50, p95, p99, buckets };
73
+ }
74
+ }
75
+
76
+ type SeriesKey = string;
77
+
78
+ class MetricSeries {
79
+ readonly metric: string;
80
+ readonly unit: "ns" | "bytes" | "count";
81
+ readonly stream?: string;
82
+ readonly tags?: Tags;
83
+ readonly hist = new Histogram();
84
+
85
+ constructor(metric: string, unit: "ns" | "bytes" | "count", stream?: string, tags?: Tags) {
86
+ this.metric = metric;
87
+ this.unit = unit;
88
+ this.stream = stream;
89
+ this.tags = tags;
90
+ }
91
+ }
92
+
93
+ function keyFor(metric: string, unit: string, stream?: string, tags?: Tags): SeriesKey {
94
+ const tagStr = tags ? JSON.stringify(tags) : "";
95
+ return `${metric}|${unit}|${stream ?? ""}|${tagStr}`;
96
+ }
97
+
98
+ function instanceId(): string {
99
+ const host = typeof process !== "undefined" ? process.pid.toString() : "node";
100
+ const rand = Math.random().toString(16).slice(2, 8);
101
+ return `${host}-${rand}`;
102
+ }
103
+
104
+ export class Metrics {
105
+ private readonly startMs = Date.now();
106
+ private windowStartMs = Date.now();
107
+ private readonly series = new Map<SeriesKey, MetricSeries>();
108
+ private readonly instance = instanceId();
109
+
110
+ record(metric: string, value: number, unit: "ns" | "bytes" | "count", tags?: Tags, stream?: string): void {
111
+ const key = keyFor(metric, unit, stream, tags);
112
+ let s = this.series.get(key);
113
+ if (!s) {
114
+ s = new MetricSeries(metric, unit, stream, tags);
115
+ this.series.set(key, s);
116
+ }
117
+ s.hist.add(value);
118
+ }
119
+
120
+ recordAppend(bytes: number, entries: number): void {
121
+ this.record("tieredstore.append.bytes", bytes, "bytes");
122
+ this.record("tieredstore.append.entries", entries, "count");
123
+ }
124
+
125
+ recordRead(bytes: number, entries: number): void {
126
+ this.record("tieredstore.read.bytes", bytes, "bytes");
127
+ this.record("tieredstore.read.entries", entries, "count");
128
+ }
129
+
130
+ snapshot(): any {
131
+ return {
132
+ uptime_ms: Date.now() - this.startMs,
133
+ series: this.series.size,
134
+ };
135
+ }
136
+
137
+ flushInterval(): MetricEvent[] {
138
+ const windowEnd = Date.now();
139
+ const intervalMs = windowEnd - this.windowStartMs;
140
+ const events: MetricEvent[] = [];
141
+ for (const s of this.series.values()) {
142
+ const snap = s.hist.snapshotAndReset();
143
+ if (snap.count === 0) continue;
144
+ events.push({
145
+ apiVersion: "durable.streams/metrics/v1",
146
+ kind: "interval",
147
+ metric: s.metric,
148
+ unit: s.unit,
149
+ windowStart: this.windowStartMs,
150
+ windowEnd,
151
+ intervalMs,
152
+ instance: this.instance,
153
+ stream: s.stream,
154
+ tags: s.tags,
155
+ ...snap,
156
+ });
157
+ }
158
+ this.windowStartMs = windowEnd;
159
+ return events;
160
+ }
161
+ }
@@ -0,0 +1,50 @@
1
+ import type { IngestQueue } from "./ingest";
2
+ import type { Metrics } from "./metrics";
3
+
4
+ export class MetricsEmitter {
5
+ private readonly metrics: Metrics;
6
+ private readonly ingest: IngestQueue;
7
+ private readonly intervalMs: number;
8
+ private timer: any | null = null;
9
+
10
+ constructor(metrics: Metrics, ingest: IngestQueue, intervalMs: number) {
11
+ this.metrics = metrics;
12
+ this.ingest = ingest;
13
+ this.intervalMs = intervalMs;
14
+ }
15
+
16
+ start(): void {
17
+ if (this.intervalMs <= 0 || this.timer) return;
18
+ this.timer = setInterval(() => {
19
+ void this.flush();
20
+ }, this.intervalMs);
21
+ }
22
+
23
+ stop(): void {
24
+ if (this.timer) clearInterval(this.timer);
25
+ this.timer = null;
26
+ }
27
+
28
+ private async flush(): Promise<void> {
29
+ const queue = this.ingest.getQueueStats();
30
+ this.metrics.record("tieredstore.ingest.queue.bytes", queue.bytes, "bytes");
31
+ this.metrics.record("tieredstore.ingest.queue.requests", queue.requests, "count");
32
+ const events = this.metrics.flushInterval();
33
+ if (events.length === 0) return;
34
+ const rows = events.map((e) => ({
35
+ routingKey: e.stream ? new TextEncoder().encode(e.stream) : null,
36
+ contentType: "application/json",
37
+ payload: new TextEncoder().encode(JSON.stringify(e)),
38
+ }));
39
+ try {
40
+ await this.ingest.appendInternal({
41
+ stream: "__stream_metrics__",
42
+ baseAppendMs: BigInt(Date.now()),
43
+ rows,
44
+ contentType: "application/json",
45
+ });
46
+ } catch {
47
+ // best-effort; drop on failure
48
+ }
49
+ }
50
+ }
@@ -0,0 +1,64 @@
1
+ type Waiter = { afterSeq: bigint; resolve: () => void };
2
+
3
+ export class StreamNotifier {
4
+ private readonly waiters = new Map<string, Set<Waiter>>();
5
+ private readonly latestSeq = new Map<string, bigint>();
6
+
7
+ notify(stream: string, newEndSeq: bigint): void {
8
+ this.latestSeq.set(stream, newEndSeq);
9
+ const set = this.waiters.get(stream);
10
+ if (!set || set.size === 0) return;
11
+ for (const w of Array.from(set)) {
12
+ if (newEndSeq > w.afterSeq) {
13
+ set.delete(w);
14
+ w.resolve();
15
+ }
16
+ }
17
+ if (set.size === 0) this.waiters.delete(stream);
18
+ }
19
+
20
+ waitFor(stream: string, afterSeq: bigint, timeoutMs: number, signal?: AbortSignal): Promise<void> {
21
+ if (signal?.aborted) return Promise.resolve();
22
+ const latest = this.latestSeq.get(stream);
23
+ if (latest != null && latest > afterSeq) return Promise.resolve();
24
+ return new Promise((resolve) => {
25
+ let done = false;
26
+ const set = this.waiters.get(stream) ?? new Set();
27
+ const cleanup = () => {
28
+ if (done) return;
29
+ done = true;
30
+ const s = this.waiters.get(stream);
31
+ if (s) {
32
+ s.delete(waiter);
33
+ if (s.size === 0) this.waiters.delete(stream);
34
+ }
35
+ if (timeoutId) clearTimeout(timeoutId);
36
+ if (signal) signal.removeEventListener("abort", onAbort);
37
+ resolve();
38
+ };
39
+ const waiter: Waiter = { afterSeq, resolve: cleanup };
40
+ set.add(waiter);
41
+ this.waiters.set(stream, set);
42
+
43
+ const onAbort = () => cleanup();
44
+ if (signal) signal.addEventListener("abort", onAbort, { once: true });
45
+
46
+ let timeoutId: any | null = null;
47
+ if (timeoutMs > 0) {
48
+ timeoutId = setTimeout(() => {
49
+ cleanup();
50
+ }, timeoutMs);
51
+ }
52
+ });
53
+ }
54
+
55
+ notifyClose(stream: string): void {
56
+ const set = this.waiters.get(stream);
57
+ if (!set || set.size === 0) return;
58
+ for (const w of Array.from(set)) {
59
+ set.delete(w);
60
+ w.resolve();
61
+ }
62
+ if (set.size === 0) this.waiters.delete(stream);
63
+ }
64
+ }
@@ -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
+ }