Skip to main content

fusillade_core/request/
query.rs

1//! Cross-batch request query types and filters.
2//!
3//! These types support listing and retrieving individual requests across batches,
4//! with server-side filtering, pagination, and sorting.
5
6use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8use uuid::Uuid;
9
10/// Default number of rows to return when limit is not specified.
11const DEFAULT_LIMIT: i64 = 50;
12
13/// Derive the service tier from the batch completion window.
14/// "1h" → "flex" (async), everything else → NULL (batch).
15pub fn service_tier_from_completion_window(completion_window: &str) -> Option<&'static str> {
16    match completion_window {
17        "1h" => Some("flex"),
18        "0s" => Some("priority"),
19        _ => None,
20    }
21}
22
23/// Filter on `service_tier`.
24///
25/// `None` in the inner vec represents the batch tier (`service_tier IS NULL`);
26/// named strings match specific tier values such as `"flex"` or `"priority"`.
27///
28/// `Default` is `Any`. Storage backends isolate the background tier from this
29/// default; callers must explicitly include `Some("background")` to expose it.
30#[derive(Debug, Clone, Default)]
31pub enum ServiceTierFilter {
32    /// No caller-supplied filter. Storage backends include ordinary named tiers
33    /// and the batch tier (NULL), but keep background isolated.
34    #[default]
35    Any,
36    /// Match only rows whose tier is in this set. Empty matches nothing.
37    Include(Vec<Option<String>>),
38    /// Match all tiers except those in this set.
39    Exclude(Vec<Option<String>>),
40}
41
42impl ServiceTierFilter {
43    /// Split a list of `Option<String>` tiers into (named_tiers, includes_null).
44    pub fn split(tiers: &[Option<String>]) -> (Vec<String>, bool) {
45        let mut names = Vec::with_capacity(tiers.len());
46        let mut has_null = false;
47        for t in tiers {
48            match t {
49                Some(s) => names.push(s.clone()),
50                None => has_null = true,
51            }
52        }
53        (names, has_null)
54    }
55}
56
57/// Filter parameters for listing requests across batches.
58#[derive(Debug, Clone)]
59pub struct ListRequestsFilter {
60    /// Filter by request creator (user ID or org ID)
61    pub created_by: Option<String>,
62    /// Filter by request state (pending, claimed, processing, completed, failed, canceled)
63    pub status: Option<String>,
64    /// Filter by model(s) — when multiple, matches any.
65    /// `None` disables model filtering. `Some(vec![])` matches no rows.
66    pub models: Option<Vec<String>>,
67    /// Only return requests created after this timestamp
68    pub created_after: Option<DateTime<Utc>>,
69    /// Only return requests created before this timestamp
70    pub created_before: Option<DateTime<Utc>>,
71    /// Filter by service tier(s). When set, only returns requests whose
72    /// `service_tier` matches one of the provided values (e.g., `["flex", "priority"]`).
73    /// Uses `= ANY($7)` which hits the composite `idx_requests_created_tier` index.
74    /// `None` disables tier filtering. `Some(vec![])` matches no rows.
75    pub service_tiers: Option<Vec<String>>,
76    /// Sort active requests (pending/claimed/processing) first
77    pub active_first: bool,
78    /// Number of rows to skip (offset pagination)
79    pub skip: i64,
80    /// Maximum number of rows to return (defaults to 50)
81    pub limit: i64,
82}
83
84impl Default for ListRequestsFilter {
85    fn default() -> Self {
86        Self {
87            created_by: None,
88            status: None,
89            models: None,
90            created_after: None,
91            created_before: None,
92            service_tiers: None,
93            active_first: false,
94            skip: 0,
95            limit: DEFAULT_LIMIT,
96        }
97    }
98}
99
100/// Summary of an individual request, suitable for list views.
101///
102/// **Row scope: batchless-only.** `list_requests` filters
103/// `WHERE r.created_by IS NOT NULL` so the planner can use the partial
104/// index `idx_requests_user_*_sort`. Batched rows are not returned here.
105/// For batched-row attribution, fetch the row's `batches.created_by`
106/// separately or use `RequestDetail` (which joins batches).
107///
108/// Note: This type does not include user email or token/cost metrics.
109/// Callers should enrich with data from their own tables (users, analytics).
110#[derive(Debug, Clone, Serialize, Deserialize)]
111#[cfg_attr(feature = "sqlx-postgres", derive(sqlx::FromRow))]
112pub struct RequestSummary {
113    pub id: Uuid,
114    pub batch_id: Option<Uuid>,
115    pub model: String,
116    #[cfg_attr(feature = "sqlx-postgres", sqlx(rename = "state"))]
117    pub status: String,
118    pub created_at: DateTime<Utc>,
119    pub completed_at: Option<DateTime<Utc>>,
120    pub failed_at: Option<DateTime<Utc>>,
121    pub duration_ms: Option<f64>,
122    pub response_status: Option<i16>,
123    pub service_tier: Option<String>,
124    /// Creator ID (user or org) for ownership checks and email lookup.
125    /// Always set: `list_requests` only returns rows where
126    /// `requests.created_by IS NOT NULL` (see struct-level docs).
127    pub created_by: String,
128}
129
130/// Internal row shape used previously when `list_requests` computed the total
131/// count via a `COUNT(*) OVER()` window function. Kept for backward
132/// compatibility with the public re-export introduced in v16.1.0; no longer
133/// constructed by this crate.
134#[allow(deprecated)]
135mod deprecated_types {
136    use super::{DateTime, Deserialize, RequestSummary, Serialize, Utc, Uuid};
137
138    #[deprecated(
139        since = "16.1.1",
140        note = "no longer used internally; use RequestSummary and RequestListResult.total_count"
141    )]
142    #[derive(Debug, Clone, Serialize, Deserialize)]
143    #[cfg_attr(feature = "sqlx-postgres", derive(sqlx::FromRow))]
144    pub struct RequestSummaryWithCount {
145        pub id: Uuid,
146        pub batch_id: Uuid,
147        pub model: String,
148        #[cfg_attr(feature = "sqlx-postgres", sqlx(rename = "state"))]
149        pub status: String,
150        pub created_at: DateTime<Utc>,
151        pub completed_at: Option<DateTime<Utc>>,
152        pub failed_at: Option<DateTime<Utc>>,
153        pub duration_ms: Option<f64>,
154        pub response_status: Option<i16>,
155        pub service_tier: Option<String>,
156        pub batch_created_by: String,
157        pub total_count: i64,
158    }
159
160    impl From<RequestSummaryWithCount> for RequestSummary {
161        fn from(r: RequestSummaryWithCount) -> Self {
162            Self {
163                id: r.id,
164                batch_id: Some(r.batch_id),
165                model: r.model,
166                status: r.status,
167                created_at: r.created_at,
168                completed_at: r.completed_at,
169                failed_at: r.failed_at,
170                duration_ms: r.duration_ms,
171                response_status: r.response_status,
172                service_tier: r.service_tier,
173                created_by: r.batch_created_by,
174            }
175        }
176    }
177}
178
179#[allow(deprecated)]
180pub use deprecated_types::RequestSummaryWithCount;
181
182/// Full detail of an individual request, including body and response.
183///
184/// **Row scope: batchless-only.** `get_request_detail` filters
185/// `WHERE r.created_by IS NOT NULL`. Per-row inspection of batched
186/// requests goes through `get_batch_results_stream` / `BatchResultItem`
187/// instead. Used by the dashboard's response-detail page and the Open
188/// Responses API.
189#[derive(Debug, Clone, Serialize, Deserialize)]
190#[cfg_attr(feature = "sqlx-postgres", derive(sqlx::FromRow))]
191pub struct RequestDetail {
192    pub id: Uuid,
193    /// Always `None` — this query is scoped to batchless rows only.
194    /// Per-row inspection of batched requests uses
195    /// `get_batch_results_stream` / `BatchResultItem`.
196    pub batch_id: Option<Uuid>,
197    pub model: String,
198    #[cfg_attr(feature = "sqlx-postgres", sqlx(rename = "state"))]
199    pub status: String,
200    pub created_at: DateTime<Utc>,
201    pub completed_at: Option<DateTime<Utc>>,
202    pub failed_at: Option<DateTime<Utc>>,
203    pub duration_ms: Option<f64>,
204    pub response_status: Option<i16>,
205    /// `None` when the template has been purged (file soft-deleted + orphan purge).
206    pub body: Option<String>,
207    pub response_body: Option<String>,
208    pub error: Option<String>,
209    pub service_tier: Option<String>,
210    /// Creator ID (user or org). Always set: `get_request_detail` only
211    /// returns rows where `requests.created_by IS NOT NULL`.
212    pub created_by: String,
213}
214
215/// Input for creating a realtime response that the proxy is already handling.
216///
217/// Inserts a request template (no parent file) and a request row in
218/// `processing` state with `batch_id = NULL` and `daemon_id = Uuid::nil()`.
219/// The proxy completes/fails the row directly via `complete_request` /
220/// `fail_request`; the daemon never claims it.
221#[derive(Debug, Clone)]
222pub struct CreateRealtimeInput {
223    /// Pre-generated request ID. Becomes the request's primary key.
224    pub request_id: Uuid,
225    /// The request body as a JSON string.
226    pub body: String,
227    /// Model identifier.
228    pub model: String,
229    /// Base URL of the target endpoint (e.g., "http://localhost:3001/ai").
230    pub endpoint: String,
231    /// HTTP method (e.g., "POST").
232    pub method: String,
233    /// API path (e.g., "/v1/responses").
234    pub path: String,
235    /// API key for the request.
236    pub api_key: String,
237    /// User/org ID that owns this request.
238    pub created_by: String,
239}
240
241/// Input for persisting a batch of already-completed realtime responses.
242///
243/// Used by the dwctl responses writer to flush a buffer of finished
244/// realtime calls in one transaction.
245///
246/// Two cases are handled in the same batch:
247///   * Background realtime: the row was pre-created in `processing` state by
248///     `create_realtime`; we UPDATE it to `completed`.
249///   * Non-background realtime: no row exists yet; we INSERT a template and
250///     a request row directly in `completed` state.
251///
252/// All synthesize fields (`request_body`, `model`, `endpoint`, `started_at`,
253/// `completed_at`, etc.) are only consulted on the INSERT path. On the UPDATE
254/// path only `request_id`, `response_body`, and `status_code` are used — the
255/// pre-existing row already carries a real `started_at` from `create_realtime`.
256#[derive(Debug, Clone)]
257pub struct PersistCompletedRealtimeInput {
258    /// The request UUID (primary key).
259    pub request_id: Uuid,
260    /// Upstream response body to store.
261    pub response_body: String,
262    /// Upstream HTTP status code.
263    pub status_code: u16,
264    /// Original request body, stored on the synthesized template.
265    pub request_body: String,
266    /// Model identifier.
267    pub model: String,
268    /// Base URL of the target endpoint.
269    pub endpoint: String,
270    /// HTTP method (e.g., "POST").
271    pub method: String,
272    /// API path (e.g., "/v1/responses").
273    pub path: String,
274    /// API key for the request.
275    pub api_key: String,
276    /// User/org ID that owns this request.
277    pub created_by: String,
278    /// Wall-clock instant the request arrived, as recorded by the caller.
279    /// INSERT path only: becomes the synthesized row's `created_at`,
280    /// `claimed_at`, and `started_at`. Ignored on the UPDATE path, where the row
281    /// already carries a real `started_at`.
282    pub started_at: DateTime<Utc>,
283    /// Wall-clock instant the response completed (`started_at` plus the caller's
284    /// measured request duration). INSERT path only: stored as the row's `completed_at` on 2xx
285    /// (so the listing's `duration_ms = completed_at - started_at` reflects the
286    /// true latency instead of zero) or as `failed_at` on non-2xx. Note
287    /// `duration_ms` is derived from the `completed_at` column, so it is NULL for
288    /// failed rows — the completion instant is still recorded there, in
289    /// `failed_at`. Ignored on the UPDATE path.
290    pub completed_at: DateTime<Utc>,
291}
292
293/// Input for creating a flex (async) response that the daemon will process.
294///
295/// Inserts a request template (no parent file) and a request row in `pending`
296/// state with `batch_id = NULL`. The daemon claims and processes it like any
297/// other pending request.
298#[derive(Debug, Clone)]
299pub struct CreateFlexInput {
300    /// Pre-generated request ID. Becomes the request's primary key.
301    pub request_id: Uuid,
302    /// The request body as a JSON string.
303    pub body: String,
304    /// Model identifier.
305    pub model: String,
306    /// Base URL of the target endpoint (e.g., "http://localhost:3001/ai").
307    pub endpoint: String,
308    /// HTTP method (e.g., "POST").
309    pub method: String,
310    /// API path (e.g., "/v1/responses").
311    pub path: String,
312    /// API key for the request.
313    pub api_key: String,
314    /// User/org ID that owns this request.
315    pub created_by: String,
316    /// Provenance for the dispatched request, stored on the template.
317    ///
318    /// The batchless equivalent of `BatchInput::metadata`: keys named in the
319    /// daemon's `batch_metadata_fields` are replayed onto the dispatched request
320    /// as `x-fusillade-batch-<key>` headers. A flex request is dispatched long
321    /// after the caller submitted it, over a client that sends no User-Agent, so
322    /// this is the only way anything about the submitter reaches the far side.
323    pub metadata: Option<serde_json::Value>,
324}
325
326/// Input for creating a background response that the daemon will process only
327/// when the target model has spare live capacity.
328///
329/// Inserts a request template (no parent file) and a request row in `pending`
330/// state with `batch_id = NULL` and `service_tier = 'background'`.
331#[derive(Debug, Clone)]
332pub struct CreateBackgroundInput {
333    /// Pre-generated request ID. Becomes the request's primary key.
334    pub request_id: Uuid,
335    /// The request body as a JSON string.
336    pub body: String,
337    /// Model identifier.
338    pub model: String,
339    /// Base URL of the target endpoint (e.g., "http://localhost:3001/ai").
340    pub endpoint: String,
341    /// HTTP method (e.g., "POST").
342    pub method: String,
343    /// API path (e.g., "/v1/responses").
344    pub path: String,
345    /// API key for the request.
346    pub api_key: String,
347    /// User/org ID that owns this request.
348    pub created_by: String,
349    /// Provenance for the dispatched request, stored on the template. See
350    /// [`CreateFlexInput::metadata`].
351    pub metadata: Option<serde_json::Value>,
352}
353
354/// Result of a paginated request list query.
355#[derive(Debug, Clone, Serialize, Deserialize)]
356pub struct RequestListResult {
357    pub data: Vec<RequestSummary>,
358    /// Best-effort total row count for the full query result.
359    ///
360    /// Returns an exact count when the count query completes within a short
361    /// internal timeout; otherwise falls back to a query-planner row estimate.
362    /// Planner estimates are typically within a few percent when table
363    /// statistics are current, but may diverge more if stats are stale.
364    pub total_count: i64,
365}
366
367#[cfg(test)]
368mod background_tests {
369    use super::*;
370
371    #[test]
372    fn background_input_has_the_async_batchless_contract() {
373        let request_id = Uuid::new_v4();
374        let input = CreateBackgroundInput {
375            request_id,
376            body: r#"{"model":"model-a"}"#.to_string(),
377            model: "model-a".to_string(),
378            endpoint: "http://localhost:3001".to_string(),
379            method: "POST".to_string(),
380            path: "/v1/responses".to_string(),
381            api_key: "test-key".to_string(),
382            created_by: "user-a".to_string(),
383            metadata: None,
384        };
385
386        assert_eq!(input.request_id, request_id);
387        assert_eq!(input.model, "model-a");
388        assert_eq!(input.created_by, "user-a");
389    }
390}