castrum 0.9.8 → 0.9.10
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 +11 -1
- package/Cargo.toml +17 -3
- 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/bun-builtins-decision-matrix.md +17 -7
- 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/crypto/base64.rs +472 -57
- package/rust/crypto/hashing.rs +68 -0
- 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/payload/sse.rs +78 -5
- package/rust/task/mod.rs +1 -0
- package/rust/task/runtime.rs +55 -7
- package/rust/task/tests.rs +98 -1
- package/rust/util/text.rs +112 -10
- package/rust/util/validation.rs +34 -0
- 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/rust/crypto/hashing.rs
CHANGED
|
@@ -137,4 +137,72 @@ mod tests {
|
|
|
137
137
|
xxh3(Uint8Array::new(input.to_vec()))
|
|
138
138
|
);
|
|
139
139
|
}
|
|
140
|
+
|
|
141
|
+
/// Known-value xxh3-64 vectors that LOCK the spec output across both the
|
|
142
|
+
/// short-input (< 240 B) and long-input (>= 240 B) regimes of the engine.
|
|
143
|
+
/// Generated with `Bun.hash.xxHash3` (independent spec-compliant
|
|
144
|
+
/// implementation). The 240/241 pair guards the engine's internal
|
|
145
|
+
/// threshold: an implementation or length-handling regression that
|
|
146
|
+
/// diverges only above it fails here even though small-input tests pass.
|
|
147
|
+
/// (Perf note: the scalar core is FLAT ~30GB/s 64B→1MB — measured, no
|
|
148
|
+
/// size pathology; see docs/bun-builtins-decision-matrix.md for why Bun
|
|
149
|
+
/// still wins the consumer under Bun.)
|
|
150
|
+
#[test]
|
|
151
|
+
fn xxh3_vectors_lock_spec_across_simd_threshold() {
|
|
152
|
+
// input: (i * 31 + 5) % 256 for i in 0..n (deterministic)
|
|
153
|
+
const V: &[(usize, u64)] = &[
|
|
154
|
+
(0, 0x2d06_8005_38d3_94c2),
|
|
155
|
+
(1, 0x929e_358d_27ae_3ee2),
|
|
156
|
+
(2, 0xf8a9_b018_ddb0_dc1a),
|
|
157
|
+
(3, 0xea4f_94f7_8c69_f54e),
|
|
158
|
+
(4, 0xc674_aba0_6059_22b7),
|
|
159
|
+
(5, 0x988e_18b6_a130_77ac),
|
|
160
|
+
(6, 0x19e7_8a37_d9eb_8365),
|
|
161
|
+
(7, 0x4d32_b9b4_d45d_9319),
|
|
162
|
+
(8, 0xad43_7729_e900_e521),
|
|
163
|
+
(9, 0x2f54_64ee_70cc_cff9),
|
|
164
|
+
(10, 0x97a1_114c_1cbf_45ae),
|
|
165
|
+
(11, 0x2933_959d_e03b_8ce8),
|
|
166
|
+
(12, 0x5cba_0687_e71c_0f12),
|
|
167
|
+
(13, 0x5a27_1d93_20eb_cb8a),
|
|
168
|
+
(14, 0xeb81_cf37_8320_b598),
|
|
169
|
+
(15, 0xec7b_ee5b_893b_d5d5),
|
|
170
|
+
(16, 0x0903_f58f_8225_3d8c),
|
|
171
|
+
(17, 0x48a3_8615_d4ee_fb8b),
|
|
172
|
+
(18, 0xb871_d958_cc11_dfa4),
|
|
173
|
+
(19, 0xc6ed_7037_b89b_d206),
|
|
174
|
+
(23, 0xd410_62e4_0171_ac46),
|
|
175
|
+
(31, 0x4334_b263_9627_f197),
|
|
176
|
+
(32, 0xebeb_3ba5_7ab1_edca),
|
|
177
|
+
(33, 0x7ff1_7572_bd9e_70a9),
|
|
178
|
+
(63, 0x504d_3e53_12aa_2db1),
|
|
179
|
+
(64, 0xd8cc_034e_d06e_92f3),
|
|
180
|
+
(65, 0x73c9_73e2_4eda_1cdc),
|
|
181
|
+
(95, 0x45f6_3f33_cade_8c19),
|
|
182
|
+
(96, 0x4c46_e758_08f2_74e9),
|
|
183
|
+
(127, 0x1ed1_3eff_d01b_9402),
|
|
184
|
+
(128, 0x05a1_567d_530f_961c),
|
|
185
|
+
(191, 0xba5e_7dfd_d63d_7785),
|
|
186
|
+
(192, 0x999d_b9f2_4d8c_ffcf),
|
|
187
|
+
(238, 0xda80_d2d5_c7b9_397e),
|
|
188
|
+
(239, 0xb0ca_9389_9a38_51d5),
|
|
189
|
+
(240, 0xc502_31c4_c4c8_6a63),
|
|
190
|
+
(241, 0xa7dc_042e_5a9f_6aa6),
|
|
191
|
+
(255, 0x62c8_1de1_9704_658e),
|
|
192
|
+
(256, 0x84f8_cedc_d725_6d1a),
|
|
193
|
+
(512, 0xd4d7_d053_e788_d9ef),
|
|
194
|
+
(1024, 0xdb4a_2f32_c2a5_bc38),
|
|
195
|
+
(16384, 0xf04a_c437_8b21_4439),
|
|
196
|
+
(262144, 0x6c36_c9dc_9aaf_20f0),
|
|
197
|
+
];
|
|
198
|
+
let input =
|
|
199
|
+
|n: usize| -> Vec<u8> { (0..n).map(|i| (i.wrapping_mul(31) + 5) as u8).collect() };
|
|
200
|
+
for (n, want) in V {
|
|
201
|
+
assert_eq!(
|
|
202
|
+
fast_hash_bytes(&input(*n)),
|
|
203
|
+
*want,
|
|
204
|
+
"xxh3({n} bytes) — engine regime mismatch",
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
140
208
|
}
|
package/rust/ffi/task.rs
CHANGED
|
@@ -18,7 +18,8 @@ pub extern "C" fn castrum_task_init(threads: u32) -> u32 {
|
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
/// Submit `op` with `args_len` packed arg bytes, completing under `task_id`.
|
|
21
|
-
/// Returns `1` accepted, `
|
|
21
|
+
/// Returns `1` accepted, `2` overloaded (the bounded work queue is full), or
|
|
22
|
+
/// `0` invalid args (null args with a non-zero length) / panic.
|
|
22
23
|
///
|
|
23
24
|
/// # Safety
|
|
24
25
|
/// `args` must be valid for reads of `args_len` bytes.
|
|
@@ -39,7 +40,11 @@ pub unsafe extern "C" fn castrum_task_submit(
|
|
|
39
40
|
} else {
|
|
40
41
|
slice::from_raw_parts(args, args_len).to_vec()
|
|
41
42
|
};
|
|
42
|
-
|
|
43
|
+
if task::submit_op(op, owned, task_id as u64) {
|
|
44
|
+
1
|
|
45
|
+
} else {
|
|
46
|
+
2
|
|
47
|
+
}
|
|
43
48
|
},
|
|
44
49
|
0,
|
|
45
50
|
)
|
|
@@ -72,7 +77,7 @@ pub extern "C" fn castrum_task_pending() -> u32 {
|
|
|
72
77
|
/// (UNSUPPORTED) means the op has no `_into` form and the caller should fall
|
|
73
78
|
/// back to `castrum_task_submit`.
|
|
74
79
|
///
|
|
75
|
-
/// Returns `1` accepted, `0`
|
|
80
|
+
/// Returns `1` accepted, `2` overloaded (bounded queue full), `0` invalid args.
|
|
76
81
|
///
|
|
77
82
|
/// # Safety
|
|
78
83
|
/// `args` must be valid for reads of `args_len` bytes. `out` must be valid for
|
|
@@ -97,13 +102,11 @@ pub unsafe extern "C" fn castrum_task_submit_out(
|
|
|
97
102
|
} else {
|
|
98
103
|
slice::from_raw_parts(args, args_len).to_vec()
|
|
99
104
|
};
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
out_cap,
|
|
106
|
-
))
|
|
105
|
+
if task::submit_op_into(op, owned, task_id as u64, out as usize, out_cap) {
|
|
106
|
+
1
|
|
107
|
+
} else {
|
|
108
|
+
2
|
|
109
|
+
}
|
|
107
110
|
},
|
|
108
111
|
0,
|
|
109
112
|
)
|
|
@@ -121,7 +124,7 @@ pub extern "C" fn castrum_task_cancel(task_id: usize) -> u32 {
|
|
|
121
124
|
/// compression costs a full-size copy on the JS thread and measured WORSE than
|
|
122
125
|
/// the synchronous built-in (docs/RND-CONCURRENCY.md §7b).
|
|
123
126
|
///
|
|
124
|
-
/// Returns `1` accepted, `0`
|
|
127
|
+
/// Returns `1` accepted, `2` overloaded (bounded queue full), `0` invalid args.
|
|
125
128
|
///
|
|
126
129
|
/// # Safety
|
|
127
130
|
/// `hdr` must be valid for reads of `hdr_len` bytes. `data` must be valid for
|
|
@@ -148,13 +151,11 @@ pub unsafe extern "C" fn castrum_task_submit_slice(
|
|
|
148
151
|
} else {
|
|
149
152
|
slice::from_raw_parts(hdr, hdr_len).to_vec()
|
|
150
153
|
};
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
task_id as u64,
|
|
157
|
-
))
|
|
154
|
+
if task::submit_slice_op(op, owned, data as usize, data_len, task_id as u64) {
|
|
155
|
+
1
|
|
156
|
+
} else {
|
|
157
|
+
2
|
|
158
|
+
}
|
|
158
159
|
},
|
|
159
160
|
0,
|
|
160
161
|
)
|
package/rust/ingress/api.rs
CHANGED
|
@@ -85,11 +85,13 @@ impl Ingress {
|
|
|
85
85
|
.map_err(|e| Error::new(Status::InvalidArg, format!("Schema JSON error: {}", e)))?;
|
|
86
86
|
// Compile BOTH the authoritative jsonschema validator and the
|
|
87
87
|
// zero-DOM fast path once at construction — no per-request schema
|
|
88
|
-
// work (see IngressSchema in pipeline.rs).
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
88
|
+
// work (see IngressSchema in pipeline.rs). Identical schema bytes
|
|
89
|
+
// are shared process-wide via the compiled-schema cache.
|
|
90
|
+
let compiled =
|
|
91
|
+
crate::ingress::schema_cache::get_or_compile(&schema_value).map_err(|e| {
|
|
92
|
+
Error::new(Status::InvalidArg, format!("Schema compile error: {}", e))
|
|
93
|
+
})?;
|
|
94
|
+
Some(compiled)
|
|
93
95
|
} else {
|
|
94
96
|
None
|
|
95
97
|
};
|
|
@@ -353,3 +355,17 @@ impl Ingress {
|
|
|
353
355
|
inner.handle_packed(&packed, body, out)
|
|
354
356
|
}
|
|
355
357
|
}
|
|
358
|
+
|
|
359
|
+
/// Clear the process-wide compiled-schema cache — the dedupe store that shares
|
|
360
|
+
/// one compiled `IngressSchema` across every `Ingress`/`NativeRoute` built from
|
|
361
|
+
/// identical schema bytes.
|
|
362
|
+
///
|
|
363
|
+
/// Maintenance hook for the public `flushMemory()` (napi export name:
|
|
364
|
+
/// `clearSchemaCache`). Idempotent; safe to call at any time, including before
|
|
365
|
+
/// any schema has ever been compiled. Dropping the cache only releases the
|
|
366
|
+
/// native validators — already-constructed ingress/route instances keep their
|
|
367
|
+
/// own `Arc<IngressSchema>`, so live routes are unaffected.
|
|
368
|
+
#[napi]
|
|
369
|
+
pub fn clear_schema_cache() {
|
|
370
|
+
crate::ingress::schema_cache::clear_schema_cache();
|
|
371
|
+
}
|
package/rust/ingress/mod.rs
CHANGED
|
@@ -22,6 +22,9 @@ mod tests;
|
|
|
22
22
|
|
|
23
23
|
pub(crate) mod options;
|
|
24
24
|
pub(crate) mod packed;
|
|
25
|
+
// Process-wide compiled-schema cache: dedupes identical `IngressSchema`
|
|
26
|
+
// compiles across `Ingress` / `NativeRoute` instances (schema_cache.rs).
|
|
27
|
+
pub(crate) mod schema_cache;
|
|
25
28
|
pub(crate) mod time;
|
|
26
29
|
|
|
27
30
|
pub(crate) mod cors;
|
|
@@ -40,6 +43,10 @@ pub(crate) mod terminal;
|
|
|
40
43
|
#[cfg(test)]
|
|
41
44
|
pub(crate) use self::api::clamp_output_size;
|
|
42
45
|
pub use self::api::Ingress;
|
|
46
|
+
// napi maintenance export (`clearSchemaCache`) — re-exported so it is a
|
|
47
|
+
// crate-reachable item (dead-code analysis) while the `#[napi]` macro does the
|
|
48
|
+
// JS-side registration.
|
|
49
|
+
pub use self::api::clear_schema_cache;
|
|
43
50
|
pub(crate) use self::api::IngressInner;
|
|
44
51
|
pub(crate) use self::native_route::NativeRoute;
|
|
45
52
|
pub(crate) use self::options::{IngressOptions, Limits};
|
|
@@ -177,9 +177,11 @@ impl NativeRoute {
|
|
|
177
177
|
.map_err(|_| "route descriptor: body schema is not valid UTF-8".to_string())?;
|
|
178
178
|
let schema_value: serde_json::Value = sonic_rs::from_str(schema_str)
|
|
179
179
|
.map_err(|e| format!("route descriptor: body schema JSON error: {e}"))?;
|
|
180
|
-
|
|
180
|
+
// Shared process-wide: identical schema bytes compile once even
|
|
181
|
+
// across routes/instances (see ingress/schema_cache.rs).
|
|
182
|
+
let compiled = super::schema_cache::get_or_compile(&schema_value)
|
|
181
183
|
.map_err(|e| format!("route descriptor: body schema compile error: {e}"))?;
|
|
182
|
-
Some(
|
|
184
|
+
Some(compiled)
|
|
183
185
|
}
|
|
184
186
|
None => None,
|
|
185
187
|
};
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
// rust/ingress/schema_cache.rs — Process-wide compiled-schema cache.
|
|
2
|
+
//
|
|
3
|
+
// `Ingress` and `NativeRoute` compile a request-body schema once at
|
|
4
|
+
// construction (`IngressSchema::compile`). When an application builds N routes
|
|
5
|
+
// over the SAME schema bytes, that is N identical compiles at startup and N
|
|
6
|
+
// copies retained at runtime. This module dedupes them: identical schema JSON
|
|
7
|
+
// yields ONE shared `Arc<IngressSchema>`, bounded by an LRU (default 128
|
|
8
|
+
// entries, override with `CASTRUM_SCHEMA_CACHE_MAX`).
|
|
9
|
+
//
|
|
10
|
+
// Key = a stable 64-bit hash of the raw serialized schema bytes (exact bytes:
|
|
11
|
+
// a different key order is a different key and simply compiles a second time).
|
|
12
|
+
// The hash only selects a bucket — it is NOT the identity: each entry stores
|
|
13
|
+
// its serialized bytes alongside the compiled schema, and a hit is returned
|
|
14
|
+
// only when those bytes EQUAL the requested bytes. `xxh3_64` is
|
|
15
|
+
// non-cryptographic, so trusting the hash alone would let a craftable collision
|
|
16
|
+
// reuse the wrong validator (a validation-bypass class bug); the byte compare
|
|
17
|
+
// makes collisions a safe miss (compile + replace the colliding bucket) rather
|
|
18
|
+
// than a wrong-schema hit.
|
|
19
|
+
//
|
|
20
|
+
// Only SUCCESSFUL compiles are cached. `IngressSchema` is immutable after
|
|
21
|
+
// compile (`Arc<jsonschema::Validator>` + optional `Arc<FastNode>`), so sharing
|
|
22
|
+
// the `Arc` across routes and threads is sound; mutable state is per-call
|
|
23
|
+
// (`Cursor`/`Ctx`), never on the schema.
|
|
24
|
+
//
|
|
25
|
+
// Concurrency: double-checked locking — the cache lock is released before the
|
|
26
|
+
// (slow) compile and re-taken for insert, so one slow compile never serializes
|
|
27
|
+
// other schema lookups.
|
|
28
|
+
|
|
29
|
+
use std::num::NonZeroUsize;
|
|
30
|
+
use std::sync::{Arc, OnceLock};
|
|
31
|
+
|
|
32
|
+
use lru::LruCache;
|
|
33
|
+
use parking_lot::Mutex;
|
|
34
|
+
|
|
35
|
+
use super::pipeline::IngressSchema;
|
|
36
|
+
|
|
37
|
+
/// Default cache capacity (distinct schema byte strings).
|
|
38
|
+
const DEFAULT_SCHEMA_CACHE_MAX: usize = 128;
|
|
39
|
+
/// Upper clamp on the configured capacity — an absurd env value must not size
|
|
40
|
+
/// an unbounded LRU (each entry retains a compiled validator + its bytes).
|
|
41
|
+
const MAX_SCHEMA_CACHE_MAX: usize = 4096;
|
|
42
|
+
|
|
43
|
+
/// Cache entry: the exact serialized schema bytes (collision disambiguation)
|
|
44
|
+
/// plus the shared compiled schema.
|
|
45
|
+
type CacheEntry = (Vec<u8>, Arc<IngressSchema>);
|
|
46
|
+
|
|
47
|
+
/// Cache map: schema-hash → (schema bytes, shared compiled schema).
|
|
48
|
+
type SchemaMap = Mutex<LruCache<u64, CacheEntry>>;
|
|
49
|
+
|
|
50
|
+
/// Process-wide compiled-schema cache (lazily initialized on first compile).
|
|
51
|
+
static SCHEMA_CACHE: OnceLock<SchemaMap> = OnceLock::new();
|
|
52
|
+
|
|
53
|
+
/// Resolve a capacity from the raw env value: unset/unparseable/zero →
|
|
54
|
+
/// [`DEFAULT_SCHEMA_CACHE_MAX`], otherwise clamped to
|
|
55
|
+
/// `1..=MAX_SCHEMA_CACHE_MAX` (an LRU needs a nonzero capacity).
|
|
56
|
+
fn resolve_cap(raw: Option<&str>) -> usize {
|
|
57
|
+
raw.and_then(|v| v.trim().parse::<usize>().ok())
|
|
58
|
+
.filter(|n| *n > 0)
|
|
59
|
+
.map(|n| n.min(MAX_SCHEMA_CACHE_MAX))
|
|
60
|
+
.unwrap_or(DEFAULT_SCHEMA_CACHE_MAX)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/// Resolve the configured cache capacity (`CASTRUM_SCHEMA_CACHE_MAX`).
|
|
64
|
+
fn cache_cap() -> usize {
|
|
65
|
+
resolve_cap(std::env::var("CASTRUM_SCHEMA_CACHE_MAX").ok().as_deref())
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/// Hash the serialized schema bytes with the crate's XXH3-64 helper (stable and
|
|
69
|
+
/// well-distributed). The exact byte string is the identity: serialization
|
|
70
|
+
/// preserves key insertion order (`serde_json` is built with `preserve_order`),
|
|
71
|
+
/// so whitespace/key-order differences compile separately rather than collide.
|
|
72
|
+
#[inline]
|
|
73
|
+
fn schema_key(bytes: &[u8]) -> u64 {
|
|
74
|
+
crate::crypto::hashing::fast_hash_bytes(bytes)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/// Look up (or compile and insert) the shared `IngressSchema` for
|
|
78
|
+
/// `schema_value`. Only successes are cached; a compile error is returned
|
|
79
|
+
/// verbatim (identical message to a direct `IngressSchema::compile`).
|
|
80
|
+
fn get_or_compile_in(
|
|
81
|
+
cache: &SchemaMap,
|
|
82
|
+
schema_value: &serde_json::Value,
|
|
83
|
+
) -> std::result::Result<Arc<IngressSchema>, String> {
|
|
84
|
+
let bytes = serde_json::to_vec(schema_value).map_err(|e| e.to_string())?;
|
|
85
|
+
let key = schema_key(&bytes);
|
|
86
|
+
|
|
87
|
+
// Fast path: a hash hit is only trusted when the bytes MATCH (a colliding
|
|
88
|
+
// hash falls through to a real compile, never a wrong-schema hit).
|
|
89
|
+
if let Some((stored, existing)) = cache.lock().get(&key) {
|
|
90
|
+
if stored.as_slice() == bytes.as_slice() {
|
|
91
|
+
return Ok(Arc::clone(existing));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Miss: compile WITHOUT holding the lock (a slow compile must not block
|
|
96
|
+
// other schema lookups/inserts).
|
|
97
|
+
let compiled = Arc::new(IngressSchema::compile(schema_value)?);
|
|
98
|
+
|
|
99
|
+
// Re-check under the lock: a concurrent thread may have compiled the same
|
|
100
|
+
// schema while we were outside it — prefer the first inserter's Arc (same
|
|
101
|
+
// bytes) so all callers share one instance. A colliding entry with
|
|
102
|
+
// different bytes is REPLACED (the bucket holds exactly one schema).
|
|
103
|
+
let mut guard = cache.lock();
|
|
104
|
+
if let Some((stored, existing)) = guard.get(&key) {
|
|
105
|
+
if stored.as_slice() == bytes.as_slice() {
|
|
106
|
+
return Ok(Arc::clone(existing));
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
let _ = guard.put(key, (bytes, Arc::clone(&compiled)));
|
|
110
|
+
drop(guard);
|
|
111
|
+
Ok(compiled)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/// Return the shared compiled schema for `schema_value`, compiling it on first
|
|
115
|
+
/// use. Errors are NOT cached (a fixed schema compiles on the next attempt).
|
|
116
|
+
pub(crate) fn get_or_compile(
|
|
117
|
+
schema_value: &serde_json::Value,
|
|
118
|
+
) -> std::result::Result<Arc<IngressSchema>, String> {
|
|
119
|
+
let cache = SCHEMA_CACHE.get_or_init(|| {
|
|
120
|
+
Mutex::new(LruCache::new(
|
|
121
|
+
NonZeroUsize::new(cache_cap()).expect("cache cap is nonzero"),
|
|
122
|
+
))
|
|
123
|
+
});
|
|
124
|
+
get_or_compile_in(cache, schema_value)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/// Drop every retained compiled schema. Idempotent and safe to call before
|
|
128
|
+
/// any schema has ever been compiled (the cache is then simply uninitialized).
|
|
129
|
+
/// Maintenance hook for the public `flushMemory()`.
|
|
130
|
+
pub fn clear_schema_cache() {
|
|
131
|
+
if let Some(cache) = SCHEMA_CACHE.get() {
|
|
132
|
+
cache.lock().clear();
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
#[cfg(test)]
|
|
137
|
+
mod tests {
|
|
138
|
+
use super::*;
|
|
139
|
+
use serde_json::json;
|
|
140
|
+
|
|
141
|
+
fn local_cache(cap: usize) -> SchemaMap {
|
|
142
|
+
Mutex::new(LruCache::new(
|
|
143
|
+
NonZeroUsize::new(cap).expect("test cap is nonzero"),
|
|
144
|
+
))
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
#[test]
|
|
148
|
+
fn same_schema_bytes_share_one_arc() {
|
|
149
|
+
let cache = local_cache(4);
|
|
150
|
+
let schema = json!({
|
|
151
|
+
"type": "object",
|
|
152
|
+
"required": ["x"],
|
|
153
|
+
"properties": { "x": { "type": "string" } }
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
let first = get_or_compile_in(&cache, &schema).unwrap();
|
|
157
|
+
let second = get_or_compile_in(&cache, &schema).unwrap();
|
|
158
|
+
|
|
159
|
+
assert!(Arc::ptr_eq(&first, &second));
|
|
160
|
+
assert_eq!(cache.lock().len(), 1);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
#[test]
|
|
164
|
+
fn different_schema_bytes_compile_separately() {
|
|
165
|
+
let cache = local_cache(4);
|
|
166
|
+
let object = get_or_compile_in(&cache, &json!({ "type": "object" })).unwrap();
|
|
167
|
+
let string = get_or_compile_in(&cache, &json!({ "type": "string" })).unwrap();
|
|
168
|
+
|
|
169
|
+
assert!(!Arc::ptr_eq(&object, &string));
|
|
170
|
+
assert_eq!(cache.lock().len(), 2);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
#[test]
|
|
174
|
+
fn compile_errors_are_not_cached() {
|
|
175
|
+
let cache = local_cache(4);
|
|
176
|
+
// `type` must be a string or array of strings — jsonschema rejects this
|
|
177
|
+
// at build time, so it never reaches the cache.
|
|
178
|
+
let invalid = json!({ "type": 42 });
|
|
179
|
+
|
|
180
|
+
assert!(get_or_compile_in(&cache, &invalid).is_err());
|
|
181
|
+
assert_eq!(cache.lock().len(), 0);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
#[test]
|
|
185
|
+
fn cache_is_bounded_by_capacity() {
|
|
186
|
+
let cache = local_cache(2);
|
|
187
|
+
for i in 0..3 {
|
|
188
|
+
let schema = json!({
|
|
189
|
+
"type": "object",
|
|
190
|
+
"properties": { format!("k{i}"): { "type": "string" } }
|
|
191
|
+
});
|
|
192
|
+
get_or_compile_in(&cache, &schema).unwrap();
|
|
193
|
+
}
|
|
194
|
+
assert!(cache.lock().len() <= 2);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
#[test]
|
|
198
|
+
fn clear_empties_the_cache() {
|
|
199
|
+
let cache = local_cache(4);
|
|
200
|
+
let schema = json!({ "type": "object" });
|
|
201
|
+
let first = get_or_compile_in(&cache, &schema).unwrap();
|
|
202
|
+
|
|
203
|
+
cache.lock().clear();
|
|
204
|
+
|
|
205
|
+
assert_eq!(cache.lock().len(), 0);
|
|
206
|
+
let second = get_or_compile_in(&cache, &schema).unwrap();
|
|
207
|
+
// A cleared cache recompiles: a fresh Arc, not the old shared one.
|
|
208
|
+
assert!(!Arc::ptr_eq(&first, &second));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
#[test]
|
|
212
|
+
fn cap_defaults_clamps_and_rejects_bad_values() {
|
|
213
|
+
assert_eq!(resolve_cap(None), DEFAULT_SCHEMA_CACHE_MAX);
|
|
214
|
+
assert_eq!(resolve_cap(Some("")), DEFAULT_SCHEMA_CACHE_MAX);
|
|
215
|
+
assert_eq!(resolve_cap(Some("abc")), DEFAULT_SCHEMA_CACHE_MAX);
|
|
216
|
+
assert_eq!(resolve_cap(Some("0")), DEFAULT_SCHEMA_CACHE_MAX);
|
|
217
|
+
assert_eq!(resolve_cap(Some("256")), 256);
|
|
218
|
+
assert_eq!(resolve_cap(Some(" 64 ")), 64);
|
|
219
|
+
// Absurd values are clamped so the LRU cannot be sized unboundedly.
|
|
220
|
+
assert_eq!(resolve_cap(Some("999999999")), MAX_SCHEMA_CACHE_MAX);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
#[test]
|
|
224
|
+
fn hash_collision_returns_the_correct_schema() {
|
|
225
|
+
let cache = local_cache(4);
|
|
226
|
+
let schema_a = json!({ "type": "object", "required": ["a"] });
|
|
227
|
+
let schema_b = json!({ "type": "object", "required": ["b"] });
|
|
228
|
+
let bytes_b = serde_json::to_vec(&schema_b).unwrap();
|
|
229
|
+
let key_b = schema_key(&bytes_b);
|
|
230
|
+
|
|
231
|
+
// Simulate an xxh3 collision: an entry under B's key that actually holds
|
|
232
|
+
// A's bytes + compiled validator. Under a hash-only cache this would be
|
|
233
|
+
// returned for B (wrong-schema validation bypass).
|
|
234
|
+
let a = get_or_compile_in(&cache, &schema_a).unwrap();
|
|
235
|
+
let bytes_a = serde_json::to_vec(&schema_a).unwrap();
|
|
236
|
+
let _ = cache.lock().put(key_b, (bytes_a, Arc::clone(&a)));
|
|
237
|
+
|
|
238
|
+
// The byte compare must reject the collision and compile the REAL B.
|
|
239
|
+
let b = get_or_compile_in(&cache, &schema_b).unwrap();
|
|
240
|
+
assert!(!Arc::ptr_eq(&a, &b));
|
|
241
|
+
assert!(b.validate(br#"{"b":1}"#));
|
|
242
|
+
assert!(!b.validate(br#"{"a":1}"#));
|
|
243
|
+
// A itself still resolves correctly (its own bucket is intact).
|
|
244
|
+
let a_again = get_or_compile_in(&cache, &schema_a).unwrap();
|
|
245
|
+
assert!(Arc::ptr_eq(&a, &a_again));
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
#[test]
|
|
249
|
+
fn global_get_or_compile_and_clear() {
|
|
250
|
+
// Use a schema unique to this test so parallel tests cannot repopulate
|
|
251
|
+
// the key between the clear and the recompile.
|
|
252
|
+
let schema = json!({
|
|
253
|
+
"type": "object",
|
|
254
|
+
"properties": { "schema_cache_global_probe": { "type": "integer" } }
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
clear_schema_cache();
|
|
258
|
+
let first = get_or_compile(&schema).unwrap();
|
|
259
|
+
let again = get_or_compile(&schema).unwrap();
|
|
260
|
+
assert!(Arc::ptr_eq(&first, &again));
|
|
261
|
+
|
|
262
|
+
clear_schema_cache();
|
|
263
|
+
let recompiled = get_or_compile(&schema).unwrap();
|
|
264
|
+
assert!(!Arc::ptr_eq(&first, &recompiled));
|
|
265
|
+
}
|
|
266
|
+
}
|
package/rust/payload/sse.rs
CHANGED
|
@@ -40,10 +40,14 @@ pub fn encode_event_size(
|
|
|
40
40
|
if let Some(retry) = retry {
|
|
41
41
|
need += 8 + u64_digits(retry); // "retry: " + digits + "\n"
|
|
42
42
|
}
|
|
43
|
-
// One `data: ` (6) + `\n` (1) per input line, the line bytes themselves
|
|
44
|
-
//
|
|
45
|
-
|
|
46
|
-
|
|
43
|
+
// One `data: ` (6) + `\n` (1) per input line, the line bytes themselves
|
|
44
|
+
// (SEGMENTS EXCLUDE the `\n` separators — sum of segment lengths =
|
|
45
|
+
// `data.len() - (line_count - 1)`), then the terminating blank `\n`.
|
|
46
|
+
// Line boundaries found via SIMD memchr — a scalar `filter().count()`
|
|
47
|
+
// pass over the full payload was ~1 cycle/byte (~650ns/KB steady state),
|
|
48
|
+
// collapsing large encodes to 0.10× of JS.
|
|
49
|
+
let line_count = memchr::memchr_iter(b'\n', data).count() + 1;
|
|
50
|
+
need + line_count * 7 + data.len() - (line_count - 1) + 1
|
|
47
51
|
}
|
|
48
52
|
|
|
49
53
|
/// Encode one SSE event into `out`, returning bytes written. Zero-alloc (no
|
|
@@ -98,7 +102,23 @@ pub fn encode_event_into_slice(
|
|
|
98
102
|
out[w] = b'\n';
|
|
99
103
|
w += 1;
|
|
100
104
|
}
|
|
101
|
-
|
|
105
|
+
// One `data: ` line per input line: SIMD memchr finds the `\n` boundaries
|
|
106
|
+
// (no scalar split() re-scan of the whole payload), copying each run.
|
|
107
|
+
// `data.split('\n')` semantics are preserved exactly: the tail after the
|
|
108
|
+
// last `\n` is emitted even when empty.
|
|
109
|
+
let mut line_start = 0usize;
|
|
110
|
+
for sep in memchr::memchr_iter(b'\n', data) {
|
|
111
|
+
let line = &data[line_start..sep];
|
|
112
|
+
out[w..w + 6].copy_from_slice(b"data: ");
|
|
113
|
+
w += 6;
|
|
114
|
+
out[w..w + line.len()].copy_from_slice(line);
|
|
115
|
+
w += line.len();
|
|
116
|
+
out[w] = b'\n';
|
|
117
|
+
w += 1;
|
|
118
|
+
line_start = sep + 1;
|
|
119
|
+
}
|
|
120
|
+
{
|
|
121
|
+
let line = &data[line_start..];
|
|
102
122
|
out[w..w + 6].copy_from_slice(b"data: ");
|
|
103
123
|
w += 6;
|
|
104
124
|
out[w..w + line.len()].copy_from_slice(line);
|
|
@@ -211,4 +231,57 @@ mod tests {
|
|
|
211
231
|
let mut out = [0u8; 3];
|
|
212
232
|
assert!(encode_event_into_slice(Some("update"), b"payload", None, None, &mut out).is_err());
|
|
213
233
|
}
|
|
234
|
+
|
|
235
|
+
#[test]
|
|
236
|
+
fn large_multiline_matches_allocating() {
|
|
237
|
+
// 4k lines x 96-byte data — spans well past any SIMD threshold.
|
|
238
|
+
let mut data = Vec::with_capacity(4 * 1024 * 96);
|
|
239
|
+
for i in 0..4 * 1024 {
|
|
240
|
+
data.extend_from_slice(format!("line-{i} ").as_bytes());
|
|
241
|
+
data.extend_from_slice(b"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"); // 55
|
|
242
|
+
data.push(b'\n');
|
|
243
|
+
}
|
|
244
|
+
let expected = encode_event(None, &data, None, None);
|
|
245
|
+
assert!(
|
|
246
|
+
expected.len() > 256 * 1024,
|
|
247
|
+
"test payload unexpectedly small: {}",
|
|
248
|
+
expected.len()
|
|
249
|
+
);
|
|
250
|
+
let mut out = vec![0u8; encode_event_size(None, &data, None, None)];
|
|
251
|
+
let w = encode_event_into_slice(None, &data, None, None, &mut out).unwrap();
|
|
252
|
+
assert_eq!(w, expected.len());
|
|
253
|
+
assert_eq!(&out[..w], &expected[..]);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
#[test]
|
|
257
|
+
fn single_large_line_scales_too() {
|
|
258
|
+
// One 1MB line — the old scalar split() path cost ~300us here.
|
|
259
|
+
let mut data = vec![b'a'; 1 << 20];
|
|
260
|
+
data.push(b'\n');
|
|
261
|
+
let expected = encode_event(None, &data, None, None);
|
|
262
|
+
let mut out = vec![0u8; encode_event_size(None, &data, None, None)];
|
|
263
|
+
let w = encode_event_into_slice(None, &data, None, None, &mut out).unwrap();
|
|
264
|
+
assert_eq!(w, expected.len());
|
|
265
|
+
assert_eq!(&out[..w], &expected[..]);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
#[test]
|
|
269
|
+
fn trailing_newline_emits_empty_tail_line() {
|
|
270
|
+
// split() semantics: "a\n" -> ["a", ""] -> one empty data: line.
|
|
271
|
+
assert_eq!(
|
|
272
|
+
encode_event(None, b"hello\n", None, None),
|
|
273
|
+
b"data: hello\ndata: \n\n"
|
|
274
|
+
);
|
|
275
|
+
assert_eq!(encode_event(None, b"\n", None, None), b"data: \ndata: \n\n");
|
|
276
|
+
assert_eq!(encode_event(None, b"", None, None), b"data: \n\n");
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
#[test]
|
|
280
|
+
fn embedded_crlf_kept_in_line() {
|
|
281
|
+
// Only \n splits lines; \r stays in the line bytes.
|
|
282
|
+
assert_eq!(
|
|
283
|
+
encode_event(None, b"a\r\nb", None, None),
|
|
284
|
+
b"data: a\r\ndata: b\n\n"
|
|
285
|
+
);
|
|
286
|
+
}
|
|
214
287
|
}
|
package/rust/task/mod.rs
CHANGED
|
@@ -30,6 +30,7 @@ pub use runtime::{init, shutdown, threads};
|
|
|
30
30
|
///
|
|
31
31
|
/// The pool auto-starts (default worker count) on first use, so callers do not
|
|
32
32
|
/// have to sequence `init` before `submit`. `args` is moved onto the worker.
|
|
33
|
+
/// Returns `false` when the bounded admission queue is full (`OVERLOADED`).
|
|
33
34
|
pub fn submit_op(op: u32, args: Vec<u8>, id: u64) -> bool {
|
|
34
35
|
runtime::submit(move || ops::execute_guarded(op, &args, id))
|
|
35
36
|
}
|