Skip to main content

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