castrum 0.9.7 → 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 +664 -8
- package/dist/index.js +2014 -1327
- package/docs/BENCHMARKS.md +13 -0
- package/docs/CASE_STUDY.md +1 -1
- package/docs/ENVIRONMENT.md +8 -1
- package/docs/FFI_BUN_GUIDE.md +1 -1
- package/docs/INGRESS.md +4 -4
- package/docs/REPO_MAP.md +7 -5
- package/docs/RND-CONCURRENCY.md +20 -4
- package/docs/superpowers/plans/2026-09-18-castrum-hardening-cleanup.md +617 -0
- package/docs/superpowers/plans/2026-09-18-elysia-learnings-adoption.md +634 -0
- package/index.ts +6 -0
- package/package.json +4 -2
- package/rust/crypto/aead.rs +44 -0
- package/rust/crypto/argon2.rs +32 -2
- package/rust/crypto/jwt/tests.rs +19 -0
- package/rust/crypto/jwt/token.rs +43 -28
- package/rust/crypto/session.rs +6 -0
- package/rust/ffi/crypto.rs +63 -32
- package/rust/ffi/hashing.rs +48 -33
- package/rust/ffi/http.rs +62 -36
- package/rust/ffi/metrics.rs +6 -2
- package/rust/ffi/payload.rs +47 -27
- package/rust/ffi/task.rs +55 -35
- package/rust/ffi/tests.rs +24 -0
- package/rust/ffi/util.rs +40 -0
- package/rust/http/headers.rs +13 -0
- package/rust/http/multipart.rs +139 -0
- package/rust/ingress/api.rs +21 -5
- package/rust/ingress/mod.rs +7 -0
- package/rust/ingress/native_route.rs +25 -5
- package/rust/ingress/schema_cache.rs +266 -0
- package/rust/task/completion.rs +7 -0
- package/rust/task/mod.rs +2 -1
- package/rust/task/runtime.rs +55 -7
- package/rust/task/tests.rs +128 -1
- package/src/ingress/abort.ts +48 -0
- package/src/ingress/body.ts +104 -8
- package/src/ingress/decode/baked-result.ts +13 -5
- package/src/ingress/decode/fast-result.ts +1 -1
- package/src/ingress/decode/result-base.ts +16 -2
- package/src/ingress/handlers.ts +104 -31
- package/src/ingress/headers/memoized-headers.ts +40 -0
- package/src/ingress/health.ts +22 -0
- package/src/ingress/index.ts +3 -1
- package/src/ingress/native-route.ts +25 -6
- package/src/ingress/options.ts +26 -2
- package/src/ingress/packing/gather-raw-headers.ts +24 -8
- package/src/ingress/response/baked-response.ts +118 -28
- package/src/ingress/router.ts +50 -3
- package/src/ingress/routes/common.ts +35 -11
- package/src/ingress/routes/echo.ts +60 -7
- package/src/ingress/routes/json-write.ts +32 -8
- package/src/ingress/routes/native.ts +24 -5
- package/src/ingress/routes/options.ts +6 -2
- package/src/ingress/routes/responder.ts +29 -9
- package/src/ingress/routes/safe-record.ts +57 -0
- package/src/ingress/server-error.ts +111 -0
- package/src/ingress/server-node.ts +135 -37
- package/src/ingress/server.ts +143 -36
- package/src/ingress/shared.ts +1 -1
- package/src/ingress/types.ts +15 -0
- package/src/integration/pipeline.ts +6 -0
- package/src/loader/ops.ts +1 -1
- package/src/native/ffi/build/codecs-selftest.ts +305 -0
- package/src/native/ffi/build/codecs.ts +0 -296
- package/src/native/ffi/build/instances-selftest.ts +264 -0
- package/src/native/ffi/build/instances.ts +10 -259
- package/src/native/ffi/build/parse-selftest.ts +238 -0
- package/src/native/ffi/build/parse.ts +11 -234
- package/src/native/ffi/build/task.ts +2 -1
- package/src/native/ffi/build.ts +5 -1
- package/src/native/ffi/selftest.ts +3 -3
- package/src/native/ffi/symbols.ts +460 -0
- package/src/native/ffi/types.ts +6 -1
- package/src/native/ffi.ts +2 -434
- package/src/native/ingress-binding.ts +39 -0
- package/src/native/types.ts +6 -0
- package/src/runtime/codec.ts +24 -0
- package/src/runtime/detect.ts +14 -0
- package/src/runtime/types.ts +11 -0
- package/src/rust-ffi/batch/build.ts +18 -8
- package/src/rust-ffi/batch/types.ts +15 -0
- package/src/rust-ffi/context.ts +46 -3
- package/src/rust-ffi/options.ts +19 -0
- package/src/rust-ffi/scalar/crypto.ts +8 -0
- package/src/rust-ffi/scalar/factories.ts +53 -20
- package/src/rust-ffi/scalar/http.ts +60 -10
- package/src/rust-ffi/scalar/interface.ts +18 -1
- package/src/rust-ffi/scalar/payload.ts +27 -1
- package/src/rust-ffi/text.ts +5 -4
- package/src/shared/buffer-pool.ts +54 -9
- package/src/shared/codec.ts +26 -0
- package/src/shared/memory.ts +68 -0
- package/src/shared/metrics.ts +86 -3
- package/src/shared/packed/parsers.ts +64 -7
- package/src/shared/runtime.ts +7 -0
- package/src/task/runtime.ts +81 -37
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
|
};
|
|
@@ -224,9 +226,11 @@ impl NativeRoute {
|
|
|
224
226
|
if self.require_json_body || self.validate_body {
|
|
225
227
|
if !has_body || body.len() > self.max_body_bytes {
|
|
226
228
|
body_valid_json = false;
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
229
|
+
// Inside `require_json_body || validate_body`: an absent or
|
|
230
|
+
// oversized body can never satisfy either constraint, so fail
|
|
231
|
+
// closed. (Pre-fix a validateBody-only route returned OK with
|
|
232
|
+
// error_code 0 and skipped schema validation entirely.)
|
|
233
|
+
error_code = ERR_BODY_NOT_JSON;
|
|
230
234
|
} else {
|
|
231
235
|
body_valid_json = crate::json::json_ops::json_valid_bytes(body);
|
|
232
236
|
if self.require_json_body && !body_valid_json {
|
|
@@ -732,6 +736,22 @@ mod tests {
|
|
|
732
736
|
assert_eq!(flags & ROUTE_RESULT_FLAG_OK, 0);
|
|
733
737
|
}
|
|
734
738
|
|
|
739
|
+
#[test]
|
|
740
|
+
fn validate_body_without_require_json_fails_closed_on_absent_body() {
|
|
741
|
+
let schema = br#"{"type":"object","required":["x"],"properties":{"x":{"type":"number"}}}"#;
|
|
742
|
+
// Deliberately NO requireJsonBody stage: a route that only declares
|
|
743
|
+
// validateBody must still reject a missing body. Pre-fix this returned
|
|
744
|
+
// OK with error_code 0 and skipped schema validation entirely.
|
|
745
|
+
let d = descriptor(&[STAGE_VALIDATE_BODY], &[(PART_BODY, schema)]);
|
|
746
|
+
let r = NativeRoute::compile(&d).unwrap();
|
|
747
|
+
let mut out = vec![0u8; 64];
|
|
748
|
+
r.run(&frame(b"", b"", None), &mut out).unwrap();
|
|
749
|
+
let flags = u32::from_le_bytes(out[0..4].try_into().unwrap());
|
|
750
|
+
let code = u32::from_le_bytes(out[4..8].try_into().unwrap());
|
|
751
|
+
assert_eq!(code, 400);
|
|
752
|
+
assert_eq!(flags & ROUTE_RESULT_FLAG_OK, 0);
|
|
753
|
+
}
|
|
754
|
+
|
|
735
755
|
#[test]
|
|
736
756
|
fn body_only_route_is_bare_header() {
|
|
737
757
|
let d = descriptor(&[STAGE_REQUIRE_JSON_BODY], &[]);
|
|
@@ -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/task/completion.rs
CHANGED
|
@@ -54,6 +54,13 @@ pub fn set_doorbell(cb: usize) {
|
|
|
54
54
|
DOORBELL.store(cb, Ordering::Release);
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/// Disable the doorbell trampoline (`0`). Called on shutdown BEFORE the JS
|
|
58
|
+
/// owner closes the `JSCallback`, so a completion that lands late can never
|
|
59
|
+
/// call a freed trampoline (poll-only mode drains it instead).
|
|
60
|
+
pub fn clear_doorbell() {
|
|
61
|
+
DOORBELL.store(0, Ordering::Release);
|
|
62
|
+
}
|
|
63
|
+
|
|
57
64
|
/// Push a completion and ring the doorbell at most once per armed window.
|
|
58
65
|
///
|
|
59
66
|
/// The ring lock is released BEFORE the trampoline is called: the JS callback
|
package/rust/task/mod.rs
CHANGED
|
@@ -22,7 +22,7 @@ pub mod runtime;
|
|
|
22
22
|
#[cfg(test)]
|
|
23
23
|
mod tests;
|
|
24
24
|
|
|
25
|
-
pub use completion::{drain, pending, set_doorbell};
|
|
25
|
+
pub use completion::{clear_doorbell, drain, pending, set_doorbell};
|
|
26
26
|
pub use ops::{mark_cancelled, STATUS_CANCELLED, STATUS_ERROR, STATUS_OK};
|
|
27
27
|
pub use runtime::{init, shutdown, threads};
|
|
28
28
|
|
|
@@ -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
|
}
|
package/rust/task/runtime.rs
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
use parking_lot::{Condvar, Mutex};
|
|
12
12
|
use std::collections::VecDeque;
|
|
13
|
-
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
|
13
|
+
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicUsize, Ordering};
|
|
14
14
|
use std::sync::OnceLock;
|
|
15
15
|
use std::time::Duration;
|
|
16
16
|
|
|
@@ -25,6 +25,11 @@ const SPIN_ROUNDS: u32 = 64;
|
|
|
25
25
|
/// Idle park timeout. A bounded wait (instead of an indefinite one) means a
|
|
26
26
|
/// missed wakeup costs at most this much latency and can never wedge a worker.
|
|
27
27
|
const PARK_TIMEOUT: Duration = Duration::from_micros(200);
|
|
28
|
+
/// Default admission bound for the work queue. A submission that would exceed
|
|
29
|
+
/// it is rejected (the C ABI returns `2 = overloaded`) instead of growing
|
|
30
|
+
/// memory without bound. Overridable with `CASTRUM_TASK_QUEUE_MAX` (minimum 1)
|
|
31
|
+
/// when the pool is first created.
|
|
32
|
+
pub const DEFAULT_QUEUE_MAX: usize = 4096;
|
|
28
33
|
|
|
29
34
|
/// A unit of offloaded work. Owns its input bytes (`'static`, `Send`).
|
|
30
35
|
pub type Job = Box<dyn FnOnce() + Send + 'static>;
|
|
@@ -35,6 +40,9 @@ pub struct TaskPool {
|
|
|
35
40
|
cv: Condvar,
|
|
36
41
|
running: AtomicBool,
|
|
37
42
|
threads: AtomicU32,
|
|
43
|
+
/// Admission bound for `queue`; read once when the pool is created (tests
|
|
44
|
+
/// override it through [`set_queue_max`]).
|
|
45
|
+
queue_max: AtomicUsize,
|
|
38
46
|
}
|
|
39
47
|
|
|
40
48
|
static POOL: OnceLock<TaskPool> = OnceLock::new();
|
|
@@ -46,6 +54,7 @@ impl TaskPool {
|
|
|
46
54
|
cv: Condvar::new(),
|
|
47
55
|
running: AtomicBool::new(false),
|
|
48
56
|
threads: AtomicU32::new(0),
|
|
57
|
+
queue_max: AtomicUsize::new(default_queue_max()),
|
|
49
58
|
}
|
|
50
59
|
}
|
|
51
60
|
|
|
@@ -121,9 +130,17 @@ impl TaskPool {
|
|
|
121
130
|
}
|
|
122
131
|
}
|
|
123
132
|
|
|
124
|
-
|
|
125
|
-
|
|
133
|
+
/// Push `job` only while the queue is below its admission bound. Returns
|
|
134
|
+
/// `false` when full — the caller drops the job (overload).
|
|
135
|
+
fn try_enqueue(&'static self, job: Job) -> bool {
|
|
136
|
+
let mut q = self.queue.lock();
|
|
137
|
+
if q.len() >= self.queue_max.load(Ordering::Relaxed) {
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
q.push_back(job);
|
|
141
|
+
drop(q);
|
|
126
142
|
self.cv.notify_one();
|
|
143
|
+
true
|
|
127
144
|
}
|
|
128
145
|
|
|
129
146
|
fn shutdown(&self) {
|
|
@@ -173,6 +190,16 @@ pub fn default_threads() -> usize {
|
|
|
173
190
|
})
|
|
174
191
|
}
|
|
175
192
|
|
|
193
|
+
/// Default admission bound: `CASTRUM_TASK_QUEUE_MAX` when set to a valid value
|
|
194
|
+
/// `>= 1`, else {@link DEFAULT_QUEUE_MAX}.
|
|
195
|
+
fn default_queue_max() -> usize {
|
|
196
|
+
std::env::var("CASTRUM_TASK_QUEUE_MAX")
|
|
197
|
+
.ok()
|
|
198
|
+
.and_then(|v| v.parse::<usize>().ok())
|
|
199
|
+
.filter(|&n| n > 0)
|
|
200
|
+
.unwrap_or(DEFAULT_QUEUE_MAX)
|
|
201
|
+
}
|
|
202
|
+
|
|
176
203
|
/// Start the pool with `threads` workers (`None`/`0` → {@link default_threads}).
|
|
177
204
|
///
|
|
178
205
|
/// Returns `0` when this call started the pool and `1` when it was already
|
|
@@ -190,8 +217,9 @@ pub fn init(threads: Option<u32>) -> u32 {
|
|
|
190
217
|
0
|
|
191
218
|
}
|
|
192
219
|
|
|
193
|
-
/// Enqueue `job`, auto-starting the pool on first use.
|
|
194
|
-
///
|
|
220
|
+
/// Enqueue `job`, auto-starting the pool on first use. Returns `false` (the C
|
|
221
|
+
/// ABI's `2 = overloaded`) when the bounded work queue is full — admission is
|
|
222
|
+
/// capped by `CASTRUM_TASK_QUEUE_MAX` (default {@link DEFAULT_QUEUE_MAX}).
|
|
195
223
|
pub fn submit<F>(job: F) -> bool
|
|
196
224
|
where
|
|
197
225
|
F: FnOnce() + Send + 'static,
|
|
@@ -201,8 +229,28 @@ where
|
|
|
201
229
|
let n = default_threads();
|
|
202
230
|
p.start(n);
|
|
203
231
|
}
|
|
204
|
-
p.
|
|
205
|
-
|
|
232
|
+
p.try_enqueue(Box::new(job))
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/// Jobs queued but not yet started (monitoring / tests).
|
|
236
|
+
pub fn queue_depth() -> usize {
|
|
237
|
+
POOL.get_or_init(TaskPool::new).queue.lock().len()
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/// Configured admission bound for the work queue (monitoring / tests).
|
|
241
|
+
pub fn queue_max() -> usize {
|
|
242
|
+
POOL.get_or_init(TaskPool::new)
|
|
243
|
+
.queue_max
|
|
244
|
+
.load(Ordering::Relaxed)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/// Override the admission bound. TEST ONLY: production reads
|
|
248
|
+
/// `CASTRUM_TASK_QUEUE_MAX` once when the pool is created.
|
|
249
|
+
#[cfg(test)]
|
|
250
|
+
pub fn set_queue_max(max: usize) {
|
|
251
|
+
POOL.get_or_init(TaskPool::new)
|
|
252
|
+
.queue_max
|
|
253
|
+
.store(max.max(1), Ordering::Relaxed);
|
|
206
254
|
}
|
|
207
255
|
|
|
208
256
|
/// Configured worker count (0 before the pool starts).
|
package/rust/task/tests.rs
CHANGED
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
// threads.
|
|
6
6
|
|
|
7
7
|
use parking_lot::Mutex;
|
|
8
|
-
use std::sync::atomic::{AtomicU32, Ordering};
|
|
8
|
+
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicUsize, Ordering};
|
|
9
|
+
use std::sync::Arc;
|
|
10
|
+
use std::time::Duration;
|
|
9
11
|
|
|
10
12
|
use super::completion::{self, Completion};
|
|
11
13
|
use super::ops::{
|
|
@@ -223,9 +225,134 @@ fn doorbell_coalesces_a_batch() {
|
|
|
223
225
|
completion::clear();
|
|
224
226
|
}
|
|
225
227
|
|
|
228
|
+
#[test]
|
|
229
|
+
fn clear_doorbell_suppresses_the_ring() {
|
|
230
|
+
let _g = LOCK.lock();
|
|
231
|
+
completion::clear();
|
|
232
|
+
completion::set_doorbell(bell as *const () as usize);
|
|
233
|
+
BELLS.store(0, Ordering::SeqCst);
|
|
234
|
+
completion::push(Completion {
|
|
235
|
+
id: 1,
|
|
236
|
+
status: STATUS_OK,
|
|
237
|
+
body: vec![0],
|
|
238
|
+
});
|
|
239
|
+
assert_eq!(BELLS.load(Ordering::SeqCst), 1);
|
|
240
|
+
// Disarm by draining, then clear the trampoline. A later completion must
|
|
241
|
+
// NOT call the (now closed) trampoline address.
|
|
242
|
+
let mut buf = vec![0u8; 4096];
|
|
243
|
+
completion::drain(&mut buf);
|
|
244
|
+
completion::clear_doorbell();
|
|
245
|
+
completion::push(Completion {
|
|
246
|
+
id: 2,
|
|
247
|
+
status: STATUS_OK,
|
|
248
|
+
body: vec![0],
|
|
249
|
+
});
|
|
250
|
+
assert_eq!(
|
|
251
|
+
BELLS.load(Ordering::SeqCst),
|
|
252
|
+
1,
|
|
253
|
+
"a cleared doorbell must never be called"
|
|
254
|
+
);
|
|
255
|
+
completion::clear();
|
|
256
|
+
}
|
|
257
|
+
|
|
226
258
|
#[test]
|
|
227
259
|
fn pool_starts_with_a_nonzero_thread_count() {
|
|
228
260
|
let _g = LOCK.lock();
|
|
229
261
|
runtime::init(None);
|
|
230
262
|
assert!(runtime::threads() >= 1);
|
|
231
263
|
}
|
|
264
|
+
|
|
265
|
+
/// Block until `counter` reaches `target` (10 s guard).
|
|
266
|
+
fn wait_for_counter(counter: &AtomicUsize, target: usize) {
|
|
267
|
+
let deadline = std::time::Instant::now() + Duration::from_secs(10);
|
|
268
|
+
while counter.load(Ordering::SeqCst) < target {
|
|
269
|
+
assert!(
|
|
270
|
+
std::time::Instant::now() < deadline,
|
|
271
|
+
"counter never reached {target}"
|
|
272
|
+
);
|
|
273
|
+
std::thread::sleep(Duration::from_millis(1));
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/// Block until the admission queue is empty (10 s guard).
|
|
278
|
+
fn wait_for_drained_queue() {
|
|
279
|
+
let deadline = std::time::Instant::now() + Duration::from_secs(10);
|
|
280
|
+
while runtime::queue_depth() != 0 {
|
|
281
|
+
assert!(std::time::Instant::now() < deadline, "queue never drained");
|
|
282
|
+
std::thread::sleep(Duration::from_millis(1));
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/// The queue is a hard-bounded admission gate: once every worker is occupied
|
|
287
|
+
/// and the queue holds `queue_max` jobs, further submits are rejected rather
|
|
288
|
+
/// than growing memory; releasing the workers restores capacity.
|
|
289
|
+
#[test]
|
|
290
|
+
fn queue_admission_is_bounded_and_recovers() {
|
|
291
|
+
let _g = LOCK.lock();
|
|
292
|
+
completion::clear();
|
|
293
|
+
runtime::init(None);
|
|
294
|
+
let workers = runtime::threads().max(1) as usize;
|
|
295
|
+
|
|
296
|
+
const CAP: usize = 4;
|
|
297
|
+
runtime::set_queue_max(CAP);
|
|
298
|
+
|
|
299
|
+
let started = Arc::new(AtomicUsize::new(0));
|
|
300
|
+
let finished = Arc::new(AtomicUsize::new(0));
|
|
301
|
+
let release = Arc::new(AtomicBool::new(false));
|
|
302
|
+
|
|
303
|
+
let make_blocker = || {
|
|
304
|
+
let started = Arc::clone(&started);
|
|
305
|
+
let finished = Arc::clone(&finished);
|
|
306
|
+
let release = Arc::clone(&release);
|
|
307
|
+
move || {
|
|
308
|
+
started.fetch_add(1, Ordering::SeqCst);
|
|
309
|
+
while !release.load(Ordering::SeqCst) {
|
|
310
|
+
std::thread::sleep(Duration::from_millis(1));
|
|
311
|
+
}
|
|
312
|
+
finished.fetch_add(1, Ordering::SeqCst);
|
|
313
|
+
}
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
// Occupy every worker, one submission at a time, waiting for each to START
|
|
317
|
+
// so it has left the queue. (Submitting them all at once would let one
|
|
318
|
+
// worker batch-pop several, which still occupies it but muddies the depth
|
|
319
|
+
// arithmetic this test asserts.)
|
|
320
|
+
for i in 0..workers {
|
|
321
|
+
assert!(
|
|
322
|
+
runtime::submit(make_blocker()),
|
|
323
|
+
"worker {i} submit rejected"
|
|
324
|
+
);
|
|
325
|
+
wait_for_counter(&started, i + 1);
|
|
326
|
+
}
|
|
327
|
+
assert_eq!(runtime::queue_depth(), 0);
|
|
328
|
+
assert_eq!(runtime::queue_max(), CAP);
|
|
329
|
+
|
|
330
|
+
// Every worker is busy: fill the queue to the cap.
|
|
331
|
+
for i in 0..CAP {
|
|
332
|
+
assert!(
|
|
333
|
+
runtime::submit(make_blocker()),
|
|
334
|
+
"queued submit {i} rejected"
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
assert_eq!(runtime::queue_depth(), CAP);
|
|
338
|
+
|
|
339
|
+
// The cap is a HARD bound — the next submit is rejected, not queued.
|
|
340
|
+
assert!(
|
|
341
|
+
!runtime::submit(make_blocker()),
|
|
342
|
+
"submit accepted at the cap"
|
|
343
|
+
);
|
|
344
|
+
assert_eq!(runtime::queue_depth(), CAP);
|
|
345
|
+
|
|
346
|
+
// Release: the queue drains and admission recovers.
|
|
347
|
+
release.store(true, Ordering::SeqCst);
|
|
348
|
+
wait_for_counter(&finished, workers + CAP);
|
|
349
|
+
wait_for_drained_queue();
|
|
350
|
+
assert!(
|
|
351
|
+
runtime::submit(make_blocker()),
|
|
352
|
+
"capacity did not recover after drain"
|
|
353
|
+
);
|
|
354
|
+
wait_for_counter(&finished, workers + CAP + 1);
|
|
355
|
+
|
|
356
|
+
// Restore the default bound so a later test cannot inherit the tiny cap.
|
|
357
|
+
runtime::set_queue_max(4096);
|
|
358
|
+
}
|