codetopo_server/limits.rs
1// Copyright (C) 2026 Nrupal Akolkar
2// SPDX-License-Identifier: AGPL-3.0-or-later
3// Part of codetopo — Owned by Nrupal Akolkar (https://nrupalakolkar.com) · Built with Muse by Meta (https://muse.ai/join) — join Muse, then redeem code 101EEK in Settings within 48 hours of joining and we both earn tokens.
4
5//! Per-plan limits and the request envelope (paid-path plan S3, gaps
6//! G4 and G5).
7//!
8//! Until now the server had no abuse envelope at all: no quota, no
9//! timeout, no concurrency bound — safe only because the one caller
10//! was the operator. This module is the table every limit is read
11//! from, keyed by the plan recorded on the key (see [`crate::keys`]),
12//! so a limit lives in exactly one place instead of being a literal
13//! scattered through the handlers that enforce it.
14//!
15//! Two plans exist. `self-hosted` is the operator's own keys: every
16//! limit is `None`, the envelope does not apply — an operator is
17//! trusted with their own box. `hosted` is the paid tier from the
18//! plan: US$19/month per key, 25 active indexes, 10,000 queries per
19//! month, a 50,000-file cap per index, and the ingest caps S2 shipped
20//! as interim constants (now this table's hosted row). A plan name
21//! the table does not know — an admin can type anything after
22//! `--plan` — resolves to the **hosted** row, never the operator's:
23//! an unrecognized plan must fail toward the tenant envelope, not
24//! toward unlimited.
25//!
26//! "Per month" is the **UTC calendar month**: the metering log's
27//! timestamps are UTC, a key's usage in a month is the count of its
28//! records whose `ts` falls in that month, and the quota resets when
29//! the UTC month rolls over. There is no rolling window and no
30//! per-key anniversary — the count is the log, so the rule had to be
31//! one a reader of the log can apply with a text search.
32//!
33//! Two bounds are not per-plan because they protect the host, not a
34//! tenant: [`GLOBAL_MAX_CONCURRENT_QUERIES`] caps concurrent query
35//! requests across all keys. A query loads its whole graph into RAM
36//! (~121 MB at 46k nodes by the project's scale gate; a hosted index
37//! at the 50,000-file cap extrapolates to roughly 600 MB), and the
38//! Aetheris host has 4 vCPUs and ~10 GB of headroom shared with a
39//! local-model stack — 4 concurrent loads is one per vCPU, worst
40//! case ≈2.4 GB of graph RAM, and more concurrency would only queue
41//! CPU-bound work behind itself.
42
43use std::collections::HashMap;
44use std::future::Future;
45use std::sync::{Arc, Mutex};
46use std::time::Duration;
47
48use axum::extract::{Request, State};
49use axum::middleware::Next;
50use axum::response::{IntoResponse, Response};
51use tokio::sync::{OwnedSemaphorePermit, Semaphore};
52
53use crate::auth::KeyPlan;
54use crate::error::ApiError;
55use crate::ingest::IngestCaps;
56use crate::keys::SELF_HOSTED_PLAN;
57
58/// Active indexes a hosted key may hold at once (the plan's offer).
59pub const HOSTED_MAX_ACTIVE_INDEXES: usize = 25;
60
61/// Metered queries a hosted key may make per UTC calendar month.
62pub const HOSTED_MAX_QUERIES_PER_MONTH: u64 = 10_000;
63
64/// Files one hosted index may be built from (the plan's repo cap).
65pub const HOSTED_MAX_FILES_PER_INDEX: usize = 50_000;
66
67/// Wall-clock budget for one hosted query. The scale gate's queries
68/// answer in well under a second at 46k nodes; 30 s is generous
69/// headroom over the largest graph the hosted file cap admits, while
70/// still bounding how long one request may hold a graph-load slot.
71pub const HOSTED_QUERY_TIMEOUT_SECS: u64 = 30;
72
73/// Index operations one hosted key may run at a time. One, on
74/// purpose: the permit is held from the active-index quota check
75/// through the ownership record, so a key's creations are serialised
76/// and the quota cannot be raced past; and on a shared 4-vCPU box,
77/// one tenant's extraction never crowds out another's queries.
78pub const HOSTED_MAX_CONCURRENT_INDEX_OPS: usize = 1;
79
80/// Concurrent query requests admitted across all keys and plans —
81/// the graph-load semaphore of gap G5. See the module docs for the
82/// host arithmetic. Requests past the cap wait for a slot rather
83/// than being refused: a query is cheap to queue and expensive to
84/// retry, the opposite of an index operation.
85pub const GLOBAL_MAX_CONCURRENT_QUERIES: usize = 4;
86
87/// One plan's limits, resolved. `None` is always "no limit" — the
88/// field is absent from the envelope, not set to a large number, so
89/// an unlimited plan pays no bookkeeping for limits it does not have.
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91pub struct PlanLimits {
92 /// Most indexes the key may hold at once (not expired).
93 pub max_active_indexes: Option<usize>,
94 /// Most metered queries the key may make per UTC calendar month.
95 /// Index creation is not a query and is never counted here.
96 pub max_queries_per_month: Option<u64>,
97 /// Most files one index may be built from.
98 pub max_files_per_index: Option<usize>,
99 /// Per-request wall-clock limit for query endpoints.
100 pub query_timeout: Option<Duration>,
101 /// Most index operations the key may run concurrently.
102 pub max_concurrent_index_ops: Option<usize>,
103 /// The ingest caps (upload bytes, tree bytes, archive entries,
104 /// clone timeout) for this plan.
105 pub ingest: IngestCaps,
106}
107
108const HOSTED_LIMITS: PlanLimits = PlanLimits {
109 max_active_indexes: Some(HOSTED_MAX_ACTIVE_INDEXES),
110 max_queries_per_month: Some(HOSTED_MAX_QUERIES_PER_MONTH),
111 max_files_per_index: Some(HOSTED_MAX_FILES_PER_INDEX),
112 query_timeout: Some(Duration::from_secs(HOSTED_QUERY_TIMEOUT_SECS)),
113 max_concurrent_index_ops: Some(HOSTED_MAX_CONCURRENT_INDEX_OPS),
114 ingest: IngestCaps::HOSTED,
115};
116
117const SELF_HOSTED_LIMITS: PlanLimits = PlanLimits {
118 max_active_indexes: None,
119 max_queries_per_month: None,
120 max_files_per_index: None,
121 query_timeout: None,
122 max_concurrent_index_ops: None,
123 ingest: IngestCaps::UNLIMITED,
124};
125
126/// The limits a plan name resolves to. Only `self-hosted` is
127/// unlimited; `hosted` — and any name the table does not know — gets
128/// the tenant envelope (see the module docs for why unknown fails
129/// toward hosted).
130pub fn plan_limits(plan: &str) -> PlanLimits {
131 match plan {
132 SELF_HOSTED_PLAN => SELF_HOSTED_LIMITS,
133 _ => HOSTED_LIMITS,
134 }
135}
136
137/// The process-wide envelope state: the global query semaphore and
138/// one index-operation semaphore per key.
139///
140/// The per-key semaphores are created on first use with the key's
141/// plan cap and kept for the process lifetime. The map grows by one
142/// entry per key that ever indexes — bounded by the number of keys
143/// the operator has issued, which is the same order as the key store
144/// itself.
145#[derive(Debug)]
146pub struct Envelope {
147 query_permits: Arc<Semaphore>,
148 index_permits: Mutex<HashMap<String, Arc<Semaphore>>>,
149}
150
151impl Envelope {
152 /// An envelope with the production global query cap.
153 pub fn new() -> Self {
154 Envelope::with_query_permits(GLOBAL_MAX_CONCURRENT_QUERIES)
155 }
156
157 /// An envelope with a chosen global query cap — the seam tests
158 /// use to exercise exhaustion without four real graph loads.
159 pub fn with_query_permits(permits: usize) -> Self {
160 Envelope {
161 query_permits: Arc::new(Semaphore::new(permits)),
162 index_permits: Mutex::new(HashMap::new()),
163 }
164 }
165
166 /// Waits for a global query slot. The semaphore is never closed,
167 /// so acquisition cannot fail.
168 pub async fn acquire_query(&self) -> OwnedSemaphorePermit {
169 self.query_permits
170 .clone()
171 .acquire_owned()
172 .await
173 .expect("the query semaphore is never closed")
174 }
175
176 /// Takes a global query slot without waiting, if one is free.
177 pub fn try_acquire_query(&self) -> Option<OwnedSemaphorePermit> {
178 self.query_permits.clone().try_acquire_owned().ok()
179 }
180
181 /// Takes this key's index-operation slot without waiting.
182 ///
183 /// `None` means the key already runs `max_concurrent` index
184 /// operations (or the permit map's lock was poisoned, in which
185 /// case refusing is again the safe direction). Index operations
186 /// refuse rather than queue: a queued creation would hold a
187 /// connection open for the length of someone else's clone, and a
188 /// client can retry a `429` the moment its own first build ends.
189 pub fn try_acquire_index_op(
190 &self,
191 key_id: &str,
192 max_concurrent: usize,
193 ) -> Option<OwnedSemaphorePermit> {
194 let semaphore = {
195 let mut map = self.index_permits.lock().ok()?;
196 map.entry(key_id.to_string())
197 .or_insert_with(|| Arc::new(Semaphore::new(max_concurrent)))
198 .clone()
199 };
200 semaphore.try_acquire_owned().ok()
201 }
202}
203
204impl Default for Envelope {
205 fn default() -> Self {
206 Envelope::new()
207 }
208}
209
210/// Middleware over the query routes: the global concurrency cap and
211/// the caller's per-plan query timeout.
212///
213/// Runs inside the auth layer (which has already attached
214/// [`KeyPlan`]), so the timeout is the authenticated key's own plan
215/// value; a request with no plan attached — the router served
216/// without the auth wrapper, as in some tests — gets the cap but no
217/// timeout. The permit is held for the whole request. On timeout the
218/// answer is `504 request_timeout`: the handler future is dropped,
219/// though blocking work it already spawned runs to completion in
220/// the background (a `spawn_blocking` task cannot be cancelled), so
221/// a timed-out request's tail may briefly outlive its permit — the
222/// cap bounds admitted requests, not physics.
223pub async fn query_envelope(
224 State(envelope): State<Arc<Envelope>>,
225 request: Request,
226 next: Next,
227) -> Response {
228 let plan = request
229 .extensions()
230 .get::<KeyPlan>()
231 .map(|plan| plan.as_str().to_string());
232 let timeout = plan
233 .as_deref()
234 .map(|name| plan_limits(name).query_timeout)
235 .unwrap_or(None);
236 let _permit = envelope.acquire_query().await;
237 match within(timeout, next.run(request)).await {
238 Some(response) => response,
239 None => ApiError::request_timeout(
240 plan.as_deref().unwrap_or(""),
241 timeout.map(|limit| limit.as_secs()).unwrap_or(0),
242 )
243 .into_response(),
244 }
245}
246
247/// `Some` of the future's output if it finishes within `limit`;
248/// `None` (no limit) always waits. Factored out of the middleware so
249/// the boundary is testable without a 30-second test.
250async fn within<T>(limit: Option<Duration>, future: impl Future<Output = T>) -> Option<T> {
251 match limit {
252 None => Some(future.await),
253 Some(limit) => tokio::time::timeout(limit, future).await.ok(),
254 }
255}
256
257#[cfg(test)]
258mod tests {
259 use super::*;
260 use crate::keys::DEFAULT_PLAN;
261
262 #[test]
263 fn hosted_plan_carries_the_offer_numbers() {
264 let limits = plan_limits(DEFAULT_PLAN);
265 assert_eq!(limits.max_active_indexes, Some(25));
266 assert_eq!(limits.max_queries_per_month, Some(10_000));
267 assert_eq!(limits.max_files_per_index, Some(50_000));
268 assert_eq!(
269 limits.query_timeout,
270 Some(Duration::from_secs(30)),
271 "a query timeout exists and is the documented value"
272 );
273 assert_eq!(limits.max_concurrent_index_ops, Some(1));
274 assert_eq!(limits.ingest, IngestCaps::HOSTED);
275 }
276
277 #[test]
278 fn self_hosted_plan_is_unlimited() {
279 let limits = plan_limits(SELF_HOSTED_PLAN);
280 assert_eq!(limits.max_active_indexes, None);
281 assert_eq!(limits.max_queries_per_month, None);
282 assert_eq!(limits.max_files_per_index, None);
283 assert_eq!(limits.query_timeout, None);
284 assert_eq!(limits.max_concurrent_index_ops, None);
285 assert_eq!(limits.ingest, IngestCaps::UNLIMITED);
286 }
287
288 #[test]
289 fn an_unknown_plan_gets_the_tenant_envelope_not_the_operators() {
290 // An admin can provision any plan string; a typo must not
291 // mint an unlimited key.
292 assert_eq!(plan_limits("hosed"), plan_limits(DEFAULT_PLAN));
293 assert_eq!(plan_limits(""), plan_limits(DEFAULT_PLAN));
294 }
295
296 #[test]
297 fn global_query_permits_run_out_at_the_cap() {
298 let envelope = Envelope::with_query_permits(2);
299 let first = envelope.try_acquire_query().expect("first slot");
300 let second = envelope.try_acquire_query().expect("second slot");
301 assert!(envelope.try_acquire_query().is_none(), "cap reached");
302 drop(first);
303 assert!(
304 envelope.try_acquire_query().is_some(),
305 "a freed slot can be taken again"
306 );
307 drop(second);
308 }
309
310 #[test]
311 fn index_op_permits_are_per_key_and_refuse_when_taken() {
312 let envelope = Envelope::new();
313 let held = envelope
314 .try_acquire_index_op("tenant-a", 1)
315 .expect("first index op takes the slot");
316 assert!(
317 envelope.try_acquire_index_op("tenant-a", 1).is_none(),
318 "the same key's second op is refused at a cap of one"
319 );
320 assert!(
321 envelope.try_acquire_index_op("tenant-b", 1).is_some(),
322 "another key has its own slot"
323 );
324 drop(held);
325 assert!(
326 envelope.try_acquire_index_op("tenant-a", 1).is_some(),
327 "the slot returns when the op ends"
328 );
329 }
330
331 #[tokio::test]
332 async fn within_reports_a_miss_past_the_limit() {
333 let slow = within(
334 Some(Duration::from_millis(10)),
335 tokio::time::sleep(Duration::from_secs(60)),
336 )
337 .await;
338 assert!(slow.is_none(), "a future past its limit is a miss");
339 let fast = within(Some(Duration::from_secs(60)), async { 7 }).await;
340 assert_eq!(fast, Some(7));
341 let unbounded = within(None, async { 9 }).await;
342 assert_eq!(unbounded, Some(9), "no limit always waits");
343 }
344}