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}