Skip to main content

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}