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