fusillade_core/manager.rs
1//! Main traits for the batching system.
2//!
3//! This module defines the `Storage` and `RequestManager` traits, which provide the interface
4//! for persisting requests, creating files, launching batches, and checking execution status.
5
6use crate::batch::{
7 Batch, BatchId, BatchInput, BatchStatus, File, FileContentItem, FileFilter, FileId,
8 FileStreamItem, FileStreamResult, ListBatchesFilter, OutputFileType, RequestTemplateInput,
9};
10use crate::daemon_record::{AnyDaemonRecord, DaemonRecord, DaemonState, DaemonStatus};
11use crate::error::Result;
12use crate::request::{
13 AnyRequest, CascadeTargetState, Claimed, CreateFlexInput, CreateRealtimeInput, DaemonId,
14 ListRequestsFilter, PersistCompletedRealtimeInput, Request, RequestDetail, RequestId,
15 RequestListResult, RequestState, ServiceTierFilter,
16};
17use async_trait::async_trait;
18use chrono::{DateTime, Utc};
19use futures::stream::Stream;
20use std::collections::HashMap;
21use std::pin::Pin;
22
23/// Liveness state of a model on internal (self-hosted) infrastructure, as
24/// published by the controller into the `model_filters` append-only event log.
25///
26/// `model_filters` is an event log, not a current-state table: the CURRENT
27/// state of a model is the latest event for it. An `Absent` event is an
28/// explicit tombstone (the controller retracted the model); a model with no events at
29/// all is also treated as absent. The daemon treats absence as "claim now,
30/// route to OpenRouter".
31#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
32#[serde(rename_all = "lowercase")]
33pub enum ModelFilterState {
34 /// Internal infrastructure is serving this model now.
35 Live,
36 /// Internal infrastructure will serve this model soon; `expected_ready_at`
37 /// carries the ETA.
38 Coming,
39 /// The controller is draining this model: it has decided to scale the model to
40 /// zero, but the workers are still up finishing their in-flight requests (so it
41 /// stays listed in the gateway's `/v1/models`). Distinguished from `Absent` so
42 /// observers and the controller can tell "scaling down, still serving" from
43 /// "gone", but treated identically to `Coming`/`Absent` by the claim gate
44 /// (not-live → no new full-capacity claims).
45 Leaving,
46 /// Explicit tombstone: the controller is no longer deploying this model.
47 /// Appended (instead of deleting rows) to retract a model from the log. Treated
48 /// by the claim gate as NOT-LIVE — the same leaky-bucket + deadline-ramp path as
49 /// `Coming`/`Leaving`. NOTE: this is *not* the same as a model with **no events
50 /// at all**: the gate claims a no-events model at full capacity (it is unmanaged
51 /// — `mf.state IS NULL`), whereas an `Absent` model is explicitly held not-live.
52 Absent,
53}
54
55impl ModelFilterState {
56 /// The textual value stored in `model_filters.state`.
57 pub fn as_str(self) -> &'static str {
58 match self {
59 ModelFilterState::Live => "live",
60 ModelFilterState::Coming => "coming",
61 ModelFilterState::Leaving => "leaving",
62 ModelFilterState::Absent => "absent",
63 }
64 }
65
66 /// Parse the textual `model_filters.state` value.
67 pub fn parse_state(s: &str) -> Option<Self> {
68 match s {
69 "live" => Some(ModelFilterState::Live),
70 "coming" => Some(ModelFilterState::Coming),
71 "leaving" => Some(ModelFilterState::Leaving),
72 "absent" => Some(ModelFilterState::Absent),
73 _ => None,
74 }
75 }
76}
77
78/// A single `model_filters` event describing a model's internal-liveness
79/// transition.
80///
81/// `expected_ready_at` is only meaningful when `state == Coming`; for `Live`
82/// and `Absent` it should be `None`.
83#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
84pub struct ModelFilter {
85 /// Model name (NOT unique — many events per model in the log).
86 pub model: String,
87 /// Liveness state recorded by this event.
88 pub state: ModelFilterState,
89 /// ETA when `state == Coming`.
90 pub expected_ready_at: Option<chrono::DateTime<chrono::Utc>>,
91}
92
93/// Storage trait for persisting and querying requests.
94///
95/// This trait provides atomic operations for request lifecycle management.
96/// The type system ensures valid state transitions, so implementations don't
97/// need to validate them.
98#[async_trait]
99pub trait Storage: Send + Sync {
100 /// Create a new file with templates.
101 async fn create_file(
102 &self,
103 name: String,
104 description: Option<String>,
105 templates: Vec<RequestTemplateInput>,
106 ) -> Result<FileId>;
107
108 /// Create a new file with templates from a stream.
109 ///
110 /// The stream yields FileStreamItem which can be either:
111 /// - Metadata: File metadata (can appear anywhere, will be accumulated)
112 /// - Template: Request templates (processed as they arrive)
113 /// - Abort: Producer initiated rollback without treating it as a fusillade error
114 async fn create_file_stream<S: Stream<Item = FileStreamItem> + Send + Unpin>(
115 &self,
116 stream: S,
117 ) -> Result<FileStreamResult>;
118
119 /// Get a file by ID.
120 async fn get_file(&self, file_id: FileId) -> Result<File>;
121
122 /// Get a file by ID from the primary pool for read-after-write consistency.
123 ///
124 /// Use this immediately after creating or modifying a file to ensure you read
125 /// the latest committed data. For normal reads, use `get_file()` which may use
126 /// read replicas for better performance.
127 async fn get_file_from_primary_pool(&self, file_id: FileId) -> Result<File>;
128
129 /// List files with optional filtering.
130 async fn list_files(&self, filter: FileFilter) -> Result<Vec<File>>;
131
132 /// Get all content for a file.
133 async fn get_file_content(&self, file_id: FileId) -> Result<Vec<FileContentItem>>;
134
135 /// Stream file content.
136 /// Returns different content types based on the file's purpose:
137 /// - Regular files (purpose='batch'): RequestTemplateInput
138 /// - Batch output files (purpose='batch_output'): BatchOutputItem
139 /// - Batch error files (purpose='batch_error'): BatchErrorItem
140 ///
141 /// # Arguments
142 /// * `file_id` - The file ID to stream content from
143 /// * `offset` - Number of lines to skip (0-indexed)
144 /// * `search` - Optional filter by custom_id (case-insensitive substring match)
145 fn get_file_content_stream(
146 &self,
147 file_id: FileId,
148 offset: usize,
149 search: Option<String>,
150 ) -> Pin<Box<dyn Stream<Item = Result<FileContentItem>> + Send>>;
151
152 /// Get aggregated statistics for request templates grouped by model.
153 /// This is optimized for cost estimation - it only fetches model names and body sizes,
154 /// avoiding the overhead of streaming full template data.
155 ///
156 /// Returns a vector of per-model statistics including request count and total body bytes.
157 async fn get_file_template_stats(
158 &self,
159 file_id: FileId,
160 ) -> Result<Vec<crate::batch::ModelTemplateStats>>;
161
162 /// Delete a file (cascades to batches and executions).
163 async fn delete_file(&self, file_id: FileId) -> Result<()>;
164
165 /// Create a batch from a file's current templates.
166 ///
167 /// Convenience method that calls [`create_batch_record`] to insert the batch
168 /// row, then [`populate_batch`] to copy templates into requests. Returns the
169 /// fully-populated batch.
170 async fn create_batch(&self, input: BatchInput) -> Result<Batch>;
171
172 /// Create a batch record with virtual output/error files, without populating requests.
173 ///
174 /// Inserts the batch row and creates virtual output/error files so their IDs
175 /// are available in the API response immediately.
176 /// Returns a batch in `"validating"` status (`requests_started_at` is NULL).
177 /// `total_requests` will be set from `input.total_requests` if provided, or `0` otherwise.
178 /// Use [`populate_batch`] to copy templates into requests afterward.
179 async fn create_batch_record(&self, input: BatchInput) -> Result<Batch>;
180
181 /// Populate an existing batch with requests from its file's templates.
182 ///
183 /// Copies templates into the requests table and updates the batch with
184 /// total_requests and requests_started_at.
185 /// If the file has no templates, returns a [`ValidationError`](crate::FusilladeError::ValidationError)
186 /// and the caller is responsible for marking the batch as failed.
187 async fn populate_batch(&self, batch_id: BatchId, file_id: FileId) -> Result<()>;
188
189 /// Get a batch by ID.
190 ///
191 /// # Arguments
192 /// * `batch_id` - The batch ID to retrieve
193 async fn get_batch(&self, batch_id: BatchId) -> Result<Batch>;
194
195 /// Get batch status.
196 ///
197 /// # Arguments
198 /// * `batch_id` - The batch ID to retrieve status for
199 async fn get_batch_status(&self, batch_id: BatchId) -> Result<BatchStatus>;
200
201 /// List all batches for a file.
202 ///
203 /// # Arguments
204 /// * `file_id` - The file ID to list batches for
205 async fn list_file_batches(&self, file_id: FileId) -> Result<Vec<BatchStatus>>;
206
207 /// List batches with optional filtering and cursor-based pagination.
208 /// Returns batches sorted by created_at DESC (or active-first when `active_first` is set).
209 ///
210 /// See [`ListBatchesFilter`] for available filter options including:
211 /// - `created_by` - Filter by batch creator user ID
212 /// - `search` - Case-insensitive substring match against metadata JSON text,
213 /// input filename, or batch ID
214 /// - `after` / `limit` - Cursor-based pagination (limit defaults to 100 if not set)
215 /// - `api_key_ids` - Filter by API key UUID(s) that created the batch (for per-member attribution)
216 /// - `status` - Filter by batch status. Supported values:
217 /// `"in_progress"`, `"completed"`, `"failed"`, `"cancelled"`, `"expired"`.
218 /// `"in_progress"` covers all non-terminal batches (including validating and finalizing
219 /// sub-states). `"cancelled"` includes batches that are still cancelling.
220 /// `"expired"` matches batches with SLA issues: in-progress past their deadline,
221 /// or terminal batches that finished after their deadline.
222 /// Unrecognized values return an error.
223 /// - `created_after` / `created_before` - Time range filter on batch creation timestamp
224 /// - `active_first` - When true, sorts active batches before terminal ones
225 /// (completed, failed, cancelled, or cancelling), with each group sorted by
226 /// created_at DESC. Cancelling batches are terminal because cancel_batch sets
227 /// both timestamps atomically. Cursor pagination respects this ordering.
228 async fn list_batches(&self, filter: ListBatchesFilter) -> Result<Vec<Batch>>;
229
230 /// Get a batch by its output or error file ID.
231 async fn get_batch_by_output_file_id(
232 &self,
233 file_id: FileId,
234 file_type: OutputFileType,
235 ) -> Result<Option<Batch>>;
236
237 /// Get all requests for a batch.
238 async fn get_batch_requests(&self, batch_id: BatchId) -> Result<Vec<AnyRequest>>;
239
240 /// Stream batch results with merged input/output data.
241 ///
242 /// Returns a stream of BatchResultItem, each containing:
243 /// - The original input body from the request template
244 /// - The response body (for completed requests)
245 /// - The error message (for failed requests)
246 /// - The current status
247 ///
248 /// # Arguments
249 /// * `batch_id` - The batch to get results for
250 /// * `offset` - Number of results to skip (for pagination)
251 /// * `search` - Optional custom_id filter (case-insensitive substring match)
252 /// * `status` - Optional status filter (completed, failed, pending, in_progress)
253 fn get_batch_results_stream(
254 &self,
255 batch_id: BatchId,
256 offset: usize,
257 search: Option<String>,
258 status: Option<String>,
259 ) -> Pin<Box<dyn Stream<Item = Result<crate::batch::BatchResultItem>> + Send>>;
260
261 /// Given a list of batch IDs, return those that have been cancelled (cancelling_at IS NOT NULL).
262 async fn get_cancelled_batch_ids(&self, batch_ids: &[BatchId]) -> Result<Vec<BatchId>>;
263
264 /// Cancel all pending/in-progress requests for a batch.
265 async fn cancel_batch(&self, batch_id: BatchId) -> Result<()>;
266
267 /// Transition in-flight child requests (pending, claimed, processing) to a
268 /// terminal state after a batch has been cancelled, failed, or expired.
269 ///
270 /// Intended to be called asynchronously by the caller after the batch has
271 /// already reached a terminal state. Requests already in a terminal state
272 /// (completed, failed, canceled) are left untouched.
273 ///
274 /// Returns the number of rows updated.
275 async fn cascade_batch_state_to_requests(
276 &self,
277 batch_id: BatchId,
278 target_state: CascadeTargetState,
279 ) -> Result<u64>;
280
281 /// Soft-delete a batch by setting `deleted_at`.
282 ///
283 /// The batch row is marked deleted and (if not already terminal) cancelled
284 /// in the same UPDATE. Child requests and their templates are not touched
285 /// inline — they are hidden from active views via the `deleted_at` filter
286 /// and hard-deleted asynchronously by the orphan-purge daemon (see
287 /// `purge_orphaned_rows`) for right-to-erasure compliance.
288 async fn delete_batch(&self, batch_id: BatchId) -> Result<()>;
289
290 /// Hard-delete a single request row for right-to-erasure compliance.
291 ///
292 /// Removes the `requests` row and, if its template is batchless
293 /// (`file_id IS NULL`, dedicated 1:1 to this request), the
294 /// `request_templates` row as well — batchless templates carry the
295 /// prompt body, so leaving them defeats the erasure. File-backed
296 /// templates (shared across siblings in a batch) are not touched here;
297 /// the orphan-purge daemon cleans those up after the parent file is
298 /// soft-deleted.
299 ///
300 /// FK behavior on the deleted `requests` row:
301 /// * `response_steps.request_id` → `ON DELETE CASCADE`: removes only the
302 /// step row(s) whose `request_id` matches this request. After migration
303 /// `20260430000000` (response_steps re-anchoring), each step points at
304 /// its own per-step sub-request fusillade row, so deleting one request
305 /// only cascade-removes that step. Callers wanting to erase a whole
306 /// multi-step response chain must walk the chain and call this method
307 /// for each backing request.
308 /// * Self-references `escalated_from_request_id` / `superseded_by_request_id`
309 /// → `ON DELETE SET NULL`, so sibling rows lose their pointer cleanly.
310 ///
311 /// In-flight handling: this is an unconditional hard delete. A daemon mid-
312 /// update on the row sees 0 rows affected on its next write; a streaming
313 /// proxy mid-INSERT of `response_steps` FK-violates (logged, not corrupted).
314 /// Both are acceptable for explicit user-initiated erasure.
315 ///
316 /// Unlike [`Self::delete_batch`] (soft-delete + async purge), this is
317 /// immediate because the caller has resolved a specific request to erase.
318 ///
319 /// Returns `RequestNotFound` if the request does not exist (or was already
320 /// deleted).
321 async fn delete_request(&self, request_id: RequestId) -> Result<()>;
322
323 /// Erase all of a creator's fusillade data, for right-to-erasure compliance.
324 ///
325 /// Processes up to `batch_size` rows per category per call, using
326 /// `FOR UPDATE SKIP LOCKED` so it is safe to run concurrently and under
327 /// load. Returns the count of *top-level* rows processed this call —
328 /// batchless requests deleted plus batches and files soft-deleted. It does
329 /// NOT include the batchless templates removed alongside those requests, nor
330 /// the batch/file child rows the purge daemon reaps later. It is purely a
331 /// loop-termination signal: callers should loop until it returns 0 to drain
332 /// everything. A `batch_size < 1` returns 0 (nothing to do). Idempotent.
333 ///
334 /// Three categories, keyed on `created_by` / `uploaded_by = creator_id`:
335 /// * **Batchless requests** (`batch_id IS NULL` — realtime/flex) are
336 /// *hard*-deleted along with their batchless `request_templates` (which
337 /// carry the prompt body). The orphan-purge daemon never reaches these
338 /// because they have no soft-deleted parent batch, so they must be
339 /// removed here or the erasure is incomplete.
340 /// * **Batches** are soft-deleted (cancelled if active) with `metadata`
341 /// nullified (it can contain the user's email). Their child requests are
342 /// hard-deleted afterwards by the orphan-purge daemon (`purge_orphaned_rows`).
343 /// * **Files** are soft-deleted; their `request_templates` are likewise
344 /// reaped by the orphan-purge daemon once `files.deleted_at` is set.
345 ///
346 /// Note: completion is therefore eventually-consistent — when this returns
347 /// 0, all batches/files are soft-deleted and batchless rows are gone, but
348 /// batch/file child rows are erased on the next purge-daemon pass.
349 async fn bulk_delete_data(&self, creator_id: &str, batch_size: i64) -> Result<u64>;
350
351 /// Retry failed requests by resetting them to pending state.
352 ///
353 /// This resets the specified failed requests to pending state with retry_attempt = 0,
354 /// allowing them to be picked up by the daemon for reprocessing.
355 ///
356 /// # Arguments
357 /// * `ids` - Request IDs to retry
358 ///
359 /// # Returns
360 /// A vector of results, one for each request ID. Each result indicates whether
361 /// the retry succeeded or failed.
362 ///
363 /// # Errors
364 /// Individual retry results may fail if:
365 /// - Request ID doesn't exist
366 /// - Request is not in failed state
367 async fn retry_failed_requests(&self, ids: Vec<RequestId>) -> Result<Vec<Result<()>>>;
368
369 /// Retry a batch: re-pend its FAILED and CANCELED requests in a single
370 /// database operation (completed requests are never redone).
371 ///
372 /// Retry drives the batch back toward completion and overturns
373 /// cancellation — the batch's terminal timestamps, cancellation stamps,
374 /// and frozen counts are all reset, so cancel can serve as a pause that
375 /// retry resumes. (The name predates canceled-row support and is kept
376 /// for API stability.)
377 ///
378 /// This is much more efficient than `retry_failed_requests`, as it
379 /// performs bulk UPDATEs instead of loading requests into memory.
380 ///
381 /// # Returns
382 /// The number of requests that were retried (failed + canceled).
383 async fn retry_failed_requests_for_batch(&self, batch_id: BatchId) -> Result<u64>;
384
385 /// Get request counts grouped by model and deadline window.
386 ///
387 /// Each window is the half-open interval `[now + start_secs, now + end_secs)`
388 /// applied to each request's deadline. A request is counted in a
389 /// window if its deadline falls inside that range. Because the end is
390 /// exclusive, adjacent windows (e.g. `(_, Some(0), 3600)` and
391 /// `(_, Some(3600), 86400)`) never double-count a request sitting on the
392 /// boundary.
393 ///
394 /// A request's deadline is its batch's `expires_at`. Batchless rows
395 /// (flex/async responses, `batch_id IS NULL`) have no batch expiry, so their
396 /// deadline is synthesized as `created_at + W`, where `W` is mapped from the
397 /// row's `service_tier` via `DaemonConfig.service_tier_completion_windows_ms`
398 /// (`'flex'` → 1h by default, NULL/unmapped → `default_completion_window_ms`,
399 /// 24h) — the same window the claim path uses, so reported queue depth
400 /// matches what the daemon will claim.
401 ///
402 /// `start_secs` is optional. When `None`, the lower bound is unbounded
403 /// (the query matches every request with a deadline strictly before
404 /// `now + end_secs`, including overdue ones). Callers that want the
405 /// legacy "due within N, including overdue" semantics pass
406 /// `(label, None, N)`. Callers that specifically want the "future N
407 /// seconds" starting at `now` pass `(label, Some(0), N)`.
408 ///
409 /// - `windows`: Vec of `(label, start_secs, end_secs)`. When `start_secs`
410 /// is `Some(s)`, `s` must be `<= end_secs`.
411 /// - `states`: request states to include (e.g. `["pending"]`, or
412 /// `["pending","claimed","processing"]`).
413 /// - `model_filter`: optional model whitelist (empty = all).
414 /// - `service_tier_filter`: filter on `service_tier`. `Any` (default) applies
415 /// no filter; `Include`/`Exclude` use `Option<String>` where `None`
416 /// represents the batch tier (`service_tier IS NULL`).
417 /// - `priority_decay_window`: optional lookback in seconds. When set,
418 /// recently completed `service_tier = 'flex'` requests are added to
419 /// the `"1h"` bucket so realtime traffic can decay out of scheduling
420 /// pressure after successful completion. No effect if the requested
421 /// windows do not include a `"1h"` label.
422 /// - `strict`: bool. For critical/sensitive operations, set `true` to
423 /// use the write pool and avoid read lags.
424 ///
425 /// Excludes:
426 /// - Requests without a template_id
427 /// - Requests in batches being cancelled
428 async fn get_pending_request_counts_by_model_and_window(
429 &self,
430 windows: &[(String, Option<i64>, i64)],
431 states: &[String],
432 model_filter: &[String],
433 service_tier_filter: &ServiceTierFilter,
434 priority_decay_window: Option<i64>,
435 strict: bool,
436 ) -> Result<HashMap<String, HashMap<String, i64>>>;
437
438 /// Sum the `total_requests` of a creditor's batches for a given completion
439 /// window created on or after `cutoff`.
440 ///
441 /// Used by the control layer to enforce the unverified upload-volume cap at
442 /// batch creation: an unverified creditor may submit at most
443 /// `unverified_requests_per_completion_hour * window_hours` requests within
444 /// a rolling window equal to the completion window. Served by
445 /// `idx_batches_completion_window (completion_window, created_by)`.
446 ///
447 /// - `owner`: the batch `created_by` — the creditor (organization id for org
448 /// members, user id otherwise).
449 /// - `cutoff`: only batches with `created_at >= cutoff` are counted.
450 /// - `strict`: set `true` to read from the write pool and avoid read lag, so
451 /// a just-created batch is reflected immediately (required for enforcement).
452 async fn sum_owner_batch_requests_in_window(
453 &self,
454 owner: &str,
455 completion_window: &str,
456 cutoff: DateTime<Utc>,
457 strict: bool,
458 ) -> Result<i64>;
459
460 /// Count a creditor's batchless `flex` requests created on or after `cutoff`.
461 ///
462 /// The flex counterpart of [`Storage::sum_owner_batch_requests_in_window`]:
463 /// flex requests are batchless (`batch_id IS NULL`, attribution via
464 /// `requests.created_by`) and always map to the 1h completion window. Served
465 /// by `idx_requests_user_created_sort (created_by, created_at DESC, id DESC,
466 /// service_tier) WHERE created_by IS NOT NULL`.
467 ///
468 /// - `owner`: the request `created_by` — the creditor id.
469 /// - `cutoff`: only requests with `created_at >= cutoff` are counted.
470 /// - `strict`: set `true` to read from the write pool and avoid read lag.
471 async fn count_owner_flex_requests_since(
472 &self,
473 owner: &str,
474 cutoff: DateTime<Utc>,
475 strict: bool,
476 ) -> Result<i64>;
477 ///
478 /// Cancel one or more individual pending or in-progress requests.
479 ///
480 /// Requests that have already completed or failed cannot be canceled.
481 /// This is a best-effort operation - some requests may have already been processed.
482 ///
483 /// Returns a result for each request ID indicating whether cancellation succeeded.
484 ///
485 /// # Errors
486 /// Individual cancellation results may fail if:
487 /// - Request ID doesn't exist
488 /// - Request is already in a terminal state (completed/failed)
489 #[tracing::instrument(skip(self, ids), fields(count = ids.len()))]
490 async fn cancel_requests(&self, ids: Vec<RequestId>) -> Result<Vec<Result<()>>> {
491 tracing::debug!(count = ids.len(), "Cancelling requests");
492
493 let mut results = Vec::new();
494
495 for id in ids {
496 // Get the request from storage
497 let get_results = self.get_requests(vec![id]).await?;
498 let request_result = get_results.into_iter().next().unwrap();
499
500 let result = match request_result {
501 Ok(any_request) => match any_request {
502 AnyRequest::Pending(req) => {
503 req.cancel(self).await?;
504 Ok(())
505 }
506 AnyRequest::Claimed(req) => {
507 req.cancel(self).await?;
508 Ok(())
509 }
510 AnyRequest::Processing(req) => {
511 req.cancel(self).await?;
512 Ok(())
513 }
514 AnyRequest::Completed(_) | AnyRequest::Failed(_) | AnyRequest::Canceled(_) => {
515 Err(crate::error::FusilladeError::InvalidState(
516 id,
517 "terminal state".to_string(),
518 "cancellable state".to_string(),
519 ))
520 }
521 },
522 Err(e) => Err(e),
523 };
524
525 results.push(result);
526 }
527
528 Ok(results)
529 }
530
531 /// Get in progress requests by IDs.
532 async fn get_requests(&self, ids: Vec<RequestId>) -> Result<Vec<Result<AnyRequest>>>;
533
534 // These methods are used by the DaemonExecutor for pulling requests, and then persisting their
535 // states as they iterate through them
536
537 /// Atomically claim pending batchless requests for processing.
538 ///
539 /// `available_capacity` maps model names to the number of permits the daemon
540 /// is currently holding for that model. Only models present in this map will
541 /// be claimed — this is the authoritative set of models to process.
542 ///
543 /// `user_active_counts` maps user identifiers to their current number of
544 /// in-flight requests across all models. Used to prioritise users with fewer
545 /// active requests for per-user fair scheduling. Pass an empty map to disable
546 /// user-level prioritisation (falls back to deadline-only ordering).
547 ///
548 /// Implementations may blend user-fairness with SLA urgency (batch deadline
549 /// proximity) via `DaemonConfig::urgency_weight`. See the PostgreSQL
550 /// implementation for the composite scoring formula.
551 ///
552 /// The claim gate consults the latest `model_filters` event per model:
553 /// `state = 'live'` **or no events at all** ⇒ claim at full capacity (a
554 /// model with no events is unmanaged by the controller, so there is no
555 /// internal capacity to wait for — it flows straight through to OpenRouter).
556 /// An EXPLICIT not-live event (`coming`/`absent`) ⇒ the request is either
557 /// claimed at full capacity (→ OpenRouter) when within `ramp(W)` of its
558 /// completion-window deadline, or otherwise released only via the
559 /// per-`(user, window-class, model)` leaky bucket. So with an empty `model_filters`
560 /// table the gate is a no-op (everything claims at full capacity) — it only
561 /// engages once the controller starts writing not-live events.
562 ///
563 /// `leak_cooldown` is the set of `(user, window-class, model)` triples whose
564 /// leaky bucket has no token this cycle (the daemon stamped `next_token_at`
565 /// in the future after a recent leak). Source B skips these triples, claiming
566 /// ≤ 1 per `(user, window-class, model)` not in cooldown. Pass an empty set
567 /// to allow every bucket its first token. Claimed rows carry a `leaked` flag
568 /// (via the returned request) so the daemon knows which buckets to stamp.
569 async fn claim_batchless_requests(
570 &self,
571 limit: usize,
572 daemon_id: DaemonId,
573 available_capacity: &std::collections::HashMap<String, usize>,
574 user_active_counts: &std::collections::HashMap<String, usize>,
575 leak_cooldown: &std::collections::HashSet<(String, String, String)>,
576 ) -> Result<Vec<Request<Claimed>>> {
577 self.claim_requests(
578 limit,
579 daemon_id,
580 available_capacity,
581 user_active_counts,
582 leak_cooldown,
583 )
584 .await
585 }
586
587 /// Compatibility method for callers and storage implementations that have
588 /// not yet moved to the explicit request daemon API.
589 ///
590 /// New daemon code should call [`Storage::claim_batchless_requests`] or
591 /// [`Storage::claim_batch_requests`] directly. This method is kept
592 /// as a batchless-only alias so the request and batch policies cannot be
593 /// accidentally recombined.
594 async fn claim_requests(
595 &self,
596 limit: usize,
597 daemon_id: DaemonId,
598 available_capacity: &std::collections::HashMap<String, usize>,
599 user_active_counts: &std::collections::HashMap<String, usize>,
600 leak_cooldown: &std::collections::HashSet<(String, String, String)>,
601 ) -> Result<Vec<Request<Claimed>>>;
602
603 /// Atomically claim pending requests that belong to live-model batches.
604 ///
605 /// The batch daemon owns this policy. Implementations should select
606 /// candidate batches before probing request rows, limit selected batches by
607 /// `batch_limit`, and gate on model liveness: models whose latest
608 /// `model_filters` event is `live` are always eligible; models with **no**
609 /// filter event (external / always-on providers that scouter does not
610 /// manage) are eligible unless `DaemonConfig::batch_claim_require_live` is
611 /// set; models whose latest event is `coming`/`absent` are eligible only
612 /// once the batch is within the deadline ramp (`claim_ramp_exponent`) —
613 /// the SLA escape hatch to fallback providers. No leaky-bucket trickle
614 /// applies to batched rows.
615 async fn claim_batch_requests(
616 &self,
617 limit: usize,
618 batch_limit: usize,
619 daemon_id: DaemonId,
620 available_capacity: &std::collections::HashMap<String, usize>,
621 user_active_counts: &std::collections::HashMap<String, usize>,
622 ) -> Result<Vec<Request<Claimed>>> {
623 let _ = (
624 limit,
625 batch_limit,
626 daemon_id,
627 available_capacity,
628 user_active_counts,
629 );
630 // Fail loud rather than silently claiming nothing: a backend that
631 // doesn't override this would otherwise run a batch daemon that never
632 // claims a row — invisible in production until batches stall.
633 Err(crate::error::FusilladeError::Other(anyhow::anyhow!(
634 "claim_batch_requests is not implemented for this storage backend \
635 (override it, or return false from supports_batch_claims to run \
636 the daemon request-only)"
637 )))
638 }
639
640 /// Whether this backend implements [`Storage::claim_batch_requests`].
641 ///
642 /// The daemon only spawns its batch claim loop when this returns true.
643 /// Defaults to true so a backend that forgets to override BOTH methods
644 /// fails loudly (the default `claim_batch_requests` errors) instead of
645 /// silently never claiming batched rows. A deliberately request-only
646 /// backend should override this to return false.
647 fn supports_batch_claims(&self) -> bool {
648 true
649 }
650
651 /// Append a single event to the `model_filters` log. Used by the controller
652 /// when a model's internal liveness CHANGES (live / coming / absent).
653 ///
654 /// The gate reads only `state` (live ⇒ claim full; coming/absent ⇒ not-live).
655 /// `expected_ready_at` is retained on the type/column for the controller's own
656 /// use but is **not read by the claim gate** — callers may leave it `None`.
657 ///
658 /// This is append-only: there is no delete and no upsert. Retraction is
659 /// appending an `Absent` event. Appending **only on change** (so the log
660 /// stays a transition log rather than a poll log) is the caller's
661 /// responsibility — this function always inserts a row.
662 async fn append_model_filter_event(&self, entry: &ModelFilter) -> Result<()>;
663
664 /// Append a batch of events to the `model_filters` log (one row each, in
665 /// order). Convenience for the controller publishing several transitions in
666 /// one sync. Same append-only / append-on-change semantics as
667 /// [`Storage::append_model_filter_event`].
668 async fn append_model_filter_events(&self, entries: &[ModelFilter]) -> Result<()>;
669
670 /// List the CURRENT state of every model (the latest event per model),
671 /// excluding models whose latest event is an `Absent` tombstone
672 /// (observability / tests).
673 async fn list_model_filters(&self) -> Result<Vec<ModelFilter>>;
674
675 /// The CURRENT state of every model **and when that state began**: the latest
676 /// event per model as `(state, since)`, where `since` is that event's
677 /// `created_at`. Includes `Absent` (a model's latest event may be a tombstone);
678 /// the caller decides what to do with each state.
679 ///
680 /// This is the controller's read for time-based decisions off the log — a
681 /// `Live` model's `since` is when it went live (minimum-lifetime / anti-thrash),
682 /// a `Coming` model's `since` is when it started launching (a stuck-`coming`
683 /// watchdog). The timestamp comes from the persisted log, not in-memory state,
684 /// so it survives controller restarts.
685 async fn current_filter_states(
686 &self,
687 ) -> Result<std::collections::HashMap<String, (ModelFilterState, chrono::DateTime<chrono::Utc>)>>;
688
689 /// Update an existing request's state in storage.
690 ///
691 /// Returns `Some(request_id)` if a racing pair was superseded (for cancellation purposes).
692 async fn persist<T: RequestState + Clone>(
693 &self,
694 request: &Request<T>,
695 ) -> Result<Option<RequestId>>
696 where
697 AnyRequest: From<Request<T>>;
698
699 /// Reschedule an in-flight request back to `pending` for an automatic retry,
700 /// fenced on the worker that currently owns it.
701 ///
702 /// This is the daemon's per-attempt retry path. Unlike [`Storage::persist`]
703 /// (which matches on `id` only, so the manual retry path can intentionally
704 /// resurrect a `failed` row), this transition is guarded by
705 /// `state = 'processing' AND daemon_id = <owner>`: it applies ONLY if the row
706 /// is still the in-flight claim held by `daemon_id`.
707 ///
708 /// The guard prevents a finalize-then-resurrect race: if another writer (a
709 /// zombie/duplicate worker, or a stale-claim reclaim) has already moved the
710 /// row to a terminal state — and a finalizer has sealed the parent batch as a
711 /// result — a late retry from this worker must NOT flip it back to `pending`,
712 /// orphaning it under a completed batch.
713 ///
714 /// Returns `true` if the row was rescheduled, `false` if the worker no longer
715 /// owns it (lost the race). A `false` result is normal under contention and
716 /// should be logged, not treated as an error.
717 async fn reschedule_for_retry(
718 &self,
719 request_id: RequestId,
720 owner: DaemonId,
721 retry_attempt: u32,
722 not_before: Option<chrono::DateTime<chrono::Utc>>,
723 ) -> Result<bool>;
724
725 /// List individual requests across batches with filtering and pagination.
726 ///
727 /// Supports filtering by creator, completion window, status, model(s),
728 /// date range, and active-first sorting. Uses offset-based pagination.
729 ///
730 /// Note: Token and cost metrics are NOT included — callers should join
731 /// against their own analytics tables for that data.
732 async fn list_requests(&self, filter: ListRequestsFilter) -> Result<RequestListResult>;
733
734 /// Get a single request by ID with full detail (body, response, error).
735 async fn get_request_detail(&self, request_id: RequestId) -> Result<RequestDetail>;
736
737 /// Create a realtime response that the proxy is already handling.
738 ///
739 /// Inserts a request template (no parent file) and a request row with
740 /// `batch_id = NULL` in `processing` state. The proxy completes/fails
741 /// the row directly via `complete_request` / `fail_request`; the daemon
742 /// never claims it.
743 async fn create_realtime(&self, input: CreateRealtimeInput) -> Result<RequestId>;
744
745 /// Create a flex (async) response that the daemon will process.
746 ///
747 /// Inserts a request template (no parent file) and a request row with
748 /// `batch_id = NULL` in `pending` state. The daemon claims and processes
749 /// it via the standard flex pipeline.
750 async fn create_flex(&self, input: CreateFlexInput) -> Result<RequestId>;
751
752 /// Complete a processing request with the response body.
753 ///
754 /// Transitions the request from "processing" to "completed" and stores the
755 /// response body and HTTP status code.
756 async fn complete_request(
757 &self,
758 request_id: RequestId,
759 response_body: &str,
760 status_code: u16,
761 ) -> Result<()>;
762
763 /// Fail a processing request with an error message and HTTP status code.
764 ///
765 /// Transitions the request from "processing" to "failed" and stores the
766 /// error as a `NonRetriableHttpStatus` JSON object with the given status code.
767 async fn fail_request(
768 &self,
769 request_id: RequestId,
770 error: &str,
771 status_code: u16,
772 ) -> Result<()>;
773
774 /// Persist a batch of already-completed realtime responses in one transaction.
775 ///
776 /// Designed for the dwctl responses writer: dwctl proxies a realtime
777 /// request, captures the upstream response, and flushes a buffer of
778 /// completed records here. Two cases are handled together:
779 ///
780 /// * Background realtime: a `processing` row exists (created inline by
781 /// `create_realtime` before the 202 response). UPDATEd to `completed`.
782 /// * Non-background realtime: no row exists. INSERTed (template + request)
783 /// directly in `completed` state.
784 ///
785 /// Rows already in a terminal state (rare: duplicate enqueues, late
786 /// completions for flex slip-through) are left alone via `ON CONFLICT`.
787 ///
788 /// All work runs in a single transaction so commit overhead amortises
789 /// across the batch. An empty input is a no-op.
790 async fn persist_completed_realtime_batch(
791 &self,
792 records: &[PersistCompletedRealtimeInput],
793 ) -> Result<()>;
794}
795
796/// Daemon lifecycle persistence.
797///
798/// This trait provides storage operations for tracking daemon state,
799/// including registration, heartbeat updates, and graceful shutdown.
800#[async_trait]
801pub trait DaemonStorage: Send + Sync {
802 /// Persist daemon state update.
803 ///
804 /// This is a low-level method used by state transition methods.
805 /// The type parameter `T` ensures type-safe state transitions.
806 async fn persist_daemon<T: DaemonState + Clone>(&self, record: &DaemonRecord<T>) -> Result<()>
807 where
808 AnyDaemonRecord: From<DaemonRecord<T>>;
809
810 /// Get daemon by ID.
811 ///
812 /// Returns an `AnyDaemonRecord` which can hold the daemon in any state.
813 async fn get_daemon(&self, daemon_id: DaemonId) -> Result<AnyDaemonRecord>;
814
815 /// List all daemons with optional status filter.
816 ///
817 /// If `status_filter` is `None`, returns all daemons regardless of status.
818 /// Otherwise, returns only daemons matching the specified status.
819 async fn list_daemons(
820 &self,
821 status_filter: Option<DaemonStatus>,
822 ) -> Result<Vec<AnyDaemonRecord>>;
823
824 /// Purge orphaned request_templates and requests whose parent (file or batch)
825 /// has been soft-deleted or whose FK is NULL.
826 ///
827 /// Deletes at most `batch_size` rows from each table per call.
828 /// Returns total rows deleted across both tables. Called periodically by
829 /// the daemon purge task for right-to-erasure compliance.
830 async fn purge_orphaned_rows(&self, batch_size: i64) -> Result<u64>;
831
832 /// Purge old `model_filters` events, ALWAYS retaining, per model, the most
833 /// recent `keep_per_model` events (so the current-state lookup and a short
834 /// history window survive) AND every event newer than `retention_secs`
835 /// regardless of count.
836 ///
837 /// Deletes at most `batch_size` rows per call. Returns rows deleted.
838 /// Called periodically by the daemon purge task to bound the append-only
839 /// log. `keep_per_model >= 1` guarantees the latest event per model is
840 /// never purged, so the claim gate never loses a model's current state.
841 async fn purge_model_filter_events(
842 &self,
843 batch_size: i64,
844 keep_per_model: i64,
845 retention_secs: f64,
846 ) -> Result<u64>;
847}