@prisma/streams-server 0.0.1 → 0.1.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.
- package/CODE_OF_CONDUCT.md +45 -0
- package/CONTRIBUTING.md +68 -0
- package/LICENSE +201 -0
- package/README.md +39 -2
- package/SECURITY.md +33 -0
- package/bin/prisma-streams-server +2 -0
- package/package.json +29 -34
- package/src/app.ts +74 -0
- package/src/app_core.ts +1983 -0
- package/src/app_local.ts +46 -0
- package/src/backpressure.ts +66 -0
- package/src/bootstrap.ts +239 -0
- package/src/config.ts +251 -0
- package/src/db/db.ts +1440 -0
- package/src/db/schema.ts +619 -0
- package/src/expiry_sweeper.ts +44 -0
- package/src/hist.ts +169 -0
- package/src/index/binary_fuse.ts +379 -0
- package/src/index/indexer.ts +745 -0
- package/src/index/run_cache.ts +84 -0
- package/src/index/run_format.ts +213 -0
- package/src/ingest.ts +655 -0
- package/src/lens/lens.ts +501 -0
- package/src/manifest.ts +114 -0
- package/src/memory.ts +155 -0
- package/src/metrics.ts +161 -0
- package/src/metrics_emitter.ts +50 -0
- package/src/notifier.ts +64 -0
- package/src/objectstore/interface.ts +13 -0
- package/src/objectstore/mock_r2.ts +269 -0
- package/src/objectstore/null.ts +32 -0
- package/src/objectstore/r2.ts +128 -0
- package/src/offset.ts +70 -0
- package/src/reader.ts +454 -0
- package/src/runtime/hash.ts +156 -0
- package/src/runtime/hash_vendor/LICENSE.hash-wasm +38 -0
- package/src/runtime/hash_vendor/NOTICE.md +8 -0
- package/src/runtime/hash_vendor/xxhash3.umd.min.cjs +7 -0
- package/src/runtime/hash_vendor/xxhash32.umd.min.cjs +7 -0
- package/src/runtime/hash_vendor/xxhash64.umd.min.cjs +7 -0
- package/src/schema/lens_schema.ts +290 -0
- package/src/schema/proof.ts +547 -0
- package/src/schema/registry.ts +405 -0
- package/src/segment/cache.ts +179 -0
- package/src/segment/format.ts +331 -0
- package/src/segment/segmenter.ts +326 -0
- package/src/segment/segmenter_worker.ts +43 -0
- package/src/segment/segmenter_workers.ts +94 -0
- package/src/server.ts +326 -0
- package/src/sqlite/adapter.ts +164 -0
- package/src/stats.ts +205 -0
- package/src/touch/engine.ts +41 -0
- package/src/touch/interpreter_worker.ts +459 -0
- package/src/touch/live_keys.ts +118 -0
- package/src/touch/live_metrics.ts +858 -0
- package/src/touch/live_templates.ts +619 -0
- package/src/touch/manager.ts +1341 -0
- package/src/touch/naming.ts +13 -0
- package/src/touch/routing_key_notifier.ts +275 -0
- package/src/touch/spec.ts +526 -0
- package/src/touch/touch_journal.ts +671 -0
- package/src/touch/touch_key_id.ts +20 -0
- package/src/touch/worker_pool.ts +189 -0
- package/src/touch/worker_protocol.ts +58 -0
- package/src/types/proper-lockfile.d.ts +1 -0
- package/src/uploader.ts +317 -0
- package/src/util/base32_crockford.ts +81 -0
- package/src/util/bloom256.ts +67 -0
- package/src/util/cleanup.ts +22 -0
- package/src/util/crc32c.ts +29 -0
- package/src/util/ds_error.ts +15 -0
- package/src/util/duration.ts +17 -0
- package/src/util/endian.ts +53 -0
- package/src/util/json_pointer.ts +148 -0
- package/src/util/log.ts +25 -0
- package/src/util/lru.ts +45 -0
- package/src/util/retry.ts +35 -0
- package/src/util/siphash.ts +71 -0
- package/src/util/stream_paths.ts +31 -0
- package/src/util/time.ts +14 -0
- package/src/util/yield.ts +3 -0
- package/build/index.d.mts +0 -1
- package/build/index.d.ts +0 -1
- package/build/index.js +0 -0
- package/build/index.mjs +0 -1
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { Worker } from "node:worker_threads";
|
|
5
|
+
import type { Config } from "../config";
|
|
6
|
+
import type { SegmenterHooks, SegmenterOptions } from "./segmenter";
|
|
7
|
+
|
|
8
|
+
export type SegmenterController = {
|
|
9
|
+
start: () => void;
|
|
10
|
+
stop: (hard?: boolean) => void;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
type WorkerMessage =
|
|
14
|
+
| { type: "sealed"; payloadBytes: number; segmentBytes: number }
|
|
15
|
+
| { type: "stopped" };
|
|
16
|
+
|
|
17
|
+
export class SegmenterWorkerPool implements SegmenterController {
|
|
18
|
+
private readonly config: Config;
|
|
19
|
+
private readonly workerCount: number;
|
|
20
|
+
private readonly opts: SegmenterOptions;
|
|
21
|
+
private readonly hooks?: SegmenterHooks;
|
|
22
|
+
private readonly workers: Worker[] = [];
|
|
23
|
+
private started = false;
|
|
24
|
+
|
|
25
|
+
constructor(config: Config, workerCount: number, opts: SegmenterOptions = {}, hooks?: SegmenterHooks) {
|
|
26
|
+
this.config = config;
|
|
27
|
+
this.workerCount = Math.max(0, Math.floor(workerCount));
|
|
28
|
+
this.opts = opts;
|
|
29
|
+
this.hooks = hooks;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
start(): void {
|
|
33
|
+
if (this.started) return;
|
|
34
|
+
this.started = true;
|
|
35
|
+
for (let i = 0; i < this.workerCount; i++) {
|
|
36
|
+
this.spawnWorker(i);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
stop(_hard?: boolean): void {
|
|
41
|
+
if (!this.started) return;
|
|
42
|
+
this.started = false;
|
|
43
|
+
for (const w of this.workers) {
|
|
44
|
+
try {
|
|
45
|
+
w.postMessage({ type: "stop" });
|
|
46
|
+
} catch {
|
|
47
|
+
// ignore
|
|
48
|
+
}
|
|
49
|
+
void w.terminate();
|
|
50
|
+
}
|
|
51
|
+
this.workers.length = 0;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
private spawnWorker(idx: number): void {
|
|
55
|
+
const workerUrl = new URL("./segmenter_worker.ts", import.meta.url);
|
|
56
|
+
let workerSpec = fileURLToPath(workerUrl);
|
|
57
|
+
if (!existsSync(workerSpec)) {
|
|
58
|
+
const fallback = resolve(process.cwd(), "src/segment/segmenter_worker.ts");
|
|
59
|
+
if (existsSync(fallback)) {
|
|
60
|
+
workerSpec = fallback;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const worker = new Worker(workerSpec, {
|
|
64
|
+
workerData: {
|
|
65
|
+
config: this.config,
|
|
66
|
+
opts: this.opts,
|
|
67
|
+
},
|
|
68
|
+
type: "module",
|
|
69
|
+
smol: true,
|
|
70
|
+
} as any);
|
|
71
|
+
|
|
72
|
+
worker.on("message", (msg: WorkerMessage) => {
|
|
73
|
+
if (msg?.type === "sealed") {
|
|
74
|
+
this.hooks?.onSegmentSealed?.(msg.payloadBytes, msg.segmentBytes);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
worker.on("error", (err) => {
|
|
79
|
+
// eslint-disable-next-line no-console
|
|
80
|
+
console.error(`segmenter worker ${idx} error`, err);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
worker.on("exit", (code) => {
|
|
84
|
+
if (!this.started) return;
|
|
85
|
+
if (code !== 0) {
|
|
86
|
+
// eslint-disable-next-line no-console
|
|
87
|
+
console.error(`segmenter worker ${idx} exited with code ${code}, respawning`);
|
|
88
|
+
this.spawnWorker(idx);
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
this.workers.push(worker);
|
|
93
|
+
}
|
|
94
|
+
}
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import { loadConfig } from "./config";
|
|
2
|
+
import { createApp } from "./app";
|
|
3
|
+
import { StatsCollector, StatsReporter } from "./stats";
|
|
4
|
+
import { LatencyHistogramCollector, HistogramReporter } from "./hist";
|
|
5
|
+
import { MockR2Store } from "./objectstore/mock_r2";
|
|
6
|
+
import { R2ObjectStore } from "./objectstore/r2";
|
|
7
|
+
import { bootstrapFromR2 } from "./bootstrap";
|
|
8
|
+
import { initConsoleLogging } from "./util/log";
|
|
9
|
+
|
|
10
|
+
initConsoleLogging();
|
|
11
|
+
|
|
12
|
+
const args = process.argv.slice(2);
|
|
13
|
+
let autoTuneEnabled = false;
|
|
14
|
+
let autoTuneValueMb: number | null = null;
|
|
15
|
+
for (let i = 0; i < args.length; i++) {
|
|
16
|
+
const arg = args[i];
|
|
17
|
+
if (arg === "--auto-tune") {
|
|
18
|
+
autoTuneEnabled = true;
|
|
19
|
+
const next = args[i + 1];
|
|
20
|
+
if (next && !next.startsWith("--") && /^[0-9]+$/.test(next)) {
|
|
21
|
+
autoTuneValueMb = Number(next);
|
|
22
|
+
}
|
|
23
|
+
} else if (arg.startsWith("--auto-tune=")) {
|
|
24
|
+
autoTuneEnabled = true;
|
|
25
|
+
const raw = arg.split("=", 2)[1] ?? "";
|
|
26
|
+
if (raw.trim() !== "") autoTuneValueMb = Number(raw);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function formatPresetList<T>(presets: number[], selected: number, map: (preset: number) => T, fmt: (val: T) => string): string {
|
|
31
|
+
return presets
|
|
32
|
+
.map((preset) => {
|
|
33
|
+
const value = fmt(map(preset));
|
|
34
|
+
return preset === selected ? `[${value}]` : value;
|
|
35
|
+
})
|
|
36
|
+
.join(", ");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
type AutoTuneConfig = {
|
|
40
|
+
sqliteCacheMb: number;
|
|
41
|
+
indexMemMb: number;
|
|
42
|
+
ingestBatchMb: number;
|
|
43
|
+
ingestQueueMb: number;
|
|
44
|
+
indexBuildConcurrency: number;
|
|
45
|
+
indexCompactConcurrency: number;
|
|
46
|
+
segmenterWorkers: number;
|
|
47
|
+
uploadConcurrency: number;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
function memoryLimitForPreset(preset: number): number {
|
|
51
|
+
return preset === 256 ? 300 : preset;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function applyAutoTune(overrideMb: number | null): void {
|
|
55
|
+
const envMemRaw = process.env.DS_MEMORY_LIMIT_MB;
|
|
56
|
+
if (overrideMb != null) {
|
|
57
|
+
if (envMemRaw) {
|
|
58
|
+
console.error("--auto-tune with a value cannot be used with DS_MEMORY_LIMIT_MB");
|
|
59
|
+
process.exit(1);
|
|
60
|
+
}
|
|
61
|
+
} else if (!envMemRaw) {
|
|
62
|
+
console.error("--auto-tune requires DS_MEMORY_LIMIT_MB to be set (or pass a value)");
|
|
63
|
+
process.exit(1);
|
|
64
|
+
}
|
|
65
|
+
const memMb = overrideMb != null ? overrideMb : Number(envMemRaw);
|
|
66
|
+
if (!Number.isFinite(memMb) || memMb <= 0) {
|
|
67
|
+
const bad = overrideMb != null ? String(overrideMb) : String(envMemRaw);
|
|
68
|
+
console.error(`invalid DS_MEMORY_LIMIT_MB: ${bad}`);
|
|
69
|
+
process.exit(1);
|
|
70
|
+
}
|
|
71
|
+
if (process.env.DS_MEMORY_LIMIT_BYTES) {
|
|
72
|
+
console.error("--auto-tune does not allow DS_MEMORY_LIMIT_BYTES; use DS_MEMORY_LIMIT_MB");
|
|
73
|
+
process.exit(1);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const conflictVars = [
|
|
77
|
+
"DS_SQLITE_CACHE_MB",
|
|
78
|
+
"DS_SQLITE_CACHE_BYTES",
|
|
79
|
+
"DS_INDEX_RUN_MEM_CACHE_BYTES",
|
|
80
|
+
"DS_INGEST_MAX_BATCH_BYTES",
|
|
81
|
+
"DS_INGEST_MAX_QUEUE_BYTES",
|
|
82
|
+
];
|
|
83
|
+
const conflicts = conflictVars.filter((v) => process.env[v] != null);
|
|
84
|
+
if (conflicts.length > 0) {
|
|
85
|
+
console.error(`--auto-tune cannot be used with manual memory settings: ${conflicts.join(", ")}`);
|
|
86
|
+
process.exit(1);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const presets = [256, 512, 1024, 2048, 4096, 8192];
|
|
90
|
+
const preset = [...presets].reverse().find((v) => v <= memMb);
|
|
91
|
+
if (!preset) {
|
|
92
|
+
console.error(`DS_MEMORY_LIMIT_MB=${memMb} is below the minimum preset (256)`);
|
|
93
|
+
process.exit(1);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const tuneFor = (p: number): AutoTuneConfig => ({
|
|
97
|
+
sqliteCacheMb: Math.max(8, Math.floor(p / 16)),
|
|
98
|
+
indexMemMb: Math.max(4, Math.floor(p / 64)),
|
|
99
|
+
ingestBatchMb: Math.max(2, Math.floor(p / 128)),
|
|
100
|
+
ingestQueueMb: Math.max(8, Math.floor(p / 32)),
|
|
101
|
+
indexBuildConcurrency: p >= 8192 ? 8 : p >= 4096 ? 4 : p >= 1024 ? 2 : 1,
|
|
102
|
+
indexCompactConcurrency: p >= 4096 ? 4 : p >= 1024 ? 2 : 1,
|
|
103
|
+
segmenterWorkers: p >= 8192 ? 8 : p >= 4096 ? 4 : p >= 1024 ? 2 : 1,
|
|
104
|
+
uploadConcurrency: p >= 8192 ? 16 : p >= 4096 ? 8 : p >= 1024 ? 4 : 2,
|
|
105
|
+
});
|
|
106
|
+
const tune = tuneFor(preset);
|
|
107
|
+
|
|
108
|
+
const memoryLimitMb = memoryLimitForPreset(preset);
|
|
109
|
+
process.env.DS_MEMORY_LIMIT_MB = String(memoryLimitMb);
|
|
110
|
+
process.env.DS_SQLITE_CACHE_MB = String(tune.sqliteCacheMb);
|
|
111
|
+
process.env.DS_INDEX_RUN_MEM_CACHE_BYTES = String(tune.indexMemMb * 1024 * 1024);
|
|
112
|
+
process.env.DS_INGEST_MAX_BATCH_BYTES = String(tune.ingestBatchMb * 1024 * 1024);
|
|
113
|
+
process.env.DS_INGEST_MAX_QUEUE_BYTES = String(tune.ingestQueueMb * 1024 * 1024);
|
|
114
|
+
process.env.DS_INDEX_BUILD_CONCURRENCY = String(tune.indexBuildConcurrency);
|
|
115
|
+
process.env.DS_INDEX_COMPACT_CONCURRENCY = String(tune.indexCompactConcurrency);
|
|
116
|
+
process.env.DS_SEGMENTER_WORKERS = String(tune.segmenterWorkers);
|
|
117
|
+
process.env.DS_UPLOAD_CONCURRENCY = String(tune.uploadConcurrency);
|
|
118
|
+
|
|
119
|
+
const presetLine = formatPresetList(presets, preset, (v) => v, (v) => String(v));
|
|
120
|
+
console.log(`Auto-tuning for memory preset ${presetLine}`);
|
|
121
|
+
console.log(
|
|
122
|
+
`DS_MEMORY_LIMIT_MB presets: ${formatPresetList(presets, preset, (p) => memoryLimitForPreset(p), (v) => String(v))}`
|
|
123
|
+
);
|
|
124
|
+
console.log(
|
|
125
|
+
`DS_SQLITE_CACHE_MB presets: ${formatPresetList(presets, preset, (p) => tuneFor(p).sqliteCacheMb, (v) => String(v))}`
|
|
126
|
+
);
|
|
127
|
+
console.log(
|
|
128
|
+
`DS_INDEX_RUN_MEM_CACHE_MB presets: ${formatPresetList(presets, preset, (p) => tuneFor(p).indexMemMb, (v) => String(v))}`
|
|
129
|
+
);
|
|
130
|
+
console.log(
|
|
131
|
+
`DS_INGEST_MAX_BATCH_MB presets: ${formatPresetList(presets, preset, (p) => tuneFor(p).ingestBatchMb, (v) => String(v))}`
|
|
132
|
+
);
|
|
133
|
+
console.log(
|
|
134
|
+
`DS_INGEST_MAX_QUEUE_MB presets: ${formatPresetList(presets, preset, (p) => tuneFor(p).ingestQueueMb, (v) => String(v))}`
|
|
135
|
+
);
|
|
136
|
+
console.log(
|
|
137
|
+
`DS_INDEX_BUILD_CONCURRENCY presets: ${formatPresetList(
|
|
138
|
+
presets,
|
|
139
|
+
preset,
|
|
140
|
+
(p) => tuneFor(p).indexBuildConcurrency,
|
|
141
|
+
(v) => String(v)
|
|
142
|
+
)}`
|
|
143
|
+
);
|
|
144
|
+
console.log(
|
|
145
|
+
`DS_INDEX_COMPACT_CONCURRENCY presets: ${formatPresetList(
|
|
146
|
+
presets,
|
|
147
|
+
preset,
|
|
148
|
+
(p) => tuneFor(p).indexCompactConcurrency,
|
|
149
|
+
(v) => String(v)
|
|
150
|
+
)}`
|
|
151
|
+
);
|
|
152
|
+
console.log(
|
|
153
|
+
`DS_SEGMENTER_WORKERS presets: ${formatPresetList(
|
|
154
|
+
presets,
|
|
155
|
+
preset,
|
|
156
|
+
(p) => tuneFor(p).segmenterWorkers,
|
|
157
|
+
(v) => String(v)
|
|
158
|
+
)}`
|
|
159
|
+
);
|
|
160
|
+
console.log(
|
|
161
|
+
`DS_UPLOAD_CONCURRENCY presets: ${formatPresetList(
|
|
162
|
+
presets,
|
|
163
|
+
preset,
|
|
164
|
+
(p) => tuneFor(p).uploadConcurrency,
|
|
165
|
+
(v) => String(v)
|
|
166
|
+
)}`
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (autoTuneEnabled) applyAutoTune(autoTuneValueMb);
|
|
171
|
+
|
|
172
|
+
const cfg = loadConfig();
|
|
173
|
+
|
|
174
|
+
const statsEnabled = args.includes("--stats");
|
|
175
|
+
const histEnabled = args.includes("--hist");
|
|
176
|
+
const bootstrapEnabled = args.includes("--bootstrap-from-r2");
|
|
177
|
+
const bpBudgetRaw = process.env.DS_BACKPRESSURE_BUDGET_MS;
|
|
178
|
+
const bpBudgetMs = bpBudgetRaw ? Number(bpBudgetRaw) : cfg.ingestFlushIntervalMs + 1;
|
|
179
|
+
if (bpBudgetRaw && !Number.isFinite(bpBudgetMs)) {
|
|
180
|
+
// eslint-disable-next-line no-console
|
|
181
|
+
console.error(`invalid DS_BACKPRESSURE_BUDGET_MS: ${bpBudgetRaw}`);
|
|
182
|
+
process.exit(1);
|
|
183
|
+
}
|
|
184
|
+
const stats = statsEnabled ? new StatsCollector({ backpressureBudgetMs: bpBudgetMs }) : undefined;
|
|
185
|
+
const hist = histEnabled ? new LatencyHistogramCollector() : undefined;
|
|
186
|
+
|
|
187
|
+
const storeIdx = args.indexOf("--object-store");
|
|
188
|
+
const storeChoice = storeIdx >= 0 ? args[storeIdx + 1] : null;
|
|
189
|
+
if (!storeChoice || (storeChoice !== "r2" && storeChoice !== "local")) {
|
|
190
|
+
// eslint-disable-next-line no-console
|
|
191
|
+
console.error("missing or invalid --object-store (expected: r2 | local)");
|
|
192
|
+
process.exit(1);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
let store;
|
|
196
|
+
if (storeChoice === "local") {
|
|
197
|
+
const memBytesRaw = process.env.DS_MOCK_R2_MAX_INMEM_BYTES;
|
|
198
|
+
const memMbRaw = process.env.DS_MOCK_R2_MAX_INMEM_MB;
|
|
199
|
+
const memBytes = memBytesRaw ? Number(memBytesRaw) : memMbRaw ? Number(memMbRaw) * 1024 * 1024 : null;
|
|
200
|
+
if (memBytesRaw && !Number.isFinite(memBytes)) {
|
|
201
|
+
// eslint-disable-next-line no-console
|
|
202
|
+
console.error(`invalid DS_MOCK_R2_MAX_INMEM_BYTES: ${memBytesRaw}`);
|
|
203
|
+
process.exit(1);
|
|
204
|
+
}
|
|
205
|
+
if (memMbRaw && !Number.isFinite(Number(memMbRaw))) {
|
|
206
|
+
// eslint-disable-next-line no-console
|
|
207
|
+
console.error(`invalid DS_MOCK_R2_MAX_INMEM_MB: ${memMbRaw}`);
|
|
208
|
+
process.exit(1);
|
|
209
|
+
}
|
|
210
|
+
const spillDir = process.env.DS_MOCK_R2_SPILL_DIR;
|
|
211
|
+
store = memBytes != null || spillDir ? new MockR2Store({ maxInMemoryBytes: memBytes ?? undefined, spillDir }) : new MockR2Store();
|
|
212
|
+
} else {
|
|
213
|
+
const bucket = process.env.DURABLE_STREAMS_R2_BUCKET;
|
|
214
|
+
const accountId = process.env.DURABLE_STREAMS_R2_ACCOUNT_ID;
|
|
215
|
+
const accessKeyId = process.env.DURABLE_STREAMS_R2_ACCESS_KEY_ID;
|
|
216
|
+
const secretAccessKey = process.env.DURABLE_STREAMS_R2_SECRET_ACCESS_KEY;
|
|
217
|
+
if (!bucket || !accountId || !accessKeyId || !secretAccessKey) {
|
|
218
|
+
// eslint-disable-next-line no-console
|
|
219
|
+
console.error("missing R2 env vars: DURABLE_STREAMS_R2_BUCKET, DURABLE_STREAMS_R2_ACCOUNT_ID, DURABLE_STREAMS_R2_ACCESS_KEY_ID, DURABLE_STREAMS_R2_SECRET_ACCESS_KEY");
|
|
220
|
+
process.exit(1);
|
|
221
|
+
}
|
|
222
|
+
store = new R2ObjectStore({
|
|
223
|
+
bucket,
|
|
224
|
+
accountId,
|
|
225
|
+
accessKeyId,
|
|
226
|
+
secretAccessKey,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (bootstrapEnabled) {
|
|
231
|
+
await bootstrapFromR2(cfg, store, { clearLocal: true });
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const app = createApp(cfg, store, { stats });
|
|
235
|
+
const statsIntervalMs = process.env.DS_STATS_INTERVAL_MS ? Number(process.env.DS_STATS_INTERVAL_MS) : 60_000;
|
|
236
|
+
if (process.env.DS_STATS_INTERVAL_MS && !Number.isFinite(statsIntervalMs)) {
|
|
237
|
+
// eslint-disable-next-line no-console
|
|
238
|
+
console.error(`invalid DS_STATS_INTERVAL_MS: ${process.env.DS_STATS_INTERVAL_MS}`);
|
|
239
|
+
process.exit(1);
|
|
240
|
+
}
|
|
241
|
+
const statsReporter =
|
|
242
|
+
statsEnabled && stats
|
|
243
|
+
? new StatsReporter(stats, app.deps.db, app.deps.uploader, app.deps.ingest, app.deps.backpressure, app.deps.memory, statsIntervalMs)
|
|
244
|
+
: null;
|
|
245
|
+
const histReporter = histEnabled && hist ? new HistogramReporter(hist, statsIntervalMs) : null;
|
|
246
|
+
|
|
247
|
+
const fetchWithHist = hist
|
|
248
|
+
? async (req: Request): Promise<Response> => {
|
|
249
|
+
const start = Date.now();
|
|
250
|
+
const resp = await app.fetch(req);
|
|
251
|
+
const url = req.url;
|
|
252
|
+
let path: string | null = null;
|
|
253
|
+
if (url.startsWith("/")) {
|
|
254
|
+
path = url;
|
|
255
|
+
} else {
|
|
256
|
+
const schemeIdx = url.indexOf("://");
|
|
257
|
+
if (schemeIdx !== -1) {
|
|
258
|
+
const pathIdx = url.indexOf("/", schemeIdx + 3);
|
|
259
|
+
path = pathIdx === -1 ? "/" : url.slice(pathIdx);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
if (path) {
|
|
263
|
+
const isStream = path.startsWith("/v1/stream/") || path.startsWith("/v1/streams");
|
|
264
|
+
if (isStream) {
|
|
265
|
+
const ms = Date.now() - start;
|
|
266
|
+
const method = req.method.toUpperCase();
|
|
267
|
+
if (method === "GET" || method === "HEAD") hist.recordRead(ms);
|
|
268
|
+
else if (method === "POST" || method === "PUT" || method === "DELETE") hist.recordWrite(ms);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return resp;
|
|
272
|
+
}
|
|
273
|
+
: app.fetch;
|
|
274
|
+
|
|
275
|
+
const server = Bun.serve({
|
|
276
|
+
hostname: cfg.host,
|
|
277
|
+
port: cfg.port,
|
|
278
|
+
// Default Bun idleTimeout is 10s, which is too low for long-poll endpoints like /touch/wait.
|
|
279
|
+
// Bun expects seconds here.
|
|
280
|
+
idleTimeout: (() => {
|
|
281
|
+
const raw = process.env.DS_HTTP_IDLE_TIMEOUT_SECONDS;
|
|
282
|
+
if (raw == null || raw.trim() === "") return 180;
|
|
283
|
+
const n = Number(raw);
|
|
284
|
+
if (!Number.isFinite(n) || n <= 0) {
|
|
285
|
+
// eslint-disable-next-line no-console
|
|
286
|
+
console.error(`invalid DS_HTTP_IDLE_TIMEOUT_SECONDS: ${raw}`);
|
|
287
|
+
process.exit(1);
|
|
288
|
+
}
|
|
289
|
+
return n;
|
|
290
|
+
})(),
|
|
291
|
+
fetch: fetchWithHist,
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
statsReporter?.start();
|
|
295
|
+
histReporter?.start();
|
|
296
|
+
|
|
297
|
+
let shuttingDown = false;
|
|
298
|
+
const shutdown = (signal: NodeJS.Signals) => {
|
|
299
|
+
if (shuttingDown) return;
|
|
300
|
+
shuttingDown = true;
|
|
301
|
+
// eslint-disable-next-line no-console
|
|
302
|
+
console.log(`received ${signal}, shutting down prisma-streams server`);
|
|
303
|
+
statsReporter?.stop();
|
|
304
|
+
histReporter?.stop();
|
|
305
|
+
try {
|
|
306
|
+
server.stop(true);
|
|
307
|
+
} catch (err) {
|
|
308
|
+
// eslint-disable-next-line no-console
|
|
309
|
+
console.error("failed to stop HTTP server cleanly", err);
|
|
310
|
+
}
|
|
311
|
+
try {
|
|
312
|
+
app.close();
|
|
313
|
+
} catch (err) {
|
|
314
|
+
// eslint-disable-next-line no-console
|
|
315
|
+
console.error("failed to close application cleanly", err);
|
|
316
|
+
process.exitCode = 1;
|
|
317
|
+
}
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
const listenTarget = cfg.host.includes(":") ? `[${cfg.host}]:${server.port}` : `${cfg.host}:${server.port}`;
|
|
321
|
+
|
|
322
|
+
process.once("SIGINT", () => shutdown("SIGINT"));
|
|
323
|
+
process.once("SIGTERM", () => shutdown("SIGTERM"));
|
|
324
|
+
|
|
325
|
+
// eslint-disable-next-line no-console
|
|
326
|
+
console.log(`prisma-streams server listening on ${listenTarget}`);
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { dsError } from "../util/ds_error.ts";
|
|
2
|
+
export interface SqliteStatement {
|
|
3
|
+
get(...params: any[]): any;
|
|
4
|
+
all(...params: any[]): any[];
|
|
5
|
+
run(...params: any[]): { changes: number | bigint; lastInsertRowid: number | bigint };
|
|
6
|
+
iterate(...params: any[]): Iterable<any>;
|
|
7
|
+
finalize?(): void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface SqliteDatabase {
|
|
11
|
+
exec(sql: string): void;
|
|
12
|
+
query(sql: string): SqliteStatement;
|
|
13
|
+
transaction<T>(fn: () => T): () => T;
|
|
14
|
+
close(): void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
class BunStatementAdapter implements SqliteStatement {
|
|
18
|
+
private readonly stmt: any;
|
|
19
|
+
|
|
20
|
+
constructor(stmt: any) {
|
|
21
|
+
this.stmt = stmt;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
get(...params: any[]): any {
|
|
25
|
+
return this.stmt.get(...params);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
all(...params: any[]): any[] {
|
|
29
|
+
return this.stmt.all(...params);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
run(...params: any[]): { changes: number | bigint; lastInsertRowid: number | bigint } {
|
|
33
|
+
return this.stmt.run(...params);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
iterate(...params: any[]): Iterable<any> {
|
|
37
|
+
return this.stmt.iterate(...params);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
finalize(): void {
|
|
41
|
+
if (typeof this.stmt.finalize === "function") this.stmt.finalize();
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
class BunDatabaseAdapter implements SqliteDatabase {
|
|
46
|
+
private readonly db: any;
|
|
47
|
+
|
|
48
|
+
constructor(db: any) {
|
|
49
|
+
this.db = db;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
exec(sql: string): void {
|
|
53
|
+
this.db.exec(sql);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
query(sql: string): SqliteStatement {
|
|
57
|
+
return new BunStatementAdapter(this.db.query(sql));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
transaction<T>(fn: () => T): () => T {
|
|
61
|
+
return this.db.transaction(fn);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
close(): void {
|
|
65
|
+
this.db.close();
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
class NodeStatementAdapter implements SqliteStatement {
|
|
70
|
+
private readonly stmt: any;
|
|
71
|
+
|
|
72
|
+
constructor(stmt: any) {
|
|
73
|
+
this.stmt = stmt;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
get(...params: any[]): any {
|
|
77
|
+
return this.stmt.get(...params);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
all(...params: any[]): any[] {
|
|
81
|
+
return this.stmt.all(...params);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
run(...params: any[]): { changes: number | bigint; lastInsertRowid: number | bigint } {
|
|
85
|
+
return this.stmt.run(...params);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
iterate(...params: any[]): Iterable<any> {
|
|
89
|
+
return this.stmt.iterate(...params);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
finalize(): void {
|
|
93
|
+
if (typeof this.stmt.finalize === "function") this.stmt.finalize();
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
class NodeDatabaseAdapter implements SqliteDatabase {
|
|
98
|
+
private txDepth = 0;
|
|
99
|
+
private txCounter = 0;
|
|
100
|
+
private readonly db: any;
|
|
101
|
+
|
|
102
|
+
constructor(db: any) {
|
|
103
|
+
this.db = db;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
exec(sql: string): void {
|
|
107
|
+
this.db.exec(sql);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
query(sql: string): SqliteStatement {
|
|
111
|
+
const stmt = this.db.prepare(sql);
|
|
112
|
+
if (typeof stmt?.setReadBigInts === "function") stmt.setReadBigInts(true);
|
|
113
|
+
return new NodeStatementAdapter(stmt);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
transaction<T>(fn: () => T): () => T {
|
|
117
|
+
return () => {
|
|
118
|
+
const nested = this.txDepth > 0;
|
|
119
|
+
const savepoint = `ds_tx_${++this.txCounter}`;
|
|
120
|
+
this.txDepth += 1;
|
|
121
|
+
try {
|
|
122
|
+
if (nested) this.db.exec(`SAVEPOINT ${savepoint};`);
|
|
123
|
+
else this.db.exec("BEGIN;");
|
|
124
|
+
const out = fn();
|
|
125
|
+
if (nested) this.db.exec(`RELEASE SAVEPOINT ${savepoint};`);
|
|
126
|
+
else this.db.exec("COMMIT;");
|
|
127
|
+
return out;
|
|
128
|
+
} catch (err) {
|
|
129
|
+
try {
|
|
130
|
+
if (nested) {
|
|
131
|
+
this.db.exec(`ROLLBACK TO SAVEPOINT ${savepoint};`);
|
|
132
|
+
this.db.exec(`RELEASE SAVEPOINT ${savepoint};`);
|
|
133
|
+
} else {
|
|
134
|
+
this.db.exec("ROLLBACK;");
|
|
135
|
+
}
|
|
136
|
+
} catch {
|
|
137
|
+
// Ignore secondary rollback failures.
|
|
138
|
+
}
|
|
139
|
+
throw err;
|
|
140
|
+
} finally {
|
|
141
|
+
this.txDepth = Math.max(0, this.txDepth - 1);
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
close(): void {
|
|
147
|
+
this.db.close();
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
let openImpl: ((path: string) => SqliteDatabase) | null = null;
|
|
152
|
+
|
|
153
|
+
if (typeof (globalThis as any).Bun !== "undefined") {
|
|
154
|
+
const { Database } = await import("bun:sqlite");
|
|
155
|
+
openImpl = (path: string) => new BunDatabaseAdapter(new Database(path));
|
|
156
|
+
} else {
|
|
157
|
+
const { DatabaseSync } = await import("node:sqlite");
|
|
158
|
+
openImpl = (path: string) => new NodeDatabaseAdapter(new DatabaseSync(path));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function openSqliteDatabase(path: string): SqliteDatabase {
|
|
162
|
+
if (!openImpl) throw dsError("sqlite adapter not initialized");
|
|
163
|
+
return openImpl(path);
|
|
164
|
+
}
|