Skip to main content

plecto_control/
snapshot.rs

1//! `ConfigSnapshot` — a pinned view of one `ActiveConfig` for the span of a single request
2//! transaction (f000004 #2). `Control::on_request` and `on_response` each load the active
3//! config independently, so a reload landing *between* a request's two halves would run the
4//! request side against config A and the response side against config B — only the in-flight
5//! request at the reload instant, but asymmetric filtering nonetheless.
6//!
7//! A snapshot closes that: the fast-path server takes one snapshot per request and drives both
8//! halves through it. The snapshot holds its `Arc<ActiveConfig>` until dropped, so a concurrent
9//! reload swaps the *live* set without disturbing any transaction already in flight. Taking one
10//! is cheap — a single atomic `Arc` clone.
11
12use std::sync::Arc;
13
14use plecto_host::{HttpRequest, HttpResponse, RequestTrace};
15
16use crate::ActiveConfig;
17use crate::chain::{self, ChainOutcome, RequestBodyOutcome, ResponseOutcome};
18use crate::route::{self, RouteInfo};
19
20/// A configuration pinned for one request transaction. Obtain via [`crate::Control::snapshot`];
21/// run `on_request` then (later) `on_response` against the *same* snapshot so a reload cannot
22/// desync the two halves.
23///
24/// The snapshot also carries the request's [`RequestTrace`] (ADR 000009): both halves run
25/// under one trace context, so the request-side and response-side filter spans belong to the
26/// same trace. The host emits those spans to its sink as the chain runs.
27///
28/// `Clone` is cheap (an `Arc` clone + the trace ids) and yields the **same** pinned config and
29/// trace — the fast-path server clones one snapshot to run the request and response halves on
30/// separate `spawn_blocking` tasks while keeping them in one transaction (ADR 000013).
31#[derive(Clone)]
32pub struct ConfigSnapshot {
33    config: Arc<ActiveConfig>,
34    trace: RequestTrace,
35}
36
37impl ConfigSnapshot {
38    pub(crate) fn new(config: Arc<ActiveConfig>, trace: RequestTrace) -> Self {
39        Self { config, trace }
40    }
41
42    /// Drive a request through the **default** `[chain]` (the chain-only convenience). The
43    /// fast-path server uses [`ConfigSnapshot::find_route`] + [`ConfigSnapshot::dispatch_request`].
44    pub fn on_request(&self, request: HttpRequest) -> ChainOutcome {
45        chain::dispatch_request(&self.config.resolved_chain, request, &self.trace)
46    }
47
48    /// Drive a response back through the default `[chain]` in reverse. `request` is the
49    /// as-forwarded request snapshot every response hook sees (ADR 000073).
50    pub fn on_response(&self, request: &HttpRequest, response: HttpResponse) -> ResponseOutcome {
51        chain::dispatch_response(&self.config.resolved_chain, request, response, &self.trace)
52    }
53
54    /// Match a request to a route by its `[route.match]` dimensions — host, path prefix, method,
55    /// headers, query (ADR 000013 / 000034) — or `None` when no route matches (the server responds
56    /// 404). The most specific match wins (see [`route::select`]). Pure config lookup — cheap and
57    /// non-blocking, so it runs on the async thread; only the returned route's chain dispatch is
58    /// blocking work. Reads only borrowed request fields, so matching is allocation-free.
59    // INVARIANT: `route::select` returns an index into the very `routes` slice passed to it, so
60    // indexing `self.config.routes` with its result is always in bounds.
61    #[allow(clippy::indexing_slicing)]
62    pub fn find_route(&self, request: &HttpRequest) -> Option<RouteInfo> {
63        let parts = route::RequestParts {
64            authority: &request.authority,
65            path: &request.path,
66            method: &request.method,
67            headers: &request.headers,
68        };
69        let index = route::select(&self.config.routes, &parts)?;
70        debug_assert!(index < self.config.routes.len());
71        let r = &self.config.routes[index];
72        Some(RouteInfo {
73            index,
74            backends: r.backends.clone(),
75            strip_prefix: r.strip_prefix.clone(),
76            has_filters: !r.filters.is_empty(),
77            reads_body: r.reads_body,
78            rate_limit: r.rate_limit.clone(),
79            upgrade: r.upgrade.clone(),
80            compression: r.compression.clone(),
81        })
82    }
83
84    /// Drive a request through a matched route's chain (request side). `route` is the index from
85    /// [`ConfigSnapshot::find_route`] on this same snapshot. Returns forward-or-respond just like
86    /// `on_request`. Out-of-range (a stale index from another snapshot) responds with a
87    /// fail-closed 404 rather than panicking (data-plane no-panic, bp-rust).
88    pub fn dispatch_request(&self, route: usize, request: HttpRequest) -> ChainOutcome {
89        match self.config.routes.get(route) {
90            Some(r) => chain::dispatch_request(&r.resolved_chain, request, &self.trace),
91            None => ChainOutcome::Respond(no_route_response()),
92        }
93    }
94
95    /// Drive a buffered request body through a matched route's `on-request-body` chain (ADR 000025).
96    /// Same `route` index as the request side, on the same snapshot. The server calls this only for a
97    /// route with filters and a non-empty body; a stale index forwards the body unchanged.
98    pub fn dispatch_request_body(&self, route: usize, body: Vec<u8>) -> RequestBodyOutcome {
99        match self.config.routes.get(route) {
100            Some(r) => chain::dispatch_request_body(&r.resolved_chain, body, &self.trace),
101            None => RequestBodyOutcome::Forward(body),
102        }
103    }
104
105    /// Drive a response back through a matched route's chain in reverse. Same `route` index as
106    /// the request side, on the same (cloned) snapshot, so both halves run one route's chain.
107    /// `request` is the as-forwarded request snapshot (ADR 000073). A stale index forwards the
108    /// response unchanged (fail-soft, same as the other `dispatch_*`).
109    pub fn dispatch_response(
110        &self,
111        route: usize,
112        request: &HttpRequest,
113        response: HttpResponse,
114    ) -> ResponseOutcome {
115        match self.config.routes.get(route) {
116            Some(r) => chain::dispatch_response(&r.resolved_chain, request, response, &self.trace),
117            None => ResponseOutcome::Forward(response),
118        }
119    }
120
121    /// The `config version` (manifest content hash) this transaction is pinned to.
122    pub fn config_version(&self) -> &str {
123        &self.config.hash
124    }
125
126    /// The W3C `traceparent` for this transaction — pass downstream so the upstream request
127    /// continues the same trace (ADR 000009 propagation).
128    pub fn traceparent(&self) -> String {
129        self.trace.to_traceparent()
130    }
131}
132
133impl crate::Control {
134    /// Pin the active config for one request transaction (see [`ConfigSnapshot`]). The
135    /// fast-path server takes one snapshot per request and drives both halves through it, so a
136    /// concurrent reload cannot desync the request and response sides of the same transaction.
137    pub fn snapshot(&self) -> ConfigSnapshot {
138        self.snapshot_with_trace(RequestTrace::root())
139    }
140
141    /// Like [`Control::snapshot`], but continue an inbound trace context (ADR 000009): the
142    /// fast-path server parses the request's W3C `traceparent` into a [`RequestTrace`] and
143    /// passes it here, so the chain's spans join the caller's distributed trace instead of
144    /// starting a fresh root.
145    pub fn snapshot_with_trace(&self, trace: RequestTrace) -> ConfigSnapshot {
146        ConfigSnapshot::new(self.active.load_full(), trace)
147    }
148
149    /// Drive a request through the chain. Returns whether to forward the (possibly edited)
150    /// request upstream, or to respond now (a filter short-circuited, or the chain failed
151    /// closed on a trap / deadline). Convenience for a one-shot caller; a request transaction
152    /// that also runs a response should use [`Control::snapshot`] to pin one config.
153    pub fn on_request(&self, request: HttpRequest) -> ChainOutcome {
154        self.snapshot().on_request(request)
155    }
156
157    /// Drive a response back through the chain in reverse, applying response edits. A trapped
158    /// filter yields a fail-closed 5xx; a `replace` yields the synthesised response (both as
159    /// [`ResponseOutcome::Respond`]). See [`Control::snapshot`] for the transaction-pinned form.
160    pub fn on_response(&self, request: &HttpRequest, response: HttpResponse) -> ResponseOutcome {
161        self.snapshot().on_response(request, response)
162    }
163}
164
165/// A minimal fail-closed 404 for a `dispatch_*` called with a route index this snapshot does not
166/// have (only reachable by misuse — a stale index from a different snapshot). The fast-path
167/// server builds its own 404 for the ordinary "no route matched" case (`find_route` → `None`).
168fn no_route_response() -> HttpResponse {
169    HttpResponse {
170        status: 404,
171        headers: vec![plecto_host::Header {
172            name: "x-plecto-fault".to_string(),
173            value: b"no-route".to_vec(),
174        }],
175        body: b"no route".to_vec(),
176    }
177}