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