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 +1 -1
- package/server/SSR_MEMORY_DIAGNOSIS.md +19 -2
- package/server/cachePolicy.ts +114 -12
- package/server/processMetricsCollector.ts +4 -0
- package/server/rscWorker.tsx +32 -4
- package/server/rscWorkerHost.ts +61 -12
- package/server/ssrFromRscRenderer.tsx +2 -2
- package/server/webRouter.ts +9 -0
- package/service/ipcTypes.ts +25 -0
- package/types/server/cachePolicy.d.ts +33 -2
- package/types/server/rscWorkerHost.d.ts +13 -0
- package/types/service/ipcTypes.d.ts +24 -0
package/package.json
CHANGED
|
@@ -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`:
|
|
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`:
|
|
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
|
package/server/cachePolicy.ts
CHANGED
|
@@ -234,20 +234,62 @@ export function shouldInvalidateRouteCacheEntry(
|
|
|
234
234
|
return false;
|
|
235
235
|
}
|
|
236
236
|
|
|
237
|
-
export
|
|
238
|
-
|
|
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
|
-
|
|
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.#
|
|
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
|
-
|
|
259
|
-
|
|
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
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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.#
|
|
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.#
|
|
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
|
: []),
|
package/server/rscWorker.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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> {
|
package/server/rscWorkerHost.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
449
|
+
for (;;) {
|
|
450
450
|
const { value, done } = await htmlReader.read();
|
|
451
451
|
if (done || errored) break;
|
|
452
452
|
controller.enqueue(value);
|
package/server/webRouter.ts
CHANGED
|
@@ -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,
|
package/service/ipcTypes.ts
CHANGED
|
@@ -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
|
-
|
|
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;
|