castrum 0.9.8 → 0.9.9
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/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/castrum.darwin-arm64.node +0 -0
- package/castrum.darwin-x64.node +0 -0
- package/castrum.linux-arm64-gnu.node +0 -0
- package/castrum.linux-arm64-musl.node +0 -0
- package/castrum.linux-x64-gnu.node +0 -0
- package/castrum.linux-x64-musl.node +0 -0
- package/castrum.linux-x64-v3-gnu.node +0 -0
- package/castrum.win32-x64-msvc.node +0 -0
- package/dist/index.d.ts +164 -4
- package/dist/index.js +434 -70
- package/docs/ENVIRONMENT.md +7 -0
- package/docs/RND-CONCURRENCY.md +20 -4
- package/docs/superpowers/plans/2026-09-18-elysia-learnings-adoption.md +634 -0
- package/index.ts +5 -0
- package/package.json +2 -1
- package/rust/ffi/task.rs +19 -18
- package/rust/ingress/api.rs +21 -5
- package/rust/ingress/mod.rs +7 -0
- package/rust/ingress/native_route.rs +4 -2
- package/rust/ingress/schema_cache.rs +266 -0
- package/rust/task/mod.rs +1 -0
- package/rust/task/runtime.rs +55 -7
- package/rust/task/tests.rs +98 -1
- package/src/ingress/abort.ts +48 -0
- package/src/ingress/body.ts +104 -8
- package/src/ingress/handlers.ts +1 -0
- package/src/ingress/health.ts +22 -0
- package/src/ingress/index.ts +3 -1
- package/src/ingress/router.ts +36 -1
- package/src/ingress/routes/common.ts +9 -1
- package/src/ingress/routes/echo.ts +10 -0
- package/src/ingress/routes/json-write.ts +11 -0
- package/src/ingress/routes/native.ts +24 -5
- package/src/ingress/routes/options.ts +6 -2
- package/src/ingress/routes/responder.ts +25 -5
- package/src/ingress/routes/safe-record.ts +57 -0
- package/src/ingress/server-error.ts +111 -0
- package/src/ingress/server-node.ts +102 -23
- package/src/ingress/server.ts +140 -32
- package/src/integration/pipeline.ts +6 -0
- package/src/native/ffi/build/task.ts +2 -1
- package/src/native/ffi/types.ts +6 -1
- package/src/native/types.ts +6 -0
- package/src/runtime/detect.ts +14 -0
- package/src/rust-ffi/context.ts +46 -3
- package/src/rust-ffi/scalar/factories.ts +6 -0
- package/src/rust-ffi/scalar/interface.ts +14 -0
- package/src/rust-ffi/text.ts +5 -4
- package/src/shared/buffer-pool.ts +10 -0
- package/src/shared/memory.ts +68 -0
- package/src/shared/metrics.ts +86 -3
- package/src/shared/runtime.ts +7 -0
- package/src/task/runtime.ts +52 -9
package/src/native/types.ts
CHANGED
|
@@ -344,6 +344,12 @@ export interface NativeAddon {
|
|
|
344
344
|
|
|
345
345
|
initThreadPool(rayonThreads?: number): void
|
|
346
346
|
rayonNumThreads(): number
|
|
347
|
+
/**
|
|
348
|
+
* Drop the process-wide compiled request-body schema cache (identical schema
|
|
349
|
+
* bytes are compiled once and shared across ingress instances/routes).
|
|
350
|
+
* Maintenance hook for the public `flushMemory()`; idempotent.
|
|
351
|
+
*/
|
|
352
|
+
clearSchemaCache(): void
|
|
347
353
|
|
|
348
354
|
jsonValidBatchPacked(input: Uint8Array): Uint8Array
|
|
349
355
|
validateEmailBatchPacked(input: Uint8Array): Uint8Array
|
package/src/runtime/detect.ts
CHANGED
|
@@ -27,6 +27,20 @@ export function isNodeRuntime(): boolean {
|
|
|
27
27
|
return detectedRuntime === 'node'
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Request a synchronous full garbage collection when the host runtime exposes
|
|
32
|
+
* one. On Bun this is `Bun.gc(true)`; on Node it is `globalThis.gc?.()`, which
|
|
33
|
+
* is only defined when the process runs with `--expose-gc`. A no-op otherwise.
|
|
34
|
+
*/
|
|
35
|
+
export function forceGc(): void {
|
|
36
|
+
if (detectedRuntime === 'bun') {
|
|
37
|
+
Bun.gc(true)
|
|
38
|
+
return
|
|
39
|
+
}
|
|
40
|
+
// Node only defines `globalThis.gc` under `--expose-gc`.
|
|
41
|
+
;(globalThis as { gc?: () => void }).gc?.()
|
|
42
|
+
}
|
|
43
|
+
|
|
30
44
|
/** The Node.js major version when running under Node, otherwise `null`. */
|
|
31
45
|
export function nodeMajorVersion(): number | null {
|
|
32
46
|
if (detectedRuntime !== 'node') return null
|
package/src/rust-ffi/context.ts
CHANGED
|
@@ -21,6 +21,51 @@ import { normalizeExt, resolveRayonThreads } from './options'
|
|
|
21
21
|
// batch.ts uses for its packed entries.
|
|
22
22
|
const nativeFnCache = new Map<string, unknown>()
|
|
23
23
|
|
|
24
|
+
/** Max entries retained per process-wide MIME cache (insertion-order eviction). */
|
|
25
|
+
const MIME_CACHE_MAX = 1024
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Process-wide extension-bytes → MIME-bytes cache shared by every client
|
|
29
|
+
* context: the native addon is a process singleton, so `extension → MIME` is a
|
|
30
|
+
* global pure lookup. Bounded by `MIME_CACHE_MAX`; on overflow the oldest half
|
|
31
|
+
* is evicted via {@link evictOldestHalf}. Exported for the memory-flush path
|
|
32
|
+
* and tests — callers must NOT mutate the returned slices.
|
|
33
|
+
*/
|
|
34
|
+
export const mimeByText = new Map<string, Uint8Array>()
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Process-wide extension-string → MIME-string memo (the `text.mimeFromExtension`
|
|
38
|
+
* fast path). Bounded exactly like {@link mimeByText}.
|
|
39
|
+
*/
|
|
40
|
+
export const mimeStrCache = new Map<string, string>()
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Evict the oldest half of a cache map (insertion order) and return the keys
|
|
44
|
+
* dropped. Generational eviction — halving at the cap amortizes the cost rather
|
|
45
|
+
* than paying a delete per insertion. Returns an empty array below the cap.
|
|
46
|
+
*/
|
|
47
|
+
export function evictOldestHalf<K, V>(map: Map<K, V>): K[] {
|
|
48
|
+
if (map.size < MIME_CACHE_MAX) return []
|
|
49
|
+
const drop = Math.ceil(map.size / 2)
|
|
50
|
+
const dropped: K[] = []
|
|
51
|
+
for (const key of map.keys()) {
|
|
52
|
+
if (dropped.length >= drop) break
|
|
53
|
+
dropped.push(key)
|
|
54
|
+
}
|
|
55
|
+
for (const key of dropped) map.delete(key)
|
|
56
|
+
return dropped
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Clear the process-wide MIME caches (both the bytes and string forms). This is
|
|
61
|
+
* the impure-boundary hook consumed by the public `flushMemory()`; safe to call
|
|
62
|
+
* at any time, including before the addon has ever been loaded.
|
|
63
|
+
*/
|
|
64
|
+
export function clearMimeCaches(): void {
|
|
65
|
+
mimeByText.clear()
|
|
66
|
+
mimeStrCache.clear()
|
|
67
|
+
}
|
|
68
|
+
|
|
24
69
|
/**
|
|
25
70
|
* Resolve a native op on first use and cache it for all subsequent calls —
|
|
26
71
|
* FFI-FIRST via the runtime adapter's transport (`bun:ffi` on Bun, napi
|
|
@@ -91,7 +136,6 @@ export interface RustClientContext {
|
|
|
91
136
|
runtime: NativeRuntimeAdapter
|
|
92
137
|
/** Per-instance toggles (mime/hmac caching). */
|
|
93
138
|
state: { mimeCache: boolean; hmacCache: boolean }
|
|
94
|
-
mimeByText: Map<string, Uint8Array>
|
|
95
139
|
hmacSigners: WeakMap<Uint8Array, HmacSignerInstance>
|
|
96
140
|
|
|
97
141
|
/** Initialize the process-wide rayon pool exactly once (no-op after). */
|
|
@@ -110,7 +154,6 @@ export interface RustClientContext {
|
|
|
110
154
|
|
|
111
155
|
/** Build a per-instance context from client options. */
|
|
112
156
|
export function createContext(options: RustOptions): RustClientContext {
|
|
113
|
-
const mimeByText = new Map<string, Uint8Array>()
|
|
114
157
|
const hmacSigners = new WeakMap<Uint8Array, HmacSignerInstance>()
|
|
115
158
|
const state = {
|
|
116
159
|
mimeCache: options.mimeCache !== false,
|
|
@@ -152,6 +195,7 @@ export function createContext(options: RustOptions): RustClientContext {
|
|
|
152
195
|
|
|
153
196
|
let val = mimeByText.get(key)
|
|
154
197
|
if (!val) {
|
|
198
|
+
evictOldestHalf(mimeByText)
|
|
155
199
|
val = addon.mimeFromExtension(ext)
|
|
156
200
|
mimeByText.set(key, val)
|
|
157
201
|
}
|
|
@@ -181,7 +225,6 @@ export function createContext(options: RustOptions): RustClientContext {
|
|
|
181
225
|
addon,
|
|
182
226
|
runtime: runtimeNative,
|
|
183
227
|
state,
|
|
184
|
-
mimeByText,
|
|
185
228
|
hmacSigners,
|
|
186
229
|
ensurePool,
|
|
187
230
|
isPoolInitialized,
|
|
@@ -559,5 +559,11 @@ export function buildFactories(ctx: RustClientContext) {
|
|
|
559
559
|
rayonNumThreads(): number {
|
|
560
560
|
return asNumber(addon.rayonNumThreads() as unknown)
|
|
561
561
|
},
|
|
562
|
+
clearSchemaCache(): void {
|
|
563
|
+
// Defensive: a stale addon built before this symbol, or a failed bind,
|
|
564
|
+
// must make this a no-op rather than a throw (flushMemory calls it).
|
|
565
|
+
const native = addon as unknown as { clearSchemaCache?: () => void }
|
|
566
|
+
native.clearSchemaCache?.()
|
|
567
|
+
},
|
|
562
568
|
}
|
|
563
569
|
}
|
|
@@ -237,6 +237,20 @@ export interface RustScalar {
|
|
|
237
237
|
createMetricsRegistry(): MetricsRegistryInstance
|
|
238
238
|
initThreadPool(rayonThreads?: number): void
|
|
239
239
|
rayonNumThreads(): number
|
|
240
|
+
/**
|
|
241
|
+
* Drop the process-wide compiled request-body schema cache. Identical schema
|
|
242
|
+
* bytes are compiled once and shared across `Ingress` / `NativeRoute`
|
|
243
|
+
* instances; this releases those compiled validators (already-constructed
|
|
244
|
+
* instances keep their own shared reference and are unaffected). Idempotent,
|
|
245
|
+
* and a safe no-op when the native addon is absent or predates the symbol
|
|
246
|
+
* (the public `flushMemory()` relies on that).
|
|
247
|
+
*
|
|
248
|
+
* Maintenance-only escape hatch — NOT a benchmarked op, so it is intentionally
|
|
249
|
+
* exempt from `PROVEN_SELECTION` / `selection.json` (there is no native-vs-JS
|
|
250
|
+
* winner to classify). `test/unit/contract/proven.test.ts` only requires
|
|
251
|
+
* registry coverage for selection.json ops + Bun built-ins.
|
|
252
|
+
*/
|
|
253
|
+
clearSchemaCache(): void
|
|
240
254
|
|
|
241
255
|
// ── Backend-framework scalar features ──
|
|
242
256
|
jwtSign(
|
package/src/rust-ffi/text.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
// napi fallback) — no inline `isBun()` / `getBunFFI()`.
|
|
15
15
|
|
|
16
16
|
import { decoder, encoder, hasNul } from '../shared/bytes'
|
|
17
|
-
import { type RustClientContext, resolveNative } from './context'
|
|
17
|
+
import { evictOldestHalf, mimeStrCache, type RustClientContext, resolveNative } from './context'
|
|
18
18
|
|
|
19
19
|
/**
|
|
20
20
|
* Scratch for the byte-path `Sec-WebSocket-Accept` fallback (28 chars; the
|
|
@@ -67,9 +67,9 @@ export function buildText(ctx: RustClientContext): RustText {
|
|
|
67
67
|
const { builtins, transport } = ctx.runtime
|
|
68
68
|
// String memo: the byte path already caches the native bytes; this avoids the
|
|
69
69
|
// encode → native lookup → slice → decode round-trip for repeated extension
|
|
70
|
-
// strings (the common case) — 0 allocations on a cache hit.
|
|
71
|
-
|
|
72
|
-
|
|
70
|
+
// strings (the common case) — 0 allocations on a cache hit. The map is
|
|
71
|
+
// process-wide and bounded by `evictOldestHalf` (MIME_CACHE_MAX) — see
|
|
72
|
+
// ./context.ts.
|
|
73
73
|
return {
|
|
74
74
|
mimeFromExtension(ext) {
|
|
75
75
|
let mime = mimeStrCache.get(ext)
|
|
@@ -81,6 +81,7 @@ export function buildText(ctx: RustClientContext): RustText {
|
|
|
81
81
|
mime = f
|
|
82
82
|
? (f.mimeFromExtension(ext) ?? 'application/octet-stream')
|
|
83
83
|
: decoder.decode(ctx.cachedMime(encoder.encode(ext)))
|
|
84
|
+
evictOldestHalf(mimeStrCache)
|
|
84
85
|
mimeStrCache.set(ext, mime)
|
|
85
86
|
}
|
|
86
87
|
return mime
|
|
@@ -181,6 +181,16 @@ export class BufferPool {
|
|
|
181
181
|
return this.created
|
|
182
182
|
}
|
|
183
183
|
|
|
184
|
+
/**
|
|
185
|
+
* Drop every retained free buffer. In-flight (borrowed) buffers are
|
|
186
|
+
* unaffected and return to an empty pool on `release()`, so the next
|
|
187
|
+
* `acquire()` allocates (or grows) as needed. Used by an explicit memory
|
|
188
|
+
* flush; the pool stays fully functional afterwards.
|
|
189
|
+
*/
|
|
190
|
+
shrink(): void {
|
|
191
|
+
this.free.length = 0
|
|
192
|
+
}
|
|
193
|
+
|
|
184
194
|
/**
|
|
185
195
|
* Acquire a buffer of at least `minSize` bytes. The returned handle must be
|
|
186
196
|
* released (directly, or via a zero-copy Response) once the caller is done.
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// src/shared/memory.ts — Public memory-flush escape hatch.
|
|
2
|
+
//
|
|
3
|
+
// Impure boundary module: it owns no state of its own but reaches the cache
|
|
4
|
+
// owners (the global loader LRU and the process-wide MIME caches) to drop their
|
|
5
|
+
// retained entries, then optionally requests a runtime GC. The metrics registry
|
|
6
|
+
// is deliberately NOT flushed — an observability registry is not a cache, and
|
|
7
|
+
// clearing it would erase the counters an operator is watching.
|
|
8
|
+
|
|
9
|
+
import { loader } from '../loader'
|
|
10
|
+
import { rust } from '../rust-ffi'
|
|
11
|
+
import { clearMimeCaches } from '../rust-ffi/context'
|
|
12
|
+
import { forceGc } from './runtime'
|
|
13
|
+
|
|
14
|
+
/** Options for {@link flushMemory}. */
|
|
15
|
+
export interface FlushMemoryOptions {
|
|
16
|
+
/**
|
|
17
|
+
* When true (the default), also request a full garbage collection from the
|
|
18
|
+
* runtime: `Bun.gc(true)` on Bun, `globalThis.gc?.()` on Node when the
|
|
19
|
+
* process runs with `--expose-gc`. Set false to flush caches only.
|
|
20
|
+
*/
|
|
21
|
+
gc?: boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Flush process-level caches and (by default) request a full GC. Intended for
|
|
26
|
+
* explicit memory-pressure handling — e.g. after a burst or before a snapshot.
|
|
27
|
+
*
|
|
28
|
+
* Clears:
|
|
29
|
+
* - the global loader LRU cache (`loader.clear()`),
|
|
30
|
+
* - the process-wide MIME caches (`clearMimeCaches()`),
|
|
31
|
+
* - the native compiled-schema cache (`rust.clearSchemaCache()`).
|
|
32
|
+
*
|
|
33
|
+
* The metrics registry is intentionally left untouched.
|
|
34
|
+
*
|
|
35
|
+
* Idempotent, and safe to call before the native addon has ever been loaded
|
|
36
|
+
* (every cache starts empty, the loader cache is a plain Map, and the native
|
|
37
|
+
* schema-cache clear is a defensive no-op when the addon/symbol is absent).
|
|
38
|
+
*
|
|
39
|
+
* @param options - `gc: false` skips the runtime GC request. Defaults to `{}`
|
|
40
|
+
* (GC requested).
|
|
41
|
+
* @example
|
|
42
|
+
* ```ts
|
|
43
|
+
* import { flushMemory } from 'castrum'
|
|
44
|
+
*
|
|
45
|
+
* flushMemory() // drop caches + request GC
|
|
46
|
+
* flushMemory({ gc: false }) // drop caches only
|
|
47
|
+
* ```
|
|
48
|
+
*/
|
|
49
|
+
export function flushMemory(options: FlushMemoryOptions = {}): void {
|
|
50
|
+
// Global loader LRU (createLoader() instances own their own caches and are
|
|
51
|
+
// not registered globally — flush the shared singleton only).
|
|
52
|
+
loader.clear()
|
|
53
|
+
|
|
54
|
+
// Process-wide MIME caches (bytes + string forms).
|
|
55
|
+
clearMimeCaches()
|
|
56
|
+
|
|
57
|
+
// Process-wide native compiled-schema cache. Defensive: an absent or stale
|
|
58
|
+
// addon (no `clearSchemaCache` symbol) must be a no-op, never a throw.
|
|
59
|
+
try {
|
|
60
|
+
rust.clearSchemaCache()
|
|
61
|
+
} catch {
|
|
62
|
+
// addon not loaded / stale build — nothing to clear.
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (options.gc !== false) {
|
|
66
|
+
forceGc()
|
|
67
|
+
}
|
|
68
|
+
}
|
package/src/shared/metrics.ts
CHANGED
|
@@ -12,6 +12,27 @@ export const DEFAULT_BUCKETS: readonly number[] = [
|
|
|
12
12
|
0.001, 0.0025, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10,
|
|
13
13
|
]
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Per-family cap on the number of distinct label sets (series) retained by a
|
|
17
|
+
* single counter/gauge/histogram. A metrics registry is keyed by caller-supplied
|
|
18
|
+
* label values, so an unbounded label (e.g. a raw path or user id) would grow a
|
|
19
|
+
* `Map` without limit. At the cap the oldest half of that family is evicted
|
|
20
|
+
* (insertion order) and the count is accumulated into the synthetic counter
|
|
21
|
+
* `castrum_metrics_series_dropped_total`, making the loss observable instead of
|
|
22
|
+
* silent. That name is RESERVED — a caller declaring a counter/gauge/histogram
|
|
23
|
+
* with it suppresses the synthetic emission (see {@link SERIES_DROPPED_METRIC}).
|
|
24
|
+
*/
|
|
25
|
+
export const MAX_SERIES_PER_METRIC = 10_000
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Reserved exposition name of the synthetic counter reporting series evicted by
|
|
29
|
+
* {@link MAX_SERIES_PER_METRIC}. Callers must not register a metric family with
|
|
30
|
+
* this name; if one exists, `render()` skips the synthetic emission so the
|
|
31
|
+
* scrape never carries two `# HELP`/`# TYPE` lines for one family (Prometheus
|
|
32
|
+
* rejects that).
|
|
33
|
+
*/
|
|
34
|
+
const SERIES_DROPPED_METRIC = 'castrum_metrics_series_dropped_total'
|
|
35
|
+
|
|
15
36
|
type LabelValues = readonly string[]
|
|
16
37
|
type Labels = Readonly<Record<string, string>>
|
|
17
38
|
|
|
@@ -29,6 +50,24 @@ function escapeLabel(v: string): string {
|
|
|
29
50
|
return v.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n')
|
|
30
51
|
}
|
|
31
52
|
|
|
53
|
+
/**
|
|
54
|
+
* Once a series map is at {@link MAX_SERIES_PER_METRIC}, evict its oldest half
|
|
55
|
+
* (insertion order) and return the keys dropped; returns `[]` below the cap.
|
|
56
|
+
* Mirrors Elysia's `evictOldestHalf` generational policy: halving at the cap
|
|
57
|
+
* amortizes the eviction cost instead of paying a delete per insertion.
|
|
58
|
+
*/
|
|
59
|
+
function evictOldestHalf<K, V>(map: Map<K, V>): K[] {
|
|
60
|
+
if (map.size < MAX_SERIES_PER_METRIC) return []
|
|
61
|
+
const drop = Math.ceil(map.size / 2)
|
|
62
|
+
const dropped: K[] = []
|
|
63
|
+
for (const key of map.keys()) {
|
|
64
|
+
if (dropped.length >= drop) break
|
|
65
|
+
dropped.push(key)
|
|
66
|
+
}
|
|
67
|
+
for (const key of dropped) map.delete(key)
|
|
68
|
+
return dropped
|
|
69
|
+
}
|
|
70
|
+
|
|
32
71
|
/** A single labelled counter value. */
|
|
33
72
|
export interface Counter {
|
|
34
73
|
/** Increment by 1 (or `by`). `labels` must match the label names exactly. */
|
|
@@ -97,6 +136,8 @@ export function createMetrics(): MetricsRegistry {
|
|
|
97
136
|
const counters = new Map<string, CounterEntry>()
|
|
98
137
|
const gauges = new Map<string, GaugeEntry>()
|
|
99
138
|
const histograms = new Map<string, HistogramEntry>()
|
|
139
|
+
/** Series evicted by the per-family cardinality cap, rendered as a counter. */
|
|
140
|
+
let seriesDropped = 0
|
|
100
141
|
|
|
101
142
|
const bucketUpper = (buckets: number[]): number[] => [
|
|
102
143
|
...buckets.map((b) => b),
|
|
@@ -144,6 +185,22 @@ export function createMetrics(): MetricsRegistry {
|
|
|
144
185
|
for (const e of counters.values()) out.push(...renderCounter(e))
|
|
145
186
|
for (const e of gauges.values()) out.push(...renderGauge(e))
|
|
146
187
|
for (const e of histograms.values()) out.push(...renderHistogram(e))
|
|
188
|
+
// Only emitted once a family has actually overflowed, so the output for a
|
|
189
|
+
// well-behaved registry is byte-identical to before the cap existed. Guard
|
|
190
|
+
// against a caller-registered family of the same (reserved) name — two
|
|
191
|
+
// `# HELP`/`# TYPE` lines for one family make the scrape invalid.
|
|
192
|
+
if (
|
|
193
|
+
seriesDropped > 0 &&
|
|
194
|
+
!counters.has(SERIES_DROPPED_METRIC) &&
|
|
195
|
+
!gauges.has(SERIES_DROPPED_METRIC) &&
|
|
196
|
+
!histograms.has(SERIES_DROPPED_METRIC)
|
|
197
|
+
) {
|
|
198
|
+
out.push(
|
|
199
|
+
`# HELP ${SERIES_DROPPED_METRIC} Total metric series evicted by the per-family cardinality cap.`,
|
|
200
|
+
)
|
|
201
|
+
out.push(`# TYPE ${SERIES_DROPPED_METRIC} counter`)
|
|
202
|
+
out.push(`${SERIES_DROPPED_METRIC} ${seriesDropped}`)
|
|
203
|
+
}
|
|
147
204
|
return `${out.join('\n')}\n`
|
|
148
205
|
}
|
|
149
206
|
|
|
@@ -151,6 +208,7 @@ export function createMetrics(): MetricsRegistry {
|
|
|
151
208
|
counters.clear()
|
|
152
209
|
gauges.clear()
|
|
153
210
|
histograms.clear()
|
|
211
|
+
seriesDropped = 0
|
|
154
212
|
}
|
|
155
213
|
|
|
156
214
|
return {
|
|
@@ -172,7 +230,14 @@ export function createMetrics(): MetricsRegistry {
|
|
|
172
230
|
names,
|
|
173
231
|
names.map((n) => labels?.[n] ?? ''),
|
|
174
232
|
)
|
|
175
|
-
|
|
233
|
+
const prev = entry.values.get(key)
|
|
234
|
+
if (prev === undefined) {
|
|
235
|
+
// New series: make room first (bounded family) and count evictions.
|
|
236
|
+
seriesDropped += evictOldestHalf(entry.values).length
|
|
237
|
+
entry.values.set(key, by)
|
|
238
|
+
} else {
|
|
239
|
+
entry.values.set(key, prev + by)
|
|
240
|
+
}
|
|
176
241
|
},
|
|
177
242
|
}
|
|
178
243
|
},
|
|
@@ -192,14 +257,27 @@ export function createMetrics(): MetricsRegistry {
|
|
|
192
257
|
names,
|
|
193
258
|
names.map((n) => labels?.[n] ?? ''),
|
|
194
259
|
)
|
|
260
|
+
const add = (labels: Labels | undefined, delta: number): void => {
|
|
261
|
+
const key = keyOf(labels)
|
|
262
|
+
const prev = entry.values.get(key)
|
|
263
|
+
if (prev === undefined) {
|
|
264
|
+
seriesDropped += evictOldestHalf(entry.values).length
|
|
265
|
+
entry.values.set(key, delta)
|
|
266
|
+
} else {
|
|
267
|
+
entry.values.set(key, prev + delta)
|
|
268
|
+
}
|
|
269
|
+
}
|
|
195
270
|
return {
|
|
196
271
|
inc(labels, by = 1) {
|
|
197
|
-
|
|
272
|
+
add(labels, by)
|
|
198
273
|
},
|
|
199
274
|
dec(labels, by = 1) {
|
|
200
|
-
|
|
275
|
+
add(labels, -by)
|
|
201
276
|
},
|
|
202
277
|
set(labels, value) {
|
|
278
|
+
if (!entry.values.has(keyOf(labels))) {
|
|
279
|
+
seriesDropped += evictOldestHalf(entry.values).length
|
|
280
|
+
}
|
|
203
281
|
entry.values.set(keyOf(labels), value)
|
|
204
282
|
},
|
|
205
283
|
}
|
|
@@ -225,6 +303,11 @@ export function createMetrics(): MetricsRegistry {
|
|
|
225
303
|
)
|
|
226
304
|
let counts = entry.counts.get(key)
|
|
227
305
|
if (!counts) {
|
|
306
|
+
// New series: bound the family and drop the matching sum entries
|
|
307
|
+
// (counts and sums always share the same key set).
|
|
308
|
+
const dropped = evictOldestHalf(entry.counts)
|
|
309
|
+
for (const k of dropped) entry.sums.delete(k)
|
|
310
|
+
seriesDropped += dropped.length
|
|
228
311
|
// One slot per bucket PLUS the implicit +Inf slot.
|
|
229
312
|
counts = new Array<number>(entry.buckets.length + 1).fill(0)
|
|
230
313
|
entry.counts.set(key, counts)
|
package/src/shared/runtime.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import {
|
|
9
9
|
detectedRuntime,
|
|
10
|
+
forceGc as forceGcDetected,
|
|
10
11
|
isBunRuntime,
|
|
11
12
|
isNodeRuntime,
|
|
12
13
|
nodeMajorVersion as nodeMajorVersionDetected,
|
|
@@ -27,3 +28,9 @@ export const runtimeName: () => RuntimeName = () => detectedRuntime
|
|
|
27
28
|
|
|
28
29
|
/** The Node.js major version when running under Node, otherwise `null`. */
|
|
29
30
|
export const nodeMajorVersion: () => number | null = nodeMajorVersionDetected
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Request a synchronous full GC when the runtime exposes one (`Bun.gc(true)` on
|
|
34
|
+
* Bun, `globalThis.gc?.()` under Node `--expose-gc`). A no-op otherwise.
|
|
35
|
+
*/
|
|
36
|
+
export const forceGc: () => void = forceGcDetected
|
package/src/task/runtime.ts
CHANGED
|
@@ -152,6 +152,37 @@ function cancelledError(): Error {
|
|
|
152
152
|
return err
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
+
/** Native `taskSubmit*` return: the task was accepted onto the pool. */
|
|
156
|
+
const TASK_SUBMIT_ACCEPTED = 1
|
|
157
|
+
/** Native `taskSubmit*` return: the bounded work queue is full (overload). */
|
|
158
|
+
const TASK_SUBMIT_OVERLOADED = 2
|
|
159
|
+
|
|
160
|
+
/** Error raised when the bounded native task queue is at its admission bound. */
|
|
161
|
+
interface TaskOverloadedError extends Error {
|
|
162
|
+
/** Stable discriminant so callers can branch on overload. */
|
|
163
|
+
code: 'OVERLOADED'
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Map a native `taskSubmit*` return code to the reason a task promise should
|
|
168
|
+
* reject with, or `null` when the submission was accepted. Pure and exported
|
|
169
|
+
* for tests, so the rejection mapping can be checked without driving the native
|
|
170
|
+
* pool into a real overload.
|
|
171
|
+
*
|
|
172
|
+
* @param code Native submit return: `1` accepted, `2` overloaded, else rejected.
|
|
173
|
+
* @returns The typed overload error, the generic rejection, or `null`.
|
|
174
|
+
*/
|
|
175
|
+
export function taskSubmitError(code: number): Error | null {
|
|
176
|
+
if (code === TASK_SUBMIT_ACCEPTED) return null
|
|
177
|
+
if (code === TASK_SUBMIT_OVERLOADED) {
|
|
178
|
+
const err = new Error('task rejected: native work queue is full') as TaskOverloadedError
|
|
179
|
+
err.name = 'TaskOverloadedError'
|
|
180
|
+
err.code = 'OVERLOADED'
|
|
181
|
+
return err
|
|
182
|
+
}
|
|
183
|
+
return new Error('task rejected by the native runtime')
|
|
184
|
+
}
|
|
185
|
+
|
|
155
186
|
/** Bunny-shaped doorbell handle (structural, so it needs no bun-types dep). */
|
|
156
187
|
interface Doorbell {
|
|
157
188
|
ptr: number | null
|
|
@@ -361,10 +392,14 @@ export function createTaskRuntime(options: TaskRuntimeOptions = {}): TaskRuntime
|
|
|
361
392
|
return new Promise<Uint8Array>((resolve, reject) => {
|
|
362
393
|
inflight.set(id, { out: null, keep: null, resolve, reject })
|
|
363
394
|
retain()
|
|
364
|
-
|
|
395
|
+
const rejected = taskSubmitError(ffi.taskSubmit(op, args, id))
|
|
396
|
+
if (rejected) {
|
|
365
397
|
inflight.delete(id)
|
|
366
|
-
|
|
367
|
-
|
|
398
|
+
// Release the keep-alive only when this was the last in-flight task.
|
|
399
|
+
// An overload/invalid rejection must not tear down the interval (and
|
|
400
|
+
// its polling safety net) while other tasks are still pending.
|
|
401
|
+
if (inflight.size === 0) release()
|
|
402
|
+
reject(rejected)
|
|
368
403
|
return
|
|
369
404
|
}
|
|
370
405
|
const signal = runOptions?.signal
|
|
@@ -394,10 +429,14 @@ export function createTaskRuntime(options: TaskRuntimeOptions = {}): TaskRuntime
|
|
|
394
429
|
return new Promise<Uint8Array>((resolve, reject) => {
|
|
395
430
|
inflight.set(id, { out: output, keep: null, resolve, reject })
|
|
396
431
|
retain()
|
|
397
|
-
|
|
432
|
+
const rejected = taskSubmitError(ffi.taskSubmitOut(op, args, id, output))
|
|
433
|
+
if (rejected) {
|
|
398
434
|
inflight.delete(id)
|
|
399
|
-
|
|
400
|
-
|
|
435
|
+
// Release the keep-alive only when this was the last in-flight task.
|
|
436
|
+
// An overload/invalid rejection must not tear down the interval (and
|
|
437
|
+
// its polling safety net) while other tasks are still pending.
|
|
438
|
+
if (inflight.size === 0) release()
|
|
439
|
+
reject(rejected)
|
|
401
440
|
return
|
|
402
441
|
}
|
|
403
442
|
const signal = runOptions?.signal
|
|
@@ -428,10 +467,14 @@ export function createTaskRuntime(options: TaskRuntimeOptions = {}): TaskRuntime
|
|
|
428
467
|
return new Promise<Uint8Array>((resolve, reject) => {
|
|
429
468
|
inflight.set(id, { out: null, keep: data, resolve, reject })
|
|
430
469
|
retain()
|
|
431
|
-
|
|
470
|
+
const rejected = taskSubmitError(ffi.taskSubmitSlice(op, hdr, data, id))
|
|
471
|
+
if (rejected) {
|
|
432
472
|
inflight.delete(id)
|
|
433
|
-
|
|
434
|
-
|
|
473
|
+
// Release the keep-alive only when this was the last in-flight task.
|
|
474
|
+
// An overload/invalid rejection must not tear down the interval (and
|
|
475
|
+
// its polling safety net) while other tasks are still pending.
|
|
476
|
+
if (inflight.size === 0) release()
|
|
477
|
+
reject(rejected)
|
|
435
478
|
return
|
|
436
479
|
}
|
|
437
480
|
const signal = runOptions?.signal
|