@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
@@ -0,0 +1,249 @@
1
+ import { Result } from "better-result";
2
+ import type {
3
+ IndexRunRow,
4
+ IndexStateRow,
5
+ LexiconIndexRunRow,
6
+ LexiconIndexStateRow,
7
+ SearchCompanionPlanRow,
8
+ SearchSegmentCompanionRow,
9
+ SecondaryIndexRunRow,
10
+ SecondaryIndexStateRow,
11
+ SegmentMetaRow,
12
+ } from "./store/segment_manifest_store";
13
+ import type { StreamReadRow } from "./store/segment_read_store";
14
+ import { encodeOffsetResult } from "./offset";
15
+ import { zstdCompressSync } from "./util/zstd";
16
+ import { dsError } from "./util/ds_error.ts";
17
+
18
+ function b64(bytes: Uint8Array): string {
19
+ return Buffer.from(bytes).toString("base64");
20
+ }
21
+
22
+ function compressB64(bytes: Uint8Array): string {
23
+ return b64(new Uint8Array(zstdCompressSync(bytes)));
24
+ }
25
+
26
+ function parseSectionsJson(raw: string): string[] {
27
+ try {
28
+ const parsed = JSON.parse(raw);
29
+ return Array.isArray(parsed) ? parsed.filter((value): value is string => typeof value === "string") : [];
30
+ } catch {
31
+ return [];
32
+ }
33
+ }
34
+
35
+ export type ManifestJson = Record<string, any>;
36
+ export type ManifestBuildError = { kind: "invalid_manifest"; message: string };
37
+
38
+ function invalidManifest<T = never>(message: string): Result<T, ManifestBuildError> {
39
+ return Result.err({ kind: "invalid_manifest", message });
40
+ }
41
+
42
+ type BuildManifestArgs = {
43
+ streamName: string;
44
+ streamRow: StreamReadRow;
45
+ publishedLogicalSizeBytes: bigint;
46
+ profileJson?: Record<string, any> | null;
47
+ segmentMeta: SegmentMetaRow;
48
+ uploadedPrefixCount: number;
49
+ generation: number;
50
+ indexState?: IndexStateRow | null;
51
+ indexRuns?: IndexRunRow[];
52
+ retiredRuns?: IndexRunRow[];
53
+ secondaryIndexStates?: SecondaryIndexStateRow[];
54
+ secondaryIndexRuns?: SecondaryIndexRunRow[];
55
+ retiredSecondaryIndexRuns?: SecondaryIndexRunRow[];
56
+ lexiconIndexStates?: LexiconIndexStateRow[];
57
+ lexiconIndexRuns?: LexiconIndexRunRow[];
58
+ retiredLexiconIndexRuns?: LexiconIndexRunRow[];
59
+ searchCompanionPlan?: SearchCompanionPlanRow | null;
60
+ searchSegmentCompanions?: SearchSegmentCompanionRow[];
61
+ };
62
+
63
+ export function buildManifestResult(args: BuildManifestArgs): Result<ManifestJson, ManifestBuildError> {
64
+ const {
65
+ streamName,
66
+ streamRow,
67
+ publishedLogicalSizeBytes,
68
+ profileJson,
69
+ segmentMeta,
70
+ uploadedPrefixCount,
71
+ generation,
72
+ indexState,
73
+ indexRuns,
74
+ retiredRuns,
75
+ } = args;
76
+
77
+ const createdAt = new Date(Number(streamRow.created_at_ms)).toISOString();
78
+ const expiresAt = streamRow.expires_at_ms == null ? null : new Date(Number(streamRow.expires_at_ms)).toISOString();
79
+
80
+ const nextOffset = streamRow.next_offset;
81
+ const nextOffsetNum = Number(nextOffset);
82
+ const nextOffsetEncodedRes = encodeOffsetResult(streamRow.epoch, nextOffset);
83
+ if (Result.isError(nextOffsetEncodedRes)) return invalidManifest(nextOffsetEncodedRes.error.message);
84
+ const nextOffsetEncoded = nextOffsetEncodedRes.value;
85
+
86
+ const maxCount = Math.max(0, segmentMeta.segment_count);
87
+ const prefix = Math.max(0, Math.min(uploadedPrefixCount, maxCount));
88
+ const offBytes = segmentMeta.segment_offsets.subarray(0, prefix * 8);
89
+ const blockBytes = segmentMeta.segment_blocks.subarray(0, prefix * 4);
90
+ const lastTsBytes = segmentMeta.segment_last_ts.subarray(0, prefix * 8);
91
+
92
+ const segOffsetsB64 = compressB64(offBytes);
93
+ const segBlocksB64 = compressB64(blockBytes);
94
+ const segLastTsB64 = compressB64(lastTsBytes);
95
+
96
+ const activeRuns =
97
+ indexRuns?.map((r) => ({
98
+ run_id: r.run_id,
99
+ level: r.level,
100
+ start_segment: r.start_segment,
101
+ end_segment: r.end_segment,
102
+ object_key: r.object_key,
103
+ size_bytes: r.size_bytes,
104
+ filter_len: r.filter_len,
105
+ record_count: r.record_count,
106
+ })) ?? [];
107
+ const retired = retiredRuns?.map((r) => ({
108
+ run_id: r.run_id,
109
+ level: r.level,
110
+ start_segment: r.start_segment,
111
+ end_segment: r.end_segment,
112
+ object_key: r.object_key,
113
+ size_bytes: r.size_bytes,
114
+ filter_len: r.filter_len,
115
+ record_count: r.record_count,
116
+ retired_gen: r.retired_gen ?? undefined,
117
+ retired_at_unix: r.retired_at_ms != null ? Number(r.retired_at_ms / 1000n) : undefined,
118
+ })) ?? [];
119
+ const indexSecret = indexState?.index_secret ? b64(indexState.index_secret) : "";
120
+ const indexedThrough = indexState?.indexed_through ?? 0;
121
+ const secondaryIndexes: Record<string, any> = {};
122
+ const secondaryStates = args.secondaryIndexStates ?? [];
123
+ const secondaryRuns = args.secondaryIndexRuns ?? [];
124
+ const retiredSecondaryRuns = args.retiredSecondaryIndexRuns ?? [];
125
+ for (const state of secondaryStates) {
126
+ secondaryIndexes[state.index_name] = {
127
+ index_secret: b64(state.index_secret),
128
+ config_hash: state.config_hash,
129
+ indexed_through: state.indexed_through,
130
+ active_runs: secondaryRuns
131
+ .filter((run) => run.index_name === state.index_name)
132
+ .map((run) => ({
133
+ run_id: run.run_id,
134
+ level: run.level,
135
+ start_segment: run.start_segment,
136
+ end_segment: run.end_segment,
137
+ object_key: run.object_key,
138
+ size_bytes: run.size_bytes,
139
+ filter_len: run.filter_len,
140
+ record_count: run.record_count,
141
+ })),
142
+ retired_runs: retiredSecondaryRuns
143
+ .filter((run) => run.index_name === state.index_name)
144
+ .map((run) => ({
145
+ run_id: run.run_id,
146
+ level: run.level,
147
+ start_segment: run.start_segment,
148
+ end_segment: run.end_segment,
149
+ object_key: run.object_key,
150
+ size_bytes: run.size_bytes,
151
+ filter_len: run.filter_len,
152
+ record_count: run.record_count,
153
+ retired_gen: run.retired_gen ?? undefined,
154
+ retired_at_unix: run.retired_at_ms != null ? Number(run.retired_at_ms / 1000n) : undefined,
155
+ })),
156
+ };
157
+ }
158
+ const lexiconIndexes = (args.lexiconIndexStates ?? []).map((state) => ({
159
+ source_kind: state.source_kind,
160
+ source_name: state.source_name,
161
+ indexed_through: state.indexed_through,
162
+ active_runs: (args.lexiconIndexRuns ?? [])
163
+ .filter((run) => run.source_kind === state.source_kind && run.source_name === state.source_name)
164
+ .map((run) => ({
165
+ run_id: run.run_id,
166
+ level: run.level,
167
+ start_segment: run.start_segment,
168
+ end_segment: run.end_segment,
169
+ object_key: run.object_key,
170
+ size_bytes: run.size_bytes,
171
+ record_count: run.record_count,
172
+ })),
173
+ retired_runs: (args.retiredLexiconIndexRuns ?? [])
174
+ .filter((run) => run.source_kind === state.source_kind && run.source_name === state.source_name)
175
+ .map((run) => ({
176
+ run_id: run.run_id,
177
+ level: run.level,
178
+ start_segment: run.start_segment,
179
+ end_segment: run.end_segment,
180
+ object_key: run.object_key,
181
+ size_bytes: run.size_bytes,
182
+ record_count: run.record_count,
183
+ retired_gen: run.retired_gen ?? undefined,
184
+ retired_at_unix: run.retired_at_ms != null ? Number(run.retired_at_ms / 1000n) : undefined,
185
+ })),
186
+ }));
187
+ const searchCompanionPlan = args.searchCompanionPlan ?? null;
188
+ const searchCompanionSegments = (args.searchSegmentCompanions ?? [])
189
+ .filter((segment) => segment.segment_index < prefix)
190
+ .map((segment) => ({
191
+ segment_index: segment.segment_index,
192
+ object_key: segment.object_key,
193
+ size_bytes: segment.size_bytes,
194
+ plan_generation: segment.plan_generation,
195
+ primary_timestamp_min_ms: segment.primary_timestamp_min_ms?.toString() ?? undefined,
196
+ primary_timestamp_max_ms: segment.primary_timestamp_max_ms?.toString() ?? undefined,
197
+ sections: parseSectionsJson(segment.sections_json),
198
+ section_sizes: JSON.parse(segment.section_sizes_json || "{}"),
199
+ }));
200
+
201
+ return Result.ok({
202
+ name: streamName,
203
+ created_at: createdAt,
204
+ expires_at: expiresAt,
205
+ content_type: streamRow.content_type,
206
+ profile: streamRow.profile ?? "generic",
207
+ profile_json: profileJson ?? null,
208
+ stream_seq: streamRow.stream_seq ?? null,
209
+ closed: streamRow.closed,
210
+ closed_producer_id: streamRow.closed_producer_id ?? null,
211
+ closed_producer_epoch: streamRow.closed_producer_epoch ?? null,
212
+ closed_producer_seq: streamRow.closed_producer_seq ?? null,
213
+ ttl_seconds: streamRow.ttl_seconds ?? null,
214
+ stream_flags: streamRow.stream_flags,
215
+ generation,
216
+ epoch: streamRow.epoch,
217
+ next_offset: nextOffsetNum,
218
+ next_offset_encoded: nextOffsetEncoded,
219
+ logical_size_bytes: publishedLogicalSizeBytes.toString(),
220
+ segment_count: prefix,
221
+ uploaded_through: prefix,
222
+ active_file_offset: nextOffsetNum,
223
+ last_committed_ts: Number(streamRow.last_append_ms * 1_000_000n),
224
+ zstd_dict: "",
225
+ segment_offsets: segOffsetsB64,
226
+ segment_blocks: segBlocksB64,
227
+ segment_last_ts: segLastTsB64,
228
+ indexed_through: indexedThrough,
229
+ index_secret: indexSecret,
230
+ active_runs: activeRuns,
231
+ retired_runs: retired,
232
+ secondary_indexes: secondaryIndexes,
233
+ lexicon_indexes: lexiconIndexes,
234
+ search_companions: searchCompanionPlan
235
+ ? {
236
+ generation: searchCompanionPlan.generation,
237
+ plan_hash: searchCompanionPlan.plan_hash,
238
+ plan_json: JSON.parse(searchCompanionPlan.plan_json),
239
+ segments: searchCompanionSegments,
240
+ }
241
+ : null,
242
+ });
243
+ }
244
+
245
+ export function buildManifest(args: BuildManifestArgs): ManifestJson {
246
+ const res = buildManifestResult(args);
247
+ if (Result.isError(res)) throw dsError(res.error.message);
248
+ return res.value;
249
+ }
package/src/memory.ts ADDED
@@ -0,0 +1,334 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { readFileSync } from "node:fs";
3
+ import os from "node:os";
4
+
5
+ const HOST_MEMORY_GUARD_FRACTION = 0.7;
6
+ const HOST_MEMORY_HEADROOM_FRACTION = 0.15;
7
+ const HOST_MEMORY_HEADROOM_MIN_BYTES = 512 * 1024 * 1024;
8
+ const HOST_MEMORY_HEADROOM_MAX_BYTES = 2 * 1024 * 1024 * 1024;
9
+
10
+ export function deriveMemoryPressureLimitBytes(requestedLimitBytes: number, hostTotalBytes = os.totalmem()): number {
11
+ const requested = Math.max(0, Math.floor(requestedLimitBytes));
12
+ if (requested <= 0) return 0;
13
+ if (!Number.isFinite(hostTotalBytes) || hostTotalBytes <= 0) return requested;
14
+ const safeHostCap = Math.max(256 * 1024 * 1024, Math.floor(hostTotalBytes * HOST_MEMORY_GUARD_FRACTION));
15
+ return Math.min(requested, safeHostCap);
16
+ }
17
+
18
+ export function deriveMemoryPressureHeadroomBytes(limitBytes: number, hostTotalBytes = os.totalmem()): number {
19
+ const limit = Math.max(0, Math.floor(limitBytes));
20
+ if (limit <= 0) return 0;
21
+ if (!Number.isFinite(hostTotalBytes) || hostTotalBytes <= 0) {
22
+ return Math.min(limit, HOST_MEMORY_HEADROOM_MIN_BYTES);
23
+ }
24
+ const headroomFromHost = Math.floor(hostTotalBytes * HOST_MEMORY_HEADROOM_FRACTION);
25
+ const headroom = Math.max(HOST_MEMORY_HEADROOM_MIN_BYTES, headroomFromHost);
26
+ return Math.min(limit, Math.min(HOST_MEMORY_HEADROOM_MAX_BYTES, headroom));
27
+ }
28
+
29
+ export class MemoryPressureMonitor {
30
+ private readonly limitBytes: number;
31
+ private readonly resumeBytes: number;
32
+ private readonly hostHeadroomBytes: number;
33
+ private readonly hostResumeHeadroomBytes: number;
34
+ private readonly intervalMs: number;
35
+ private readonly onSample?: (rssBytes: number, overLimit: boolean, limitBytes: number) => void;
36
+ private readonly heapSnapshotPath?: string;
37
+ private readonly heapSnapshotMinIntervalMs: number;
38
+ private timer: any | null = null;
39
+ private overLimit = false;
40
+ private maxRssBytes = 0;
41
+ private lastRssBytes = 0;
42
+ private lastGcMs = 0;
43
+ private forcedGcCount = 0;
44
+ private forcedGcReclaimedBytesTotal = 0;
45
+ private lastForcedGcAtMs = 0;
46
+ private lastForcedGcBeforeBytes = 0;
47
+ private lastForcedGcAfterBytes = 0;
48
+ private lastForcedGcReclaimedBytes = 0;
49
+ private lastSnapshotMs = 0;
50
+ private heapSnapshotsWritten = 0;
51
+ private lastDarwinPhysicalBytes = 0;
52
+ private lastDarwinPhysicalAtMs = 0;
53
+
54
+ constructor(
55
+ limitBytes: number,
56
+ opts: {
57
+ resumeFraction?: number;
58
+ intervalMs?: number;
59
+ onSample?: (rssBytes: number, overLimit: boolean, limitBytes: number) => void;
60
+ heapSnapshotPath?: string;
61
+ heapSnapshotMinIntervalMs?: number;
62
+ } = {}
63
+ ) {
64
+ const requestedLimitBytes = Math.max(0, Math.floor(limitBytes));
65
+ this.limitBytes = deriveMemoryPressureLimitBytes(requestedLimitBytes);
66
+ const initialRssBytes = process.memoryUsage().rss;
67
+ this.lastRssBytes = initialRssBytes;
68
+ this.maxRssBytes = initialRssBytes;
69
+ if (requestedLimitBytes > 0 && this.limitBytes < requestedLimitBytes) {
70
+ // eslint-disable-next-line no-console
71
+ console.warn(
72
+ `[memory] clamped limit from ${formatBytes(requestedLimitBytes)} to ${formatBytes(this.limitBytes)} based on host memory`
73
+ );
74
+ }
75
+ // Resume as soon as RSS drops back below the limit by default (no hysteresis),
76
+ // so the server doesn't "deadlock" itself under a stable high-water mark.
77
+ const resumeFraction = Math.min(1.0, Math.max(0.5, opts.resumeFraction ?? 1.0));
78
+ this.resumeBytes = Math.floor(this.limitBytes * resumeFraction);
79
+ this.hostHeadroomBytes = deriveMemoryPressureHeadroomBytes(this.limitBytes);
80
+ this.hostResumeHeadroomBytes = Math.floor(this.hostHeadroomBytes * 1.25);
81
+ this.intervalMs = Math.max(50, opts.intervalMs ?? 1000);
82
+ this.onSample = opts.onSample;
83
+ this.heapSnapshotPath = opts.heapSnapshotPath;
84
+ this.heapSnapshotMinIntervalMs = Math.max(1000, opts.heapSnapshotMinIntervalMs ?? 60_000);
85
+ }
86
+
87
+ start(): void {
88
+ if (this.timer) return;
89
+ if (this.limitBytes <= 0) return;
90
+ this.sample();
91
+ this.timer = setInterval(() => this.sample(), this.intervalMs);
92
+ }
93
+
94
+ stop(): void {
95
+ if (this.timer) clearInterval(this.timer);
96
+ this.timer = null;
97
+ }
98
+
99
+ private sample(): void {
100
+ const rss = process.memoryUsage().rss;
101
+ const effectiveBytes = this.effectiveBytesForGuard(rss);
102
+ const hostAvailableBytes = readHostAvailableMemoryBytes();
103
+ this.lastRssBytes = rss;
104
+ if (rss > this.maxRssBytes) this.maxRssBytes = rss;
105
+ const hostLowMemory = this.hostHeadroomBytes > 0 && hostAvailableBytes <= this.hostHeadroomBytes;
106
+ const overLimit = this.limitBytes > 0 && (effectiveBytes > this.limitBytes || hostLowMemory);
107
+ if (this.onSample) {
108
+ try {
109
+ this.onSample(rss, overLimit, this.limitBytes);
110
+ } catch {
111
+ // ignore
112
+ }
113
+ }
114
+ if (this.limitBytes <= 0) return;
115
+ if (overLimit) {
116
+ this.maybeGc(hostLowMemory ? "host memory headroom" : "memory sample");
117
+ this.maybeHeapSnapshot(hostLowMemory ? "host memory headroom" : "memory sample");
118
+ }
119
+ if (this.overLimit) {
120
+ if (effectiveBytes <= this.resumeBytes && hostAvailableBytes > this.hostResumeHeadroomBytes) this.overLimit = false;
121
+ } else if (effectiveBytes > this.limitBytes) {
122
+ this.overLimit = true;
123
+ } else if (hostLowMemory) {
124
+ this.overLimit = true;
125
+ }
126
+ }
127
+
128
+ private effectiveBytesForGuard(rssBytes: number): number {
129
+ if (this.limitBytes <= 0 || rssBytes <= this.limitBytes) return rssBytes;
130
+ if (process.platform !== "darwin") return rssBytes;
131
+ const now = Date.now();
132
+ if (this.lastDarwinPhysicalAtMs !== 0 && now - this.lastDarwinPhysicalAtMs < 5_000) {
133
+ return this.lastDarwinPhysicalBytes > 0 ? this.lastDarwinPhysicalBytes : this.limitBytes;
134
+ }
135
+ this.lastDarwinPhysicalAtMs = now;
136
+ const physicalBytes = readDarwinTopMemBytes(process.pid);
137
+ if (physicalBytes != null) {
138
+ this.lastDarwinPhysicalBytes = physicalBytes;
139
+ return physicalBytes;
140
+ }
141
+ if (this.lastDarwinPhysicalBytes > 0) return this.lastDarwinPhysicalBytes;
142
+ this.lastDarwinPhysicalBytes = this.limitBytes;
143
+ return this.lastDarwinPhysicalBytes;
144
+ }
145
+
146
+ isOverLimit(): boolean {
147
+ return this.overLimit;
148
+ }
149
+
150
+ getMaxRssBytes(): number {
151
+ return this.maxRssBytes;
152
+ }
153
+
154
+ snapshotMaxRssBytes(reset = true): number {
155
+ const max = this.maxRssBytes;
156
+ if (reset) this.maxRssBytes = this.lastRssBytes;
157
+ return max;
158
+ }
159
+
160
+ getLastRssBytes(): number {
161
+ return this.lastRssBytes;
162
+ }
163
+
164
+ getLimitBytes(): number {
165
+ return this.limitBytes;
166
+ }
167
+
168
+ getGcStats(): {
169
+ forced_gc_count: number;
170
+ forced_gc_reclaimed_bytes_total: number;
171
+ last_forced_gc_at_ms: number | null;
172
+ last_forced_gc_before_bytes: number | null;
173
+ last_forced_gc_after_bytes: number | null;
174
+ last_forced_gc_reclaimed_bytes: number | null;
175
+ heap_snapshots_written: number;
176
+ last_heap_snapshot_at_ms: number | null;
177
+ } {
178
+ return {
179
+ forced_gc_count: this.forcedGcCount,
180
+ forced_gc_reclaimed_bytes_total: this.forcedGcReclaimedBytesTotal,
181
+ last_forced_gc_at_ms: this.lastForcedGcAtMs > 0 ? this.lastForcedGcAtMs : null,
182
+ last_forced_gc_before_bytes: this.lastForcedGcAtMs > 0 ? this.lastForcedGcBeforeBytes : null,
183
+ last_forced_gc_after_bytes: this.lastForcedGcAtMs > 0 ? this.lastForcedGcAfterBytes : null,
184
+ last_forced_gc_reclaimed_bytes: this.lastForcedGcAtMs > 0 ? this.lastForcedGcReclaimedBytes : null,
185
+ heap_snapshots_written: this.heapSnapshotsWritten,
186
+ last_heap_snapshot_at_ms: this.lastSnapshotMs > 0 ? this.lastSnapshotMs : null,
187
+ };
188
+ }
189
+
190
+ maybeGc(reason: string): void {
191
+ const gcFn = (globalThis as any)?.Bun?.gc;
192
+ if (typeof gcFn !== "function") return;
193
+ const now = Date.now();
194
+ if (now - this.lastGcMs < 10_000) return;
195
+ this.lastGcMs = now;
196
+ const before = process.memoryUsage().rss;
197
+ try {
198
+ gcFn(true);
199
+ } catch {
200
+ try {
201
+ gcFn();
202
+ } catch {
203
+ return;
204
+ }
205
+ }
206
+ const after = process.memoryUsage().rss;
207
+ const reclaimed = Math.max(0, before - after);
208
+ this.forcedGcCount += 1;
209
+ this.forcedGcReclaimedBytesTotal += reclaimed;
210
+ this.lastForcedGcAtMs = now;
211
+ this.lastForcedGcBeforeBytes = before;
212
+ this.lastForcedGcAfterBytes = after;
213
+ this.lastForcedGcReclaimedBytes = reclaimed;
214
+ // eslint-disable-next-line no-console
215
+ console.warn(`[gc] forced GC (${reason}) rss ${formatBytes(before)} -> ${formatBytes(after)}`);
216
+ }
217
+
218
+ maybeHeapSnapshot(reason: string): void {
219
+ if (!this.heapSnapshotPath) return;
220
+ const now = Date.now();
221
+ if (now - this.lastSnapshotMs < this.heapSnapshotMinIntervalMs) return;
222
+ this.lastSnapshotMs = now;
223
+ void this.writeHeapSnapshot(reason);
224
+ }
225
+
226
+ private async writeHeapSnapshot(reason: string): Promise<void> {
227
+ try {
228
+ const v8 = await import("v8");
229
+ if (typeof v8.writeHeapSnapshot !== "function") return;
230
+ const fs = await import("node:fs");
231
+ try {
232
+ fs.unlinkSync(this.heapSnapshotPath!);
233
+ } catch {
234
+ // ignore
235
+ }
236
+ const before = process.memoryUsage().rss;
237
+ v8.writeHeapSnapshot(this.heapSnapshotPath);
238
+ const after = process.memoryUsage().rss;
239
+ this.heapSnapshotsWritten += 1;
240
+ // eslint-disable-next-line no-console
241
+ console.warn(`[heap] snapshot (${reason}) rss ${formatBytes(before)} -> ${formatBytes(after)} path=${this.heapSnapshotPath}`);
242
+ } catch (err) {
243
+ // eslint-disable-next-line no-console
244
+ console.warn(`[heap] snapshot failed (${reason}): ${String(err)}`);
245
+ }
246
+ }
247
+ }
248
+
249
+ export function parseDarwinTopMemBytes(output: string, pid: number): number | null {
250
+ const line = output
251
+ .split(/\r?\n/)
252
+ .map((entry) => entry.trim())
253
+ .find((entry) => new RegExp(`^${pid}\\s+`).test(entry));
254
+ if (!line) return null;
255
+ const match = line.match(new RegExp(`^${pid}\\s+([0-9]+(?:\\.[0-9]+)?)([BKMGTP])\\+?\\b`, "i"));
256
+ if (!match) return null;
257
+ const value = Number(match[1]);
258
+ if (!Number.isFinite(value)) return null;
259
+ const unit = match[2]!.toUpperCase();
260
+ const power =
261
+ unit === "B"
262
+ ? 0
263
+ : unit === "K"
264
+ ? 1
265
+ : unit === "M"
266
+ ? 2
267
+ : unit === "G"
268
+ ? 3
269
+ : unit === "T"
270
+ ? 4
271
+ : unit === "P"
272
+ ? 5
273
+ : -1;
274
+ if (power < 0) return null;
275
+ return Math.round(value * 1024 ** power);
276
+ }
277
+
278
+ export function darwinTopMemArgs(pid: number): string[] {
279
+ return ["-l", "1", "-pid", String(pid), "-stats", "pid,mem"];
280
+ }
281
+
282
+ export function parseLinuxMemAvailableBytes(meminfo: string): number | null {
283
+ for (const line of meminfo.split(/\r?\n/)) {
284
+ const match = line.match(/^MemAvailable:\s+([0-9]+)\s+kB$/i);
285
+ if (!match) continue;
286
+ const kb = Number(match[1]);
287
+ if (!Number.isFinite(kb) || kb < 0) return null;
288
+ return kb * 1024;
289
+ }
290
+ return null;
291
+ }
292
+
293
+ function readLinuxMemAvailableBytes(): number | null {
294
+ try {
295
+ const meminfo = readFileSync("/proc/meminfo", "utf8");
296
+ return parseLinuxMemAvailableBytes(meminfo);
297
+ } catch {
298
+ return null;
299
+ }
300
+ }
301
+
302
+ function readHostAvailableMemoryBytes(): number {
303
+ if (process.platform === "linux") {
304
+ const available = readLinuxMemAvailableBytes();
305
+ if (available != null) return available;
306
+ }
307
+ return os.freemem();
308
+ }
309
+
310
+ function readDarwinTopMemBytes(pid: number): number | null {
311
+ try {
312
+ const output = execFileSync("/usr/bin/top", darwinTopMemArgs(pid), {
313
+ encoding: "utf8",
314
+ stdio: ["ignore", "pipe", "ignore"],
315
+ timeout: 5_000,
316
+ maxBuffer: 256 * 1024,
317
+ });
318
+ return parseDarwinTopMemBytes(output, pid);
319
+ } catch {
320
+ return null;
321
+ }
322
+ }
323
+
324
+ export function formatBytes(bytes: number): string {
325
+ const units = ["b", "kb", "mb", "gb"];
326
+ let value = bytes;
327
+ let idx = 0;
328
+ while (value >= 1024 && idx < units.length - 1) {
329
+ value /= 1024;
330
+ idx += 1;
331
+ }
332
+ const digits = idx === 0 ? 0 : 1;
333
+ return `${value.toFixed(digits)}${units[idx]}`;
334
+ }