akanjs 3.0.0-alpha.0 → 3.0.0-alpha.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akanjs",
3
- "version": "3.0.0-alpha.0",
3
+ "version": "3.0.0-alpha.1",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -23,14 +23,31 @@ Read snapshots from:
23
23
  curl http://localhost:8080/_akan/app/metrics
24
24
  ```
25
25
 
26
+ A replica and its RSC worker are separate processes and are reported separately. **`rssBytes` is the
27
+ replica's own; the worker's is `rscWorkerRssBytes`.** Sum them for what the pod pays.
28
+
26
29
  Key fields:
27
30
 
28
- - `rssBytes`, `heapUsedBytes`, `jscHeapSizeBytes`: distinguish RSS-only native retention from JS heap retention.
31
+ - `rssBytes`, `heapUsedBytes`, `jscHeapSizeBytes`: the replica. Distinguish RSS-only native retention from JS
32
+ heap retention.
33
+ - `rscWorkerRssBytes`, `rscWorkerHeapUsedBytes`, `rscWorkerJscHeapSizeBytes`, `rscWorkerJscExtraMemorySizeBytes`:
34
+ the same for the worker. `jscExtra` is off-heap, mostly typed-array backing stores.
29
35
  - `rscRenderCount`, `rscInFlightRenderCount`: detect request lifecycle leaks.
30
36
  - `rscLoadedRouteModuleCount`, `rscRouteModuleCacheHits`, `rscRouteModuleCacheMisses`: detect route module warm-up.
31
- - `ssrChunkRegistrySize`, `ssrChunkLoadCount`: detect full-document SSR client chunk retention.
37
+ - `ssrChunkRegistrySize`, `ssrChunkLoadCount`: full-document SSR client chunk loading. **`…RegistrySize` counts
38
+ keys, not bytes** — evicting does not unload the module, so it can never fall on its own.
39
+ - `httpHtmlCacheEntries` / `httpHtmlCacheBytes`, `rscResultCacheEntries` / `rscResultCacheBytes`,
40
+ `rscPatchResultCacheEntries` / `rscPatchResultCacheBytes`: what each cache actually holds. Entry count alone
41
+ says nothing when entries span three orders of magnitude.
32
42
  - `httpFullSsrCount`, `httpRscNavigationCount`, `httpStaticAssetCount`, `httpImageCount`: separate request kinds.
33
43
 
44
+ Two things measured on `apps/akan` that shape how to read all of the above:
45
+
46
+ - **Growth converges; it is not a leak.** Ten passes over the same routes plateau by roughly the sixth, with a
47
+ flat JS heap throughout. Three passes is not enough to tell a plateau from a ratchet.
48
+ - **Freeing JS objects does not lower RSS.** Emptying both result caches returns their bytes to the heap and
49
+ leaves RSS unchanged. Only not allocating, or restarting the process, reduces what the pod pays.
50
+
34
51
  ## Scenarios
35
52
 
36
53
  ### Same Route Repeated
@@ -234,20 +234,62 @@ export function shouldInvalidateRouteCacheEntry(
234
234
  return false;
235
235
  }
236
236
 
237
- export class LruTtlCache<T> {
238
- readonly #entries = new Map<string, { value: T; expiresAt: number }>();
237
+ export interface LruTtlCacheOptions<T> {
238
+ /**
239
+ * Measures one entry's payload. Entry count alone says nothing about a cache whose entries span
240
+ * three orders of magnitude, and a byte ceiling needs a running total to enforce. The default
241
+ * reports 0 rather than guessing, so `byteSize` stays honest about not knowing.
242
+ */
243
+ sizeOf?: (value: T) => number;
244
+ /** Total payload ceiling. 0 leaves the cache bounded only by `maxEntries`. */
245
+ maxBytes?: number;
246
+ /** An entry over this is not stored at all, rather than evicting everything else to fit it. */
247
+ maxEntryBytes?: number;
248
+ /**
249
+ * Cadence of the idle sweep. Without one a filled cache never shrinks: an entry is dropped only
250
+ * when its own key is fetched after expiry or when a write evicts it, so a pod that stops
251
+ * serving holds its peak forever — measured at 100 entries / 21.4 MiB still resident 310s after
252
+ * the last request, with a 30s TTL. 0 disables it.
253
+ */
254
+ sweepIntervalMs?: number;
255
+ }
239
256
 
240
- constructor(readonly maxEntries = 100) {}
257
+ export class LruTtlCache<T> {
258
+ readonly #entries = new Map<string, { value: T; expiresAt: number; byteLength: number }>();
259
+ #byteLength = 0;
260
+ #sweepTimer: ReturnType<typeof setInterval> | null = null;
261
+ readonly #sizeOf: (value: T) => number;
262
+ readonly #maxBytes: number;
263
+ readonly #maxEntryBytes: number;
264
+
265
+ constructor(
266
+ readonly maxEntries = 100,
267
+ options: LruTtlCacheOptions<T> = {},
268
+ ) {
269
+ this.#sizeOf = options.sizeOf ?? (() => 0);
270
+ this.#maxBytes = options.maxBytes ?? 0;
271
+ this.#maxEntryBytes = options.maxEntryBytes ?? 0;
272
+ const sweepIntervalMs = options.sweepIntervalMs ?? 0;
273
+ if (sweepIntervalMs > 0) {
274
+ this.#sweepTimer = setInterval(() => this.sweepExpired(), sweepIntervalMs);
275
+
276
+ (this.#sweepTimer as { unref?: () => void }).unref?.();
277
+ }
278
+ }
241
279
 
242
280
  get size(): number {
243
281
  return this.#entries.size;
244
282
  }
245
283
 
284
+ get byteSize(): number {
285
+ return this.#byteLength;
286
+ }
287
+
246
288
  get(key: string): T | null {
247
289
  const entry = this.#entries.get(key);
248
290
  if (!entry) return null;
249
291
  if (entry.expiresAt <= Date.now()) {
250
- this.#entries.delete(key);
292
+ this.#remove(key);
251
293
  return null;
252
294
  }
253
295
  this.#entries.delete(key);
@@ -255,26 +297,56 @@ export class LruTtlCache<T> {
255
297
  return entry.value;
256
298
  }
257
299
 
258
- set(key: string, value: T, ttlSeconds: number): void {
259
- this.#entries.delete(key);
300
+ /** Returns whether the entry was stored; a payload over `maxEntryBytes` is rejected. */
301
+ set(key: string, value: T, ttlSeconds: number): boolean {
302
+ this.#remove(key);
303
+ const byteLength = LruTtlCache.#measure(this.#sizeOf, value);
304
+ if (this.#maxEntryBytes > 0 && byteLength > this.#maxEntryBytes) return false;
305
+ this.sweepExpired();
260
306
  const maxEntries = this.maxEntries > 0 ? this.maxEntries : 100;
261
307
  while (this.#entries.size >= maxEntries) {
262
- const oldest = this.#entries.keys().next().value;
263
- if (!oldest) break;
264
- this.#entries.delete(oldest);
308
+ if (!this.#removeOldest()) break;
309
+ }
310
+ while (this.#maxBytes > 0 && this.#entries.size > 0 && this.#byteLength + byteLength > this.#maxBytes) {
311
+ if (!this.#removeOldest()) break;
265
312
  }
266
- this.#entries.set(key, { value, expiresAt: Date.now() + ttlSeconds * 1000 });
313
+ this.#entries.set(key, { value, expiresAt: Date.now() + ttlSeconds * 1000, byteLength });
314
+ this.#byteLength += byteLength;
315
+ return true;
316
+ }
317
+
318
+ /**
319
+ * Drops every expired entry. Deliberately a full scan rather than a walk from the oldest that
320
+ * stops at the first live entry: insertion order is *LRU* order because `get` reinserts, and TTLs
321
+ * differ per entry, so expiry is not monotonic in map order and an early break would leave
322
+ * expired entries behind. The map is bounded by `maxEntries`, so the scan is cheap.
323
+ */
324
+ sweepExpired(now = Date.now()): number {
325
+ let removed = 0;
326
+ for (const [key, entry] of this.#entries) {
327
+ if (entry.expiresAt > now) continue;
328
+ this.#remove(key);
329
+ removed += 1;
330
+ }
331
+ return removed;
332
+ }
333
+
334
+ /** Stops the idle sweep. The cache stays usable; only the timer goes away. */
335
+ dispose(): void {
336
+ if (!this.#sweepTimer) return;
337
+ clearInterval(this.#sweepTimer);
338
+ this.#sweepTimer = null;
267
339
  }
268
340
 
269
341
  delete(key: string): boolean {
270
- return this.#entries.delete(key);
342
+ return this.#remove(key);
271
343
  }
272
344
 
273
345
  invalidate(predicate: (key: string, value: T) => boolean): number {
274
346
  let count = 0;
275
347
  for (const [key, entry] of this.#entries) {
276
348
  if (!predicate(key, entry.value)) continue;
277
- this.#entries.delete(key);
349
+ this.#remove(key);
278
350
  count += 1;
279
351
  }
280
352
  return count;
@@ -282,5 +354,35 @@ export class LruTtlCache<T> {
282
354
 
283
355
  clear(): void {
284
356
  this.#entries.clear();
357
+ this.#byteLength = 0;
358
+ }
359
+
360
+ static parseByteCeiling(value: string | undefined | null, fallback = 0): number {
361
+ const parsed = Number.parseInt(value ?? "", 10);
362
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
363
+ }
364
+
365
+ #remove(key: string): boolean {
366
+ const entry = this.#entries.get(key);
367
+ if (!entry) return false;
368
+ this.#entries.delete(key);
369
+ this.#byteLength -= entry.byteLength;
370
+ return true;
371
+ }
372
+
373
+ #removeOldest(): boolean {
374
+ const oldest = this.#entries.keys().next().value;
375
+ if (!oldest) return false;
376
+ return this.#remove(oldest);
377
+ }
378
+
379
+ /** A measurement must never fail a cache write, and a bad measurement must never skew the total. */
380
+ static #measure<T>(sizeOf: (value: T) => number, value: T): number {
381
+ try {
382
+ const measured = sizeOf(value);
383
+ return Number.isFinite(measured) && measured > 0 ? measured : 0;
384
+ } catch {
385
+ return 0;
386
+ }
285
387
  }
286
388
  }
@@ -138,6 +138,10 @@ export class ProcessMetricsCollector {
138
138
  ...(metrics.jscHeapSizeBytes !== undefined
139
139
  ? [`jscHeap=${ProcessMetricsCollector.formatBytes(metrics.jscHeapSizeBytes)}`]
140
140
  : []),
141
+
142
+ ...(metrics.rscWorkerRssBytes !== undefined
143
+ ? [`rscWorkerRss=${ProcessMetricsCollector.formatBytes(metrics.rscWorkerRssBytes)}`]
144
+ : []),
141
145
  ...(metrics.eventLoopLagMeanMs !== undefined
142
146
  ? [`elLag=${metrics.eventLoopLagMeanMs}/${metrics.eventLoopLagP99Ms ?? 0}/${metrics.eventLoopLagMaxMs ?? 0}ms`]
143
147
  : []),
@@ -24,6 +24,7 @@ import { renderToReadableStream } from "react-server-dom-webpack/server.node";
24
24
  import type { ClientManifest } from "./artifact";
25
25
  import {
26
26
  LruTtlCache,
27
+ type LruTtlCacheOptions,
27
28
  parsePositiveInt,
28
29
  type RouteCacheEntry,
29
30
  type RouteCacheInvalidation,
@@ -122,6 +123,9 @@ interface FlightRenderResult {
122
123
  cancelled: boolean;
123
124
  }
124
125
 
126
+ /** Well above any sensible TTL: this reclaims a cache nobody is reading, not tracks expiry closely. */
127
+ const RESULT_CACHE_SWEEP_INTERVAL_MS = 60_000;
128
+
125
129
  function hashRscTraceCacheKey(cacheKey: string): string {
126
130
  let hash = 5381;
127
131
  for (let index = 0; index < cacheKey.length; index += 1) hash = (hash * 33) ^ cacheKey.charCodeAt(index);
@@ -197,11 +201,14 @@ export class RscRenderer {
197
201
  pagesBundleBuildId: 0,
198
202
  };
199
203
  readonly #routeStats = new Map<string, RouteRenderStats>();
204
+
200
205
  #resultCache = new LruTtlCache<CachedRscResult>(
201
206
  parsePositiveInt(process.env.AKAN_RSC_RESULT_CACHE_MAX_ENTRIES) ?? 100,
207
+ RscRenderer.#resultCacheOptions(),
202
208
  );
203
209
  #patchResultCache = new LruTtlCache<CachedRscResult>(
204
210
  parsePositiveInt(process.env.AKAN_RSC_RESULT_CACHE_MAX_ENTRIES) ?? 100,
211
+ RscRenderer.#resultCacheOptions(),
205
212
  );
206
213
  readonly #activeRenderReaders = new Map<string, ReadableStreamDefaultReader<Uint8Array>>();
207
214
  readonly #cancelledRenderRequests = new Set<string>();
@@ -818,7 +825,11 @@ export class RscRenderer {
818
825
  rscLoadedRouteModuleKeys: routeStats.loadedModuleKeys,
819
826
  rscTopRoutesByRenderCount: this.#topRoutes((route) => route.count),
820
827
  rscTopRoutesByFlightBytes: this.#topRoutes((route) => route.flightBytes),
821
- rscResultCacheEntries: this.#resultCache.size + this.#patchResultCache.size,
828
+
829
+ rscResultCacheEntries: this.#resultCache.size,
830
+ rscResultCacheBytes: this.#resultCache.byteSize,
831
+ rscPatchResultCacheEntries: this.#patchResultCache.size,
832
+ rscPatchResultCacheBytes: this.#patchResultCache.byteSize,
822
833
  rscResultCacheHits: this.#resultCacheHits,
823
834
  rscResultCacheMisses: this.#resultCacheMisses,
824
835
  rscResultCacheBypass: this.#resultCacheBypass,
@@ -899,7 +910,7 @@ export class RscRenderer {
899
910
  });
900
911
  };
901
912
  try {
902
- while (true) {
913
+ for (;;) {
903
914
  if (options.requestId && this.#cancelledRenderRequests.has(options.requestId)) {
904
915
  await reader.cancel();
905
916
  return { chunks, bytes, chunksCount, control: null, lateControlSent, cancelled: true };
@@ -1125,6 +1136,15 @@ export class RscRenderer {
1125
1136
  return decision;
1126
1137
  }
1127
1138
 
1139
+ static #resultCacheOptions(): LruTtlCacheOptions<CachedRscResult> {
1140
+ return {
1141
+ sizeOf: (result) => result.bytes,
1142
+ maxBytes: LruTtlCache.parseByteCeiling(process.env.AKAN_RSC_RESULT_CACHE_MAX_BYTES),
1143
+ maxEntryBytes: LruTtlCache.parseByteCeiling(process.env.AKAN_RSC_RESULT_CACHE_MAX_BODY_BYTES),
1144
+ sweepIntervalMs: RESULT_CACHE_SWEEP_INTERVAL_MS,
1145
+ };
1146
+ }
1147
+
1128
1148
  #getCachedResult(cacheKey: string): CachedRscResult | null {
1129
1149
  const cached = this.#resultCache.get(cacheKey);
1130
1150
  if (!cached) {
@@ -1146,11 +1166,19 @@ export class RscRenderer {
1146
1166
  }
1147
1167
 
1148
1168
  #setCachedResult(cacheKey: string, result: CachedRscResult, ttl: number): void {
1149
- this.#resultCache.set(cacheKey, result, ttl);
1169
+ this.#logRejectedStore("full", result, this.#resultCache.set(cacheKey, result, ttl));
1150
1170
  }
1151
1171
 
1152
1172
  #setCachedPatchResult(cacheKey: string, result: CachedRscResult, ttl: number): void {
1153
- this.#patchResultCache.set(cacheKey, result, ttl);
1173
+ this.#logRejectedStore("patch", result, this.#patchResultCache.set(cacheKey, result, ttl));
1174
+ }
1175
+
1176
+ /** A silently dropped store looks identical to a cache miss; say which route is too big to cache. */
1177
+ #logRejectedStore(kind: string, result: CachedRscResult, stored: boolean): void {
1178
+ if (stored) return;
1179
+ this.#logger.verbose(
1180
+ `${kind} result cache store skipped pathname=${result.pathname} bytes=${result.bytes} reason=body-too-large`,
1181
+ );
1154
1182
  }
1155
1183
 
1156
1184
  #runWithRequest<T>(request: Request, fn: () => Promise<T>): Promise<T> {
@@ -89,6 +89,66 @@ export function createRscWorkerInvalidateCacheMessage(
89
89
  };
90
90
  }
91
91
 
92
+ /**
93
+ * The RSC worker samples its own process with the same `ProcessMetricsCollector.collect` its host
94
+ * replica uses, so its report carries `pid` / `rssBytes` / `role` / … under the identical names.
95
+ * `AkanServer` spreads the host's report into its own `collect({ role, ...webRouter.getMetrics() })`
96
+ * and `collect` spreads `extra` last (`processMetricsCollector.ts:122`) — so passing these through
97
+ * unprefixed silently overwrote the replica's own process fields, and the replica's RSS was reported
98
+ * nowhere. Rename them onto `rscWorker*`; the `rsc*` render counters, which only the worker
99
+ * produces, pass through untouched.
100
+ *
101
+ * XXX A new process-level field in `ProcessMetricsCollector.collect` must be added here too, or it
102
+ * starts shadowing the replica again.
103
+ */
104
+ export function projectRscWorkerProcessMetrics(metrics: AkanMetricsReport): AkanMetricsReport {
105
+ const {
106
+ role: _role,
107
+ pid: _pid,
108
+ trace: _trace,
109
+ reportedAt,
110
+ rssBytes,
111
+ heapTotalBytes,
112
+ heapUsedBytes,
113
+ externalBytes,
114
+ arrayBuffersBytes,
115
+ cpuUserMicros,
116
+ cpuSystemMicros,
117
+ maxRssKb,
118
+ jscHeapSizeBytes,
119
+ jscHeapCapacityBytes,
120
+ jscExtraMemorySizeBytes,
121
+ jscObjectCount,
122
+ jscProtectedObjectCount,
123
+ eventLoopLagMeanMs,
124
+ eventLoopLagP99Ms,
125
+ eventLoopLagMaxMs,
126
+ gcDurationMs,
127
+ ...renderMetrics
128
+ } = metrics;
129
+ return {
130
+ ...renderMetrics,
131
+ rscWorkerReportedAt: reportedAt,
132
+ rscWorkerRssBytes: rssBytes,
133
+ rscWorkerHeapTotalBytes: heapTotalBytes,
134
+ rscWorkerHeapUsedBytes: heapUsedBytes,
135
+ rscWorkerExternalBytes: externalBytes,
136
+ rscWorkerArrayBuffersBytes: arrayBuffersBytes,
137
+ rscWorkerCpuUserMicros: cpuUserMicros,
138
+ rscWorkerCpuSystemMicros: cpuSystemMicros,
139
+ rscWorkerMaxRssKb: maxRssKb,
140
+ rscWorkerJscHeapSizeBytes: jscHeapSizeBytes,
141
+ rscWorkerJscHeapCapacityBytes: jscHeapCapacityBytes,
142
+ rscWorkerJscExtraMemorySizeBytes: jscExtraMemorySizeBytes,
143
+ rscWorkerJscObjectCount: jscObjectCount,
144
+ rscWorkerJscProtectedObjectCount: jscProtectedObjectCount,
145
+ rscWorkerEventLoopLagMeanMs: eventLoopLagMeanMs,
146
+ rscWorkerEventLoopLagP99Ms: eventLoopLagP99Ms,
147
+ rscWorkerEventLoopLagMaxMs: eventLoopLagMaxMs,
148
+ rscWorkerGcDurationMs: gcDurationMs,
149
+ };
150
+ }
151
+
92
152
  export function createRscHostRenderStream(input: {
93
153
  setPending: (pending: RscPending) => void;
94
154
  deletePending: () => void;
@@ -464,18 +524,7 @@ export class RscWorker {
464
524
  }
465
525
 
466
526
  getMetrics(): AkanMetricsReport {
467
- const {
468
- rscWorkerPid: _rscWorkerPid,
469
- rscWorkerStatus: _rscWorkerStatus,
470
- rscWorkerRestartCount: _rscWorkerRestartCount,
471
- rscWorkerRecycleCount: _rscWorkerRecycleCount,
472
- rscWorkerLastRecycleReason: _rscWorkerLastRecycleReason,
473
- rscPendingRenderCount: _rscPendingRenderCount,
474
- rscQueuedSendCount: _rscQueuedSendCount,
475
- rscHostPendingChunkOverflowCount: _rscHostPendingChunkOverflowCount,
476
- ...workerMetrics
477
- } = this.#lastWorkerMetrics;
478
- return Object.assign(workerMetrics, {
527
+ return Object.assign(projectRscWorkerProcessMetrics(this.#lastWorkerMetrics), {
479
528
  rscWorkerPid: this.#proc.pid,
480
529
  rscWorkerStatus: this.#status,
481
530
  rscWorkerRestartCount: this.#restartCount,
@@ -411,7 +411,7 @@ export function interleaveRscScriptsWithHtml(
411
411
  const pumpRscScripts = async () => {
412
412
  rscReader = rscClientStream.getReader();
413
413
  try {
414
- while (true) {
414
+ for (;;) {
415
415
  const { value, done } = await rscReader.read();
416
416
  if (done || errored) break;
417
417
  await waitForRscQueueDrain();
@@ -446,7 +446,7 @@ export function interleaveRscScriptsWithHtml(
446
446
 
447
447
  htmlReader = htmlStream.getReader();
448
448
  try {
449
- while (true) {
449
+ for (;;) {
450
450
  const { value, done } = await htmlReader.read();
451
451
  if (done || errored) break;
452
452
  controller.enqueue(value);
@@ -56,6 +56,8 @@ import type { BaseBuildArtifact, HttpRoutes, RenderState } from "./types";
56
56
 
57
57
  const CLIENT_CLOSED_REQUEST_STATUS = 499;
58
58
  export const DEFAULT_HTML_RESULT_CACHE_MAX_BODY_BYTES = 2 * 1024 * 1024;
59
+ /** Well above any sensible TTL: this only has to reclaim a cache nobody is reading, not track expiry closely. */
60
+ const ROUTE_CACHE_SWEEP_INTERVAL_MS = 60_000;
59
61
  const APPLE_APP_SITE_ASSOCIATION_PATH = "/.well-known/apple-app-site-association";
60
62
  const ANDROID_ASSET_LINKS_PATH = "/.well-known/assetlinks.json";
61
63
 
@@ -286,6 +288,11 @@ export class WebRouter {
286
288
  };
287
289
  readonly #htmlCache = new LruTtlCache<CachedHtmlResult>(
288
290
  parsePositiveInt(process.env.AKAN_HTML_RESULT_CACHE_MAX_ENTRIES) ?? 100,
291
+ {
292
+ sizeOf: (result) => result.html.length,
293
+ maxBytes: LruTtlCache.parseByteCeiling(process.env.AKAN_HTML_RESULT_CACHE_MAX_BYTES),
294
+ sweepIntervalMs: ROUTE_CACHE_SWEEP_INTERVAL_MS,
295
+ },
289
296
  );
290
297
  #htmlCacheHits = 0;
291
298
  #htmlCacheMisses = 0;
@@ -669,6 +676,7 @@ export class WebRouter {
669
676
  this.#devHmr?.dispose();
670
677
  this.#devHmr = null;
671
678
  this.#builderRpc = null;
679
+ this.#htmlCache.dispose();
672
680
  this.#rsc.kill();
673
681
  this.#hub = null;
674
682
  }
@@ -686,6 +694,7 @@ export class WebRouter {
686
694
  httpCsrCount: this.#requestStats.csr,
687
695
  httpImageCount: this.#requestStats.image,
688
696
  httpHtmlCacheEntries: this.#htmlCache.size,
697
+ httpHtmlCacheBytes: this.#htmlCache.byteSize,
689
698
  httpHtmlCacheHits: this.#htmlCacheHits,
690
699
  httpHtmlCacheMisses: this.#htmlCacheMisses,
691
700
  httpHtmlCacheBypass: this.#htmlCacheBypass,
@@ -55,6 +55,26 @@ export interface AkanMetricsReport {
55
55
  rscWorkerRestartCount?: number;
56
56
  rscWorkerRecycleCount?: number;
57
57
  rscWorkerLastRecycleReason?: string;
58
+
59
+ rscWorkerReportedAt?: number;
60
+ rscWorkerRssBytes?: number;
61
+ rscWorkerHeapTotalBytes?: number;
62
+ rscWorkerHeapUsedBytes?: number;
63
+ rscWorkerExternalBytes?: number;
64
+ rscWorkerArrayBuffersBytes?: number;
65
+ rscWorkerCpuUserMicros?: number;
66
+ rscWorkerCpuSystemMicros?: number;
67
+ rscWorkerMaxRssKb?: number;
68
+ rscWorkerJscHeapSizeBytes?: number;
69
+ rscWorkerJscHeapCapacityBytes?: number;
70
+ /** Off-heap bytes JSC attributes to JS objects — typed-array backing stores, i.e. cached Flight chunks. */
71
+ rscWorkerJscExtraMemorySizeBytes?: number;
72
+ rscWorkerJscObjectCount?: number;
73
+ rscWorkerJscProtectedObjectCount?: number;
74
+ rscWorkerEventLoopLagMeanMs?: number;
75
+ rscWorkerEventLoopLagP99Ms?: number;
76
+ rscWorkerEventLoopLagMaxMs?: number;
77
+ rscWorkerGcDurationMs?: number;
58
78
  rscPendingRenderCount?: number;
59
79
  rscQueuedSendCount?: number;
60
80
  rscHostPendingChunkOverflowCount?: number;
@@ -80,9 +100,13 @@ export interface AkanMetricsReport {
80
100
  rscLastRenderLoadedModuleDelta?: number;
81
101
  rscLastRenderLoadedModules?: string[];
82
102
  rscResultCacheEntries?: number;
103
+ rscResultCacheBytes?: number;
104
+ rscPatchResultCacheEntries?: number;
105
+ rscPatchResultCacheBytes?: number;
83
106
  rscResultCacheHits?: number;
84
107
  rscResultCacheMisses?: number;
85
108
  rscResultCacheBypass?: number;
109
+ /** Keys the SSR chunk registry tracks — NOT a memory bound; the modules stay in Bun's ESM registry. */
86
110
  ssrChunkRegistrySize?: number;
87
111
  ssrChunkLoadCount?: number;
88
112
  ssrChunkCacheHitCount?: number;
@@ -93,6 +117,7 @@ export interface AkanMetricsReport {
93
117
  httpCsrCount?: number;
94
118
  httpImageCount?: number;
95
119
  httpHtmlCacheEntries?: number;
120
+ httpHtmlCacheBytes?: number;
96
121
  httpHtmlCacheHits?: number;
97
122
  httpHtmlCacheMisses?: number;
98
123
  httpHtmlCacheBypass?: number;
@@ -78,14 +78,45 @@ export declare function shouldStoreRouteCache(input: {
78
78
  }): RouteCacheRenderState;
79
79
  export declare function hasRouteCacheInvalidationScope(invalidation?: RouteCacheInvalidation): boolean;
80
80
  export declare function shouldInvalidateRouteCacheEntry(metadata: RouteCacheMetadata, invalidation: RouteCacheInvalidation): boolean;
81
+ export interface LruTtlCacheOptions<T> {
82
+ /**
83
+ * Measures one entry's payload. Entry count alone says nothing about a cache whose entries span
84
+ * three orders of magnitude, and a byte ceiling needs a running total to enforce. The default
85
+ * reports 0 rather than guessing, so `byteSize` stays honest about not knowing.
86
+ */
87
+ sizeOf?: (value: T) => number;
88
+ /** Total payload ceiling. 0 leaves the cache bounded only by `maxEntries`. */
89
+ maxBytes?: number;
90
+ /** An entry over this is not stored at all, rather than evicting everything else to fit it. */
91
+ maxEntryBytes?: number;
92
+ /**
93
+ * Cadence of the idle sweep. Without one a filled cache never shrinks: an entry is dropped only
94
+ * when its own key is fetched after expiry or when a write evicts it, so a pod that stops
95
+ * serving holds its peak forever — measured at 100 entries / 21.4 MiB still resident 310s after
96
+ * the last request, with a 30s TTL. 0 disables it.
97
+ */
98
+ sweepIntervalMs?: number;
99
+ }
81
100
  export declare class LruTtlCache<T> {
82
101
  #private;
83
102
  readonly maxEntries: number;
84
- constructor(maxEntries?: number);
103
+ constructor(maxEntries?: number, options?: LruTtlCacheOptions<T>);
85
104
  get size(): number;
105
+ get byteSize(): number;
86
106
  get(key: string): T | null;
87
- set(key: string, value: T, ttlSeconds: number): void;
107
+ /** Returns whether the entry was stored; a payload over `maxEntryBytes` is rejected. */
108
+ set(key: string, value: T, ttlSeconds: number): boolean;
109
+ /**
110
+ * Drops every expired entry. Deliberately a full scan rather than a walk from the oldest that
111
+ * stops at the first live entry: insertion order is *LRU* order because `get` reinserts, and TTLs
112
+ * differ per entry, so expiry is not monotonic in map order and an early break would leave
113
+ * expired entries behind. The map is bounded by `maxEntries`, so the scan is cheap.
114
+ */
115
+ sweepExpired(now?: number): number;
116
+ /** Stops the idle sweep. The cache stays usable; only the timer goes away. */
117
+ dispose(): void;
88
118
  delete(key: string): boolean;
89
119
  invalidate(predicate: (key: string, value: T) => boolean): number;
90
120
  clear(): void;
121
+ static parseByteCeiling(value: string | undefined | null, fallback?: number): number;
91
122
  }
@@ -49,6 +49,19 @@ export declare function nextRscHostPendingChunkCount(currentPendingChunks: numbe
49
49
  export declare function isRscHostPendingChunkOverflow(pendingChunks: number, maxPendingChunks: number): boolean;
50
50
  export declare function createIdempotentRscRenderCancel(onCancel: (reason?: unknown) => void): (reason?: unknown) => void;
51
51
  export declare function createRscWorkerInvalidateCacheMessage(invalidation?: string | RouteCacheInvalidation): RscWorkerInvalidateCacheMessage;
52
+ /**
53
+ * The RSC worker samples its own process with the same `ProcessMetricsCollector.collect` its host
54
+ * replica uses, so its report carries `pid` / `rssBytes` / `role` / … under the identical names.
55
+ * `AkanServer` spreads the host's report into its own `collect({ role, ...webRouter.getMetrics() })`
56
+ * and `collect` spreads `extra` last (`processMetricsCollector.ts:122`) — so passing these through
57
+ * unprefixed silently overwrote the replica's own process fields, and the replica's RSS was reported
58
+ * nowhere. Rename them onto `rscWorker*`; the `rsc*` render counters, which only the worker
59
+ * produces, pass through untouched.
60
+ *
61
+ * XXX A new process-level field in `ProcessMetricsCollector.collect` must be added here too, or it
62
+ * starts shadowing the replica again.
63
+ */
64
+ export declare function projectRscWorkerProcessMetrics(metrics: AkanMetricsReport): AkanMetricsReport;
52
65
  export declare function createRscHostRenderStream(input: {
53
66
  setPending: (pending: RscPending) => void;
54
67
  deletePending: () => void;
@@ -59,6 +59,25 @@ export interface AkanMetricsReport {
59
59
  rscWorkerRestartCount?: number;
60
60
  rscWorkerRecycleCount?: number;
61
61
  rscWorkerLastRecycleReason?: string;
62
+ rscWorkerReportedAt?: number;
63
+ rscWorkerRssBytes?: number;
64
+ rscWorkerHeapTotalBytes?: number;
65
+ rscWorkerHeapUsedBytes?: number;
66
+ rscWorkerExternalBytes?: number;
67
+ rscWorkerArrayBuffersBytes?: number;
68
+ rscWorkerCpuUserMicros?: number;
69
+ rscWorkerCpuSystemMicros?: number;
70
+ rscWorkerMaxRssKb?: number;
71
+ rscWorkerJscHeapSizeBytes?: number;
72
+ rscWorkerJscHeapCapacityBytes?: number;
73
+ /** Off-heap bytes JSC attributes to JS objects — typed-array backing stores, i.e. cached Flight chunks. */
74
+ rscWorkerJscExtraMemorySizeBytes?: number;
75
+ rscWorkerJscObjectCount?: number;
76
+ rscWorkerJscProtectedObjectCount?: number;
77
+ rscWorkerEventLoopLagMeanMs?: number;
78
+ rscWorkerEventLoopLagP99Ms?: number;
79
+ rscWorkerEventLoopLagMaxMs?: number;
80
+ rscWorkerGcDurationMs?: number;
62
81
  rscPendingRenderCount?: number;
63
82
  rscQueuedSendCount?: number;
64
83
  rscHostPendingChunkOverflowCount?: number;
@@ -94,9 +113,13 @@ export interface AkanMetricsReport {
94
113
  rscLastRenderLoadedModuleDelta?: number;
95
114
  rscLastRenderLoadedModules?: string[];
96
115
  rscResultCacheEntries?: number;
116
+ rscResultCacheBytes?: number;
117
+ rscPatchResultCacheEntries?: number;
118
+ rscPatchResultCacheBytes?: number;
97
119
  rscResultCacheHits?: number;
98
120
  rscResultCacheMisses?: number;
99
121
  rscResultCacheBypass?: number;
122
+ /** Keys the SSR chunk registry tracks — NOT a memory bound; the modules stay in Bun's ESM registry. */
100
123
  ssrChunkRegistrySize?: number;
101
124
  ssrChunkLoadCount?: number;
102
125
  ssrChunkCacheHitCount?: number;
@@ -107,6 +130,7 @@ export interface AkanMetricsReport {
107
130
  httpCsrCount?: number;
108
131
  httpImageCount?: number;
109
132
  httpHtmlCacheEntries?: number;
133
+ httpHtmlCacheBytes?: number;
110
134
  httpHtmlCacheHits?: number;
111
135
  httpHtmlCacheMisses?: number;
112
136
  httpHtmlCacheBypass?: number;