Skip to content

Commit dee3c39

Browse files
authored
fix: automatically manage gateway runtime and memory (#134)
Automatically size CPU execution and buffering from usable CPUs and container memory. Bound AI, MCP, cache, discovery and audit buffering, retain admission through cancellation and slow consumers, and protect Argon2 workspaces. Verified with 939 unit tests and 492 integration tests.
1 parent b569e40 commit dee3c39

39 files changed

Lines changed: 1969 additions & 312 deletions

‎Cargo.lock‎

Lines changed: 229 additions & 26 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Cargo.toml‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -70,10 +70,10 @@ opt-level = 3
7070
# never re-exported through a local shim. And the reverse: something only
7171
# this side uses (the at-rest crypto, IMDSv2 credentials, the gateway error)
7272
# lives here, not in core.
73-
tw-bedrock = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.86.0" }
74-
tw-breaker = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.86.0" }
75-
tw-dialect = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.86.0" }
76-
tw-guard = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.86.0" }
73+
tw-bedrock = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.89.0" }
74+
tw-breaker = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.89.0" }
75+
tw-dialect = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.89.0" }
76+
tw-guard = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.89.0" }
7777

7878
# Web framework
7979
axum = { version = "0.8", features = ["macros", "ws"] }

‎crates/auth/src/oauth/client.rs‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,15 @@ pub async fn exchange_authorization_code(
9898
.map_err(TokenExchangeError::Unreachable)?;
9999

100100
let status = resp.status();
101-
let resp_text = resp.text().await.map_err(TokenExchangeError::Unreachable)?;
101+
let bytes = think_watch_common::resources::read_response(resp, 64 * 1024)
102+
.await
103+
.map_err(|e| match e {
104+
think_watch_common::resources::ReadError::Transport(e) => {
105+
TokenExchangeError::Unreachable(e)
106+
}
107+
other => TokenExchangeError::InvalidResponse(other.to_string()),
108+
})?;
109+
let resp_text = String::from_utf8_lossy(&bytes);
102110

103111
// Two cases land here:
104112
// * non-2xx status (always treated as failure)

‎crates/auth/src/password.rs‎

Lines changed: 49 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,7 @@
1-
use std::sync::{Arc, LazyLock};
2-
31
use argon2::{
42
Argon2,
53
password_hash::{PasswordHasher, PasswordVerifier, phc::PasswordHash},
64
};
7-
use tokio::sync::Semaphore;
85

96
const RANDOM_PASSWORD_LEN: usize = 16;
107
const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%&*";
@@ -60,24 +57,13 @@ pub fn verify_password_or_dummy(password: &str, hash: Option<&str>) -> bool {
6057
// every other request scheduled on that thread, so the handlers use
6158
// these versions, which run it on the blocking pool.
6259

63-
/// How many Argon2 runs go at once: one per CPU, as many as the async
64-
/// workers allowed when they ran it. Each run holds 19 MiB (`m=19456`)
65-
/// while it lasts and the blocking pool grows to hundreds of threads, so
66-
/// without this a burst of logins would hold gigabytes at once.
67-
static ARGON2_PERMITS: LazyLock<Arc<Semaphore>> = LazyLock::new(|| {
68-
Arc::new(Semaphore::new(
69-
std::thread::available_parallelism().map_or(1, |n| n.get()),
70-
))
71-
});
72-
73-
/// `f` on the blocking pool once an [`ARGON2_PERMITS`] place is free. The
74-
/// task keeps the place until it ends, even when the caller has gone.
60+
/// Share CPU admission with the data plane and limit Argon2 memory using
61+
/// the container allowance. The running task owns both places even when
62+
/// its caller is cancelled.
7563
async fn on_blocking_pool<T: Send + 'static>(
7664
f: impl FnOnce() -> T + Send + 'static,
7765
) -> Result<T, tokio::task::JoinError> {
78-
// Never closed, so the acquire cannot fail; the permit inside the
79-
// result is held all the same.
80-
let permit = Arc::clone(&ARGON2_PERMITS).acquire_owned().await;
66+
let permit = think_watch_common::resources::password_slot().await;
8167
tokio::task::spawn_blocking(move || {
8268
let _permit = permit;
8369
f()
@@ -111,6 +97,48 @@ pub async fn verify_password_or_dummy_off_runtime(password: String, hash: Option
11197
mod tests {
11298
use super::*;
11399

100+
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
101+
async fn cancelling_callers_does_not_admit_more_running_password_jobs() {
102+
use std::time::Duration;
103+
let policy = think_watch_common::resources::policy();
104+
let jobs = policy.argon_jobs.min(policy.cpu_jobs);
105+
let (started, mut beginnings) = tokio::sync::mpsc::unbounded_channel();
106+
let mut release = Vec::new();
107+
let mut callers = Vec::new();
108+
for _ in 0..jobs {
109+
let (tx, rx) = std::sync::mpsc::channel::<()>();
110+
release.push(tx);
111+
let started = started.clone();
112+
callers.push(tokio::spawn(on_blocking_pool(move || {
113+
started.send(()).unwrap();
114+
let _ = rx.recv_timeout(Duration::from_secs(10));
115+
})));
116+
}
117+
for _ in 0..jobs {
118+
tokio::time::timeout(Duration::from_secs(5), beginnings.recv())
119+
.await
120+
.unwrap()
121+
.unwrap();
122+
}
123+
for caller in callers {
124+
caller.abort();
125+
assert!(caller.await.unwrap_err().is_cancelled());
126+
}
127+
let next = tokio::spawn(on_blocking_pool(move || started.send(()).unwrap()));
128+
assert!(
129+
tokio::time::timeout(Duration::from_millis(50), beginnings.recv())
130+
.await
131+
.is_err()
132+
);
133+
drop(release);
134+
tokio::time::timeout(Duration::from_secs(5), next)
135+
.await
136+
.unwrap()
137+
.unwrap()
138+
.unwrap();
139+
assert_eq!(beginnings.recv().await, Some(()));
140+
}
141+
114142
#[tokio::test]
115143
async fn the_off_runtime_versions_agree_with_the_blocking_ones() {
116144
let hash = hash_password_off_runtime("pw".into()).await.unwrap();
@@ -133,7 +161,9 @@ mod tests {
133161
use std::sync::atomic::{AtomicUsize, Ordering};
134162
static RUNNING: AtomicUsize = AtomicUsize::new(0);
135163
static PEAK: AtomicUsize = AtomicUsize::new(0);
136-
let permits = std::thread::available_parallelism().map_or(1, |n| n.get());
164+
let permits = think_watch_common::resources::policy()
165+
.argon_jobs
166+
.min(think_watch_common::resources::policy().cpu_jobs);
137167
let tasks: Vec<_> = (0..permits * 4)
138168
.map(|_| {
139169
tokio::spawn(on_blocking_pool(|| {

‎crates/common/Cargo.toml‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,3 +48,11 @@ aws-sigv4 = { workspace = true }
4848
aws-credential-types = { workspace = true }
4949
http_1x = { package = "http", version = "1" }
5050
async-trait = "0.1"
51+
http-body-util = { workspace = true }
52+
http-body = "1"
53+
sysinfo = { version = "0.36", default-features = false, features = ["system"] }
54+
55+
[dev-dependencies]
56+
async-stream = { workspace = true }
57+
tempfile = "3"
58+
tower = { workspace = true, features = ["util"] }

‎crates/common/src/audit/forwarders.rs‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,10 @@ pub(super) async fn send_kafka(
202202
if resp.status().is_success() {
203203
Ok(())
204204
} else {
205-
let body = resp.text().await.unwrap_or_default();
205+
let bytes = crate::resources::read_response(resp, 64 * 1024)
206+
.await
207+
.map_err(|e| e.to_string())?;
208+
let body = String::from_utf8_lossy(&bytes);
206209
Err(format!("Kafka REST proxy returned error: {body}"))
207210
}
208211
}
@@ -300,7 +303,10 @@ pub(super) async fn send_webhook(
300303
Ok(resp) if resp.status().is_success() => Ok(()),
301304
Ok(resp) => {
302305
let status = resp.status();
303-
let rtext = resp.text().await.unwrap_or_default();
306+
let bytes = crate::resources::read_response(resp, 64 * 1024)
307+
.await
308+
.map_err(|e| e.to_string())?;
309+
let rtext = String::from_utf8_lossy(&bytes);
304310
Err(format!("Webhook returned {status}: {rtext}"))
305311
}
306312
Err(e) => Err(format!("Webhook request failed: {e}")),

‎crates/common/src/audit/logger.rs‎

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -59,17 +59,15 @@ pub struct AuditLogger {
5959

6060
/// Bounded audit channel capacity.
6161
///
62-
/// At ~80 bytes per entry that's about 8 MB of in-memory backlog,
63-
/// which is fine for any reasonable host. 100k entries means a
64-
/// 30-second ClickHouse outage at 3k req/s still survives; drops
65-
/// are surfaced via a metric.
62+
/// Secondary count limit. Entries include captured bodies, so a separate
63+
/// process-wide byte budget is the primary memory bound, including retries.
6664
const AUDIT_CHANNEL_CAPACITY: usize = 100_000;
6765

6866
/// Throttle window for the structured "audit drop" log line. The
6967
/// metric still increments on every drop, so dashboards see the
7068
/// real rate; the log is just for human inspection and one line
7169
/// per second is plenty.
72-
fn log_audit_drop_throttled(err: &tokio::sync::mpsc::error::TrySendError<AuditEntry>) {
70+
fn log_audit_drop_throttled(err: &impl std::fmt::Display) {
7371
use std::sync::atomic::{AtomicU64, Ordering};
7472
use std::time::{SystemTime, UNIX_EPOCH};
7573
static LAST_LOG_SECS: AtomicU64 = AtomicU64::new(0);
@@ -272,6 +270,8 @@ impl AuditLogger {
272270
// the REMAINING capacity, so depth = total - capacity.
273271
let depth = AUDIT_CHANNEL_CAPACITY.saturating_sub(tx_for_gauge.capacity());
274272
metrics::gauge!("audit_log_queue_depth").set(depth as f64);
273+
metrics::gauge!("audit_log_buffered_bytes")
274+
.set(crate::resources::audit_budget().used() as f64);
275275
}
276276
}
277277
});
@@ -297,7 +297,7 @@ impl AuditLogger {
297297
.store(bps, std::sync::atomic::Ordering::Relaxed);
298298
}
299299

300-
pub fn log(&self, entry: AuditEntry) {
300+
pub fn log(&self, mut entry: AuditEntry) {
301301
// Sampling: consult the atomic rate and skip the entry when a
302302
// uniform draw falls outside the keep window. A full keep
303303
// (10000) short-circuits the RNG so the hot path pays nothing
@@ -309,6 +309,12 @@ impl AuditLogger {
309309
metrics::counter!("audit_log_sampled_out_total").increment(1);
310310
return;
311311
}
312+
if !entry.reserve_memory(crate::resources::audit_budget()) {
313+
metrics::counter!("audit_log_dropped_total").increment(1);
314+
metrics::counter!("audit_log_memory_dropped_total").increment(1);
315+
log_audit_drop_throttled(&"audit byte budget occupied (queue and retained batches)");
316+
return;
317+
}
312318
if let Err(e) = self.tx.try_send(entry) {
313319
// Compliance signal: dropped audit entries are a real
314320
// operational incident, not a debug warning. Bump the

‎crates/common/src/audit/types.rs‎

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,10 @@ impl std::fmt::Display for BodyCaptureStatus {
132132
/// back out and feeds them into `send_webhook` again.
133133
#[derive(Debug, Clone, Serialize, Deserialize)]
134134
pub struct AuditEntry {
135+
/// Account for queued and retained data through its final clone. Never part
136+
/// of ClickHouse rows or the durable webhook JSON contract.
137+
#[serde(skip)]
138+
memory: Option<std::sync::Arc<crate::resources::Reservation>>,
135139
pub id: String,
136140
#[serde(skip, default = "default_log_type")]
137141
pub log_type: LogType,
@@ -407,6 +411,7 @@ impl AuditEntry {
407411
)]
408412
pub fn new(action: impl Into<String>) -> Self {
409413
Self {
414+
memory: None,
410415
id: Uuid::new_v4().to_string(),
411416
log_type: LogType::Audit,
412417
user_id: None,
@@ -430,6 +435,61 @@ impl AuditEntry {
430435
}
431436
}
432437

438+
pub(super) fn reserve_memory(
439+
&mut self,
440+
budget: &std::sync::Arc<crate::resources::ByteBudget>,
441+
) -> bool {
442+
fn value_bytes(value: &serde_json::Value) -> usize {
443+
use serde_json::Value;
444+
match value {
445+
Value::String(s) => s.capacity(),
446+
Value::Array(values) => values
447+
.capacity()
448+
.saturating_mul(std::mem::size_of::<Value>())
449+
.saturating_add(values.iter().map(value_bytes).sum::<usize>()),
450+
Value::Object(values) => values.iter().fold(0usize, |total, (key, value)| {
451+
total
452+
.saturating_add(key.capacity())
453+
.saturating_add(std::mem::size_of::<(String, Value)>() * 2)
454+
.saturating_add(value_bytes(value))
455+
}),
456+
_ => 0,
457+
}
458+
}
459+
let strings = [
460+
Some(&self.id),
461+
Some(&self.action),
462+
Some(&self.created_at),
463+
self.user_id.as_ref(),
464+
self.user_email.as_ref(),
465+
self.api_key_id.as_ref(),
466+
self.api_key_lineage_id.as_ref(),
467+
self.resource.as_ref(),
468+
self.resource_id.as_ref(),
469+
self.ip_address.as_ref(),
470+
self.user_agent.as_ref(),
471+
self.trace_id.as_ref(),
472+
self.session_id.as_ref(),
473+
self.request_body.as_ref(),
474+
self.response_body.as_ref(),
475+
self.body_capture_status.as_ref(),
476+
];
477+
let bytes = strings
478+
.into_iter()
479+
.flatten()
480+
.fold(std::mem::size_of::<Self>(), |n, s| {
481+
n.saturating_add(s.capacity())
482+
})
483+
.saturating_add(self.detail.as_ref().map_or(0, value_bytes));
484+
// Flush mappers and forwarders temporarily clone/serialize one entry.
485+
let mut lease = budget.reserve();
486+
if !lease.grow(bytes.saturating_mul(2)) {
487+
return false;
488+
}
489+
self.memory = Some(std::sync::Arc::new(lease));
490+
true
491+
}
492+
433493
/// Create entry for gateway request logs.
434494
///
435495
/// Bare constructor — same deprecation reasoning as `::new`. Use
@@ -680,3 +740,39 @@ mod tests {
680740
assert_eq!(decoded.detail, original.detail);
681741
}
682742
}
743+
744+
#[cfg(test)]
745+
#[allow(deprecated)]
746+
mod memory_tests {
747+
use super::*;
748+
use crate::resources::ByteBudget;
749+
750+
#[test]
751+
fn queue_and_retry_clones_retain_body_admission_without_changing_wire_json() {
752+
let budget = ByteBudget::new(32 * 1024);
753+
let mut entry = AuditEntry::new("tools.call").request_body("x".repeat(4096));
754+
let before = serde_json::to_value(&entry).unwrap();
755+
assert!(entry.reserve_memory(&budget));
756+
assert!(budget.used() >= 8192);
757+
assert_eq!(before, serde_json::to_value(&entry).unwrap());
758+
let retained = entry.clone();
759+
drop(entry);
760+
assert!(budget.used() >= 8192);
761+
drop(retained);
762+
assert_eq!(budget.used(), 0);
763+
}
764+
765+
#[test]
766+
fn a_few_large_entries_hit_the_byte_limit_before_the_count_limit() {
767+
let budget = ByteBudget::new(16 * 1024);
768+
let mut first = AuditEntry::new("gateway").response_body("x".repeat(4096));
769+
let mut second = first.clone();
770+
assert!(first.reserve_memory(&budget));
771+
let used = budget.used();
772+
assert!(!second.reserve_memory(&budget));
773+
assert_eq!(used, budget.used());
774+
drop(first);
775+
assert_eq!(budget.used(), 0);
776+
assert!(second.reserve_memory(&budget));
777+
}
778+
}

‎crates/common/src/blob_store.rs‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -378,8 +378,12 @@ impl S3Store {
378378
.await
379379
.map_err(|e| BlobError::Transport(e.to_string()))?;
380380
let status = resp.status();
381-
let bytes = resp
382-
.bytes()
381+
let max = if status.is_success() {
382+
crate::resources::policy().max_body_bytes
383+
} else {
384+
64 * 1024
385+
};
386+
let bytes = crate::resources::read_response(resp, max)
383387
.await
384388
.map_err(|e| BlobError::Transport(e.to_string()))?;
385389
if !status.is_success() {

‎crates/common/src/lib.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ pub mod clickhouse_client;
4343
pub mod cost_decimal; // Decimal ↔ raw i64/i128 helpers for CH Decimal(18, 10) columns
4444
pub mod lifecycle; // Surface-agnostic request pipeline (see lifecycle::mod docs)
4545
pub mod limits; // rate-limit & budget evaluation
46+
pub mod resources; // automatic CPU admission and process-wide byte budgets
4647

4748
// --- Utilities ---
4849
pub mod crypto; // AES-256-GCM envelope for secrets at rest

0 commit comments

Comments
 (0)