Skip to main content

imagegen_bridge_runtime/
sqlite_jobs.rs

1//! Durable `SQLite` job state with conservative crash recovery.
2
3use std::path::Path;
4
5use imagegen_bridge_core::{
6    BridgeError, ErrorCode, ImageJob, ImageJobProgress, ImageJobStatus, ImageJobSummary,
7    ImageRequest, ImageResponse,
8};
9use sha2::{Digest as _, Sha256};
10use tokio_rusqlite::{
11    Connection, params,
12    rusqlite::{
13        OptionalExtension as _, TransactionBehavior, params_from_iter, types::Value as SqlValue,
14    },
15};
16
17const CURRENT_MIGRATION: u32 = 3;
18const ACTIVE_RESULT_RESERVE_BYTES: u64 = 256 * 1024;
19
20/// Result of atomically submitting a durable job.
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct SqliteJobSubmission {
23    /// Canonical durable job selected by the submission identity.
24    pub job: ImageJob,
25    /// Whether this call inserted and must schedule the job.
26    pub created: bool,
27}
28
29/// Visibility constraint applied before durable history pagination.
30#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
31pub enum ImageJobVisibility {
32    /// Ordinary non-hidden history only.
33    #[default]
34    Active,
35    /// Soft-deleted history only.
36    Hidden,
37    /// Both active and hidden history.
38    All,
39}
40
41/// Server-side durable history filters applied before cursor pagination.
42#[derive(Debug, Clone, Default, PartialEq, Eq)]
43pub struct ImageJobListFilter {
44    /// Exclusive newest-first `(created,id)` cursor.
45    pub before: Option<(u64, String)>,
46    /// Maximum rows returned.
47    pub limit: usize,
48    /// Visibility selection.
49    pub visibility: ImageJobVisibility,
50    /// Optional lifecycle status.
51    pub status: Option<ImageJobStatus>,
52    /// Optional favorite state.
53    pub favorite: Option<bool>,
54    /// Optional case-insensitive literal prompt substring.
55    pub search: Option<String>,
56}
57
58/// Read-only status of the durable job database schema.
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub struct SqliteJobSchemaStatus {
61    /// Whether the database and migration table exist.
62    pub initialized: bool,
63    /// Highest applied migration.
64    pub version: Option<u32>,
65    /// Migration expected by this build.
66    pub current_version: u32,
67}
68
69/// Redaction-safe aggregate state for the durable job database.
70#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
71pub struct SqliteJobStatistics {
72    /// Total retained job rows, including soft-deleted history.
73    pub total: u64,
74    /// Jobs accepted but not yet claimed.
75    pub queued: u64,
76    /// Jobs currently recorded as running.
77    pub running: u64,
78    /// Successfully completed jobs.
79    pub succeeded: u64,
80    /// Jobs completed with a structured failure.
81    pub failed: u64,
82    /// Jobs cancelled before or during execution.
83    pub cancelled: u64,
84    /// Jobs conservatively marked after uncertain shutdown completion.
85    pub interrupted: u64,
86    /// Soft-deleted history rows.
87    pub hidden: u64,
88    /// Main `SQLite` database pages multiplied by page size, excluding WAL files.
89    pub database_bytes: u64,
90    /// Logical job-row bytes used for admission and retention accounting.
91    pub logical_bytes: u64,
92}
93
94/// Inspects job storage without creating or migrating it.
95pub async fn inspect_sqlite_job_schema(path: &Path) -> Result<SqliteJobSchemaStatus, BridgeError> {
96    let metadata = match tokio::fs::symlink_metadata(path).await {
97        Ok(metadata) => metadata,
98        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
99            return Ok(SqliteJobSchemaStatus {
100                initialized: false,
101                version: None,
102                current_version: CURRENT_MIGRATION,
103            });
104        }
105        Err(_) => return Err(job_error("could not inspect job database")),
106    };
107    if !metadata.file_type().is_file() {
108        return Err(job_error("job database must be a regular file"));
109    }
110    let flags = tokio_rusqlite::rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY;
111    let connection = Connection::open_with_flags(path, flags)
112        .await
113        .map_err(|_| job_error("could not open job database read-only"))?;
114    let version = connection
115        .call(|connection| {
116            let exists: bool = connection.query_row(
117                "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='job_schema_migrations')",
118                [],
119                |row| row.get(0),
120            )?;
121            if !exists {
122                return Ok(None);
123            }
124            connection.query_row(
125                "SELECT MAX(version) FROM job_schema_migrations",
126                [],
127                |row| row.get(0),
128            )
129        })
130        .await
131        .map_err(|_| job_error("could not inspect job database schema"))?;
132    connection
133        .close()
134        .await
135        .map_err(|_| job_error("could not close job database"))?;
136    Ok(SqliteJobSchemaStatus {
137        initialized: version.is_some(),
138        version,
139        current_version: CURRENT_MIGRATION,
140    })
141}
142
143/// Durable job state used by the HTTP job manager and history index.
144pub struct SqliteImageJobStore {
145    connection: Connection,
146}
147
148enum CreateOutcome {
149    Created(String),
150    Replay(String),
151    Conflict,
152    Full,
153}
154
155impl std::fmt::Debug for SqliteImageJobStore {
156    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
157        formatter
158            .debug_struct("SqliteImageJobStore")
159            .finish_non_exhaustive()
160    }
161}
162
163impl SqliteImageJobStore {
164    /// Opens storage and applies idempotent migrations.
165    pub async fn open(path: &Path) -> Result<Self, BridgeError> {
166        match tokio::fs::symlink_metadata(path).await {
167            Ok(metadata) if metadata.file_type().is_file() => {}
168            Ok(_) => return Err(job_error("job database must be a regular file")),
169            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
170            Err(_) => return Err(job_error("could not inspect job database")),
171        }
172        let connection = Connection::open(path)
173            .await
174            .map_err(|_| job_error("could not open job database"))?;
175        connection
176            .call(|connection| {
177                connection.execute_batch(
178                    "PRAGMA journal_mode=WAL;
179                     PRAGMA synchronous=FULL;
180                     PRAGMA busy_timeout=5000;
181                     PRAGMA foreign_keys=ON;
182                     CREATE TABLE IF NOT EXISTS job_schema_migrations (
183                       version INTEGER PRIMARY KEY,
184                       applied_at INTEGER NOT NULL
185                     );
186                     CREATE TABLE IF NOT EXISTS image_jobs (
187                       id TEXT PRIMARY KEY,
188                       status TEXT NOT NULL CHECK(status IN ('queued','running','succeeded','failed','cancelled','interrupted')),
189                       created_at INTEGER NOT NULL,
190                       updated_at INTEGER NOT NULL,
191                       started_at INTEGER,
192                       completed_at INTEGER,
193                       request_json TEXT NOT NULL,
194                       prompt_search TEXT NOT NULL DEFAULT '',
195                       progress_stage TEXT,
196                       partial_images INTEGER NOT NULL DEFAULT 0,
197                       response_json TEXT,
198                       error_json TEXT,
199                       cancel_requested INTEGER NOT NULL DEFAULT 0 CHECK(cancel_requested IN (0,1)),
200                       favorite INTEGER NOT NULL DEFAULT 0 CHECK(favorite IN (0,1)),
201                       deleted_at INTEGER
202                     );
203                     CREATE INDEX IF NOT EXISTS image_jobs_created_idx
204                       ON image_jobs(created_at DESC, id DESC);
205                     CREATE INDEX IF NOT EXISTS image_jobs_status_idx
206                       ON image_jobs(status, created_at);
207                     INSERT OR IGNORE INTO job_schema_migrations(version, applied_at)
208                       VALUES (1, unixepoch());",
209                )?;
210                let transaction = connection.transaction()?;
211                let has_prompt_search = transaction
212                    .prepare("PRAGMA table_info(image_jobs)")?
213                    .query_map([], |row| row.get::<_, String>(1))?
214                    .collect::<Result<Vec<_>, _>>()?
215                    .iter()
216                    .any(|name| name == "prompt_search");
217                if !has_prompt_search {
218                    transaction.execute_batch(
219                        "ALTER TABLE image_jobs ADD COLUMN prompt_search TEXT NOT NULL DEFAULT '';",
220                    )?;
221                }
222                let prompts = {
223                    let mut statement = transaction.prepare(
224                        "SELECT id, COALESCE(json_extract(request_json, '$.prompt'), '')
225                         FROM image_jobs WHERE prompt_search='' AND json_valid(request_json)",
226                    )?;
227                    statement
228                        .query_map([], |row| {
229                            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
230                        })?
231                        .collect::<Result<Vec<_>, _>>()?
232                };
233                for (id, prompt) in prompts {
234                    transaction.execute(
235                        "UPDATE image_jobs SET prompt_search=?2 WHERE id=?1",
236                        params![id, prompt.to_lowercase()],
237                    )?;
238                }
239                transaction.execute_batch(
240                    "CREATE INDEX IF NOT EXISTS image_jobs_history_idx
241                       ON image_jobs(deleted_at, favorite, created_at DESC, id DESC);
242                     INSERT OR IGNORE INTO job_schema_migrations(version, applied_at)
243                       VALUES (2, unixepoch());",
244                )?;
245                let columns = transaction
246                    .prepare("PRAGMA table_info(image_jobs)")?
247                    .query_map([], |row| row.get::<_, String>(1))?
248                    .collect::<Result<Vec<_>, _>>()?;
249                if !columns.iter().any(|name| name == "auth_scope") {
250                    transaction.execute_batch(
251                        "ALTER TABLE image_jobs ADD COLUMN auth_scope TEXT NOT NULL
252                           DEFAULT 'legacy-unowned';",
253                    )?;
254                }
255                if !columns.iter().any(|name| name == "submission_key_hash") {
256                    transaction.execute_batch(
257                        "ALTER TABLE image_jobs ADD COLUMN submission_key_hash TEXT;",
258                    )?;
259                }
260                if !columns.iter().any(|name| name == "request_fingerprint") {
261                    transaction.execute_batch(
262                        "ALTER TABLE image_jobs ADD COLUMN request_fingerprint TEXT;",
263                    )?;
264                }
265                transaction.execute_batch(
266                    "CREATE INDEX IF NOT EXISTS image_jobs_scope_history_idx
267                       ON image_jobs(auth_scope, deleted_at, favorite, created_at DESC, id DESC);
268                     CREATE UNIQUE INDEX IF NOT EXISTS image_jobs_submission_idx
269                       ON image_jobs(auth_scope, submission_key_hash)
270                       WHERE submission_key_hash IS NOT NULL;
271                     INSERT OR IGNORE INTO job_schema_migrations(version, applied_at)
272                       VALUES (3, unixepoch());",
273                )?;
274                transaction.commit()?;
275                Ok::<(), tokio_rusqlite::rusqlite::Error>(())
276            })
277            .await
278            .map_err(|_| job_error("could not migrate job database"))?;
279        Ok(Self { connection })
280    }
281
282    /// Closes the `SQLite` worker after pending calls finish.
283    pub async fn close(self) -> Result<(), BridgeError> {
284        self.connection
285            .close()
286            .await
287            .map_err(|_| job_error("could not close job database"))
288    }
289
290    /// Inserts a queued request if bounded pending capacity remains.
291    pub async fn create(
292        &self,
293        auth_scope: &str,
294        id: &str,
295        request: &ImageRequest,
296        now: u64,
297        max_pending: usize,
298        max_database_bytes: u64,
299    ) -> Result<SqliteJobSubmission, BridgeError> {
300        validate_auth_scope(auth_scope)?;
301        let mut persisted_request = request.clone();
302        persisted_request.idempotency_key = None;
303        let request_json = serde_json::to_string(&persisted_request)
304            .map_err(|_| job_error("could not encode job request"))?;
305        let prompt_search = request.prompt.to_lowercase();
306        if let Some(key) = request.idempotency_key.as_deref() {
307            validate_submission_key(key)?;
308        }
309        let submission_key_hash = request
310            .idempotency_key
311            .as_deref()
312            .map(|key| base16ct::lower::encode_string(&Sha256::digest(key.as_bytes())));
313        let request_fingerprint = submission_key_hash
314            .as_ref()
315            .map(|_| durable_request_fingerprint(request))
316            .transpose()?;
317        let auth_scope = auth_scope.to_owned();
318        let lookup_scope = auth_scope.clone();
319        let id = id.to_owned();
320        let now = to_i64(now)?;
321        let max_pending = i64::try_from(max_pending).unwrap_or(i64::MAX);
322        let max_database_bytes = i64::try_from(max_database_bytes).unwrap_or(i64::MAX);
323        let new_job_bytes = logical_new_job_bytes(
324            &id,
325            &auth_scope,
326            &request_json,
327            &prompt_search,
328            submission_key_hash.as_deref(),
329            request_fingerprint.as_deref(),
330        )?;
331        let outcome = self
332            .connection
333            .call(move |connection| {
334                let transaction =
335                    connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
336                if let Some(key_hash) = submission_key_hash.as_deref() {
337                    let existing: Option<(String, String)> = transaction
338                        .query_row(
339                            "SELECT id,request_fingerprint FROM image_jobs
340                             WHERE auth_scope=?1 AND submission_key_hash=?2",
341                            params![auth_scope, key_hash],
342                            |row| Ok((row.get(0)?, row.get(1)?)),
343                        )
344                        .optional()?;
345                    if let Some((existing_id, existing_fingerprint)) = existing {
346                        return Ok(
347                            if request_fingerprint.as_deref() == Some(existing_fingerprint.as_str())
348                            {
349                                CreateOutcome::Replay(existing_id)
350                            } else {
351                                CreateOutcome::Conflict
352                            },
353                        );
354                    }
355                }
356                let pending: i64 = transaction.query_row(
357                    "SELECT COUNT(*) FROM image_jobs WHERE status = 'queued'",
358                    [],
359                    |row| row.get(0),
360                )?;
361                if pending >= max_pending {
362                    return Ok(CreateOutcome::Full);
363                }
364                let logical_bytes: i64 =
365                    transaction.query_row(logical_bytes_query(), [], |row| row.get(0))?;
366                if logical_bytes.saturating_add(new_job_bytes) > max_database_bytes {
367                    return Ok(CreateOutcome::Full);
368                }
369                transaction.execute(
370                    "INSERT INTO image_jobs(
371                       id,status,created_at,updated_at,request_json,prompt_search,auth_scope,
372                       submission_key_hash,request_fingerprint
373                     ) VALUES (?1,'queued',?2,?2,?3,?4,?5,?6,?7)",
374                    params![
375                        id,
376                        now,
377                        request_json,
378                        prompt_search,
379                        auth_scope,
380                        submission_key_hash,
381                        request_fingerprint
382                    ],
383                )?;
384                transaction.commit()?;
385                Ok::<_, tokio_rusqlite::rusqlite::Error>(CreateOutcome::Created(id))
386            })
387            .await
388            .map_err(|_| job_error("could not submit durable job"))?;
389        let (lookup_id, created) = match outcome {
390            CreateOutcome::Created(id) => (id, true),
391            CreateOutcome::Replay(id) => (id, false),
392            CreateOutcome::Conflict => {
393                return Err(BridgeError::new(
394                    ErrorCode::IdempotencyConflict,
395                    "idempotency key was already used for a different request",
396                ));
397            }
398            CreateOutcome::Full => {
399                return Err(
400                    BridgeError::new(ErrorCode::Overloaded, "durable job queue is full")
401                        .retryable(true),
402                );
403            }
404        };
405        Ok(SqliteJobSubmission {
406            job: self.get(lookup_scope.as_str(), lookup_id.as_str()).await?,
407            created,
408        })
409    }
410
411    /// Returns bounded aggregate counters without request, prompt, or path data.
412    pub async fn statistics(&self) -> Result<SqliteJobStatistics, BridgeError> {
413        self.connection
414            .call(|connection| {
415                let counts: (i64, i64, i64, i64, i64, i64, i64, i64) = connection.query_row(
416                    "SELECT COUNT(*),
417                            COALESCE(SUM(status='queued'),0),
418                            COALESCE(SUM(status='running'),0),
419                            COALESCE(SUM(status='succeeded'),0),
420                            COALESCE(SUM(status='failed'),0),
421                            COALESCE(SUM(status='cancelled'),0),
422                            COALESCE(SUM(status='interrupted'),0),
423                            COALESCE(SUM(deleted_at IS NOT NULL),0)
424                     FROM image_jobs",
425                    [],
426                    |row| {
427                        Ok((
428                            row.get(0)?,
429                            row.get(1)?,
430                            row.get(2)?,
431                            row.get(3)?,
432                            row.get(4)?,
433                            row.get(5)?,
434                            row.get(6)?,
435                            row.get(7)?,
436                        ))
437                    },
438                )?;
439                let logical_bytes: i64 =
440                    connection.query_row(logical_bytes_query(), [], |row| row.get(0))?;
441                let page_count: i64 =
442                    connection.query_row("PRAGMA page_count", [], |row| row.get(0))?;
443                let page_size: i64 =
444                    connection.query_row("PRAGMA page_size", [], |row| row.get(0))?;
445                Ok::<_, tokio_rusqlite::rusqlite::Error>((
446                    counts,
447                    logical_bytes,
448                    page_count,
449                    page_size,
450                ))
451            })
452            .await
453            .map_err(|_| job_error("could not inspect job database statistics"))
454            .and_then(|(counts, logical_bytes, page_count, page_size)| {
455                let convert = |value: i64| {
456                    u64::try_from(value)
457                        .map_err(|_| job_error("job database returned invalid statistics"))
458                };
459                Ok(SqliteJobStatistics {
460                    total: convert(counts.0)?,
461                    queued: convert(counts.1)?,
462                    running: convert(counts.2)?,
463                    succeeded: convert(counts.3)?,
464                    failed: convert(counts.4)?,
465                    cancelled: convert(counts.5)?,
466                    interrupted: convert(counts.6)?,
467                    hidden: convert(counts.7)?,
468                    database_bytes: convert(page_count)?.saturating_mul(convert(page_size)?),
469                    logical_bytes: convert(logical_bytes)?,
470                })
471            })
472    }
473
474    /// Marks previously running jobs interrupted without retrying paid work.
475    pub async fn recover_interrupted(&self, now: u64) -> Result<usize, BridgeError> {
476        let now = to_i64(now)?;
477        let error = serde_json::to_string(
478            &BridgeError::new(
479                ErrorCode::Cancelled,
480                "bridge stopped while provider completion was uncertain",
481            )
482            .with_detail("recovery", "inspect_history_before_retrying"),
483        )
484        .map_err(|_| job_error("could not encode interruption error"))?;
485        self.connection
486            .call(move |connection| {
487                connection.execute(
488                    "UPDATE image_jobs
489                     SET status='interrupted', updated_at=?1, completed_at=?1, error_json=?2
490                     WHERE status='running'",
491                    params![now, error],
492                )
493            })
494            .await
495            .map_err(|_| job_error("could not recover interrupted jobs"))
496    }
497
498    /// Returns oldest-first queued identities across ownership scopes for crash recovery.
499    pub async fn queued_identities(
500        &self,
501        limit: usize,
502    ) -> Result<Vec<(String, String)>, BridgeError> {
503        let limit = i64::try_from(limit).unwrap_or(i64::MAX);
504        self.connection
505            .call(move |connection| {
506                let mut statement = connection.prepare(
507                    "SELECT auth_scope,id FROM image_jobs
508                     WHERE status='queued'
509                     ORDER BY created_at ASC,id ASC LIMIT ?1",
510                )?;
511                statement
512                    .query_map([limit], |row| Ok((row.get(0)?, row.get(1)?)))?
513                    .collect::<Result<Vec<_>, _>>()
514            })
515            .await
516            .map_err(|_| job_error("could not list queued durable jobs"))
517    }
518
519    /// Atomically claims one queued job for execution.
520    pub async fn claim(&self, auth_scope: &str, id: &str, now: u64) -> Result<bool, BridgeError> {
521        validate_auth_scope(auth_scope)?;
522        let auth_scope = auth_scope.to_owned();
523        let id = id.to_owned();
524        let now = to_i64(now)?;
525        self.connection
526            .call(move |connection| {
527                connection.execute(
528                    "UPDATE image_jobs
529                     SET status='running', started_at=?2, updated_at=?2, progress_stage='starting'
530                     WHERE id=?1 AND auth_scope=?3 AND status='queued' AND cancel_requested=0",
531                    params![id, now, auth_scope],
532                )
533            })
534            .await
535            .map(|changed| changed == 1)
536            .map_err(|_| job_error("could not claim durable job"))
537    }
538
539    /// Stores only the latest safe progress label and partial count.
540    pub async fn progress(
541        &self,
542        auth_scope: &str,
543        id: &str,
544        stage: &str,
545        partial_images: u32,
546        now: u64,
547    ) -> Result<(), BridgeError> {
548        validate_auth_scope(auth_scope)?;
549        if stage.is_empty()
550            || stage.len() > 64
551            || !stage
552                .bytes()
553                .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
554        {
555            return Err(job_error("job progress stage is invalid"));
556        }
557        let id = id.to_owned();
558        let auth_scope = auth_scope.to_owned();
559        let stage = stage.to_owned();
560        let partial_images = i64::from(partial_images);
561        let now = to_i64(now)?;
562        self.connection
563            .call(move |connection| {
564                connection.execute(
565                    "UPDATE image_jobs
566                     SET progress_stage=?2, partial_images=?3, updated_at=?4
567                     WHERE id=?1 AND auth_scope=?5 AND status='running'",
568                    params![id, stage, partial_images, now, auth_scope],
569                )?;
570                Ok::<(), tokio_rusqlite::rusqlite::Error>(())
571            })
572            .await
573            .map_err(|_| job_error("could not update durable job progress"))
574    }
575
576    /// Stores a verified terminal result.
577    pub async fn succeed(
578        &self,
579        auth_scope: &str,
580        id: &str,
581        response: &ImageResponse,
582        now: u64,
583    ) -> Result<(), BridgeError> {
584        let encoded = serde_json::to_string(response)
585            .map_err(|_| job_error("could not encode job result"))?;
586        if encoded.len() > usize::try_from(ACTIVE_RESULT_RESERVE_BYTES).unwrap_or(usize::MAX) {
587            return Err(BridgeError::new(
588                ErrorCode::Artifact,
589                "durable job result metadata exceeds the storage reserve",
590            ));
591        }
592        self.finish(
593            auth_scope,
594            id,
595            ImageJobStatus::Succeeded,
596            Some(encoded),
597            None,
598            now,
599        )
600        .await
601    }
602
603    /// Stores a structured terminal error or cancellation.
604    pub async fn fail(
605        &self,
606        auth_scope: &str,
607        id: &str,
608        status: ImageJobStatus,
609        error: &BridgeError,
610        now: u64,
611    ) -> Result<(), BridgeError> {
612        if !matches!(
613            status,
614            ImageJobStatus::Failed | ImageJobStatus::Cancelled | ImageJobStatus::Interrupted
615        ) {
616            return Err(job_error("invalid terminal job failure status"));
617        }
618        let mut encoded =
619            serde_json::to_string(error).map_err(|_| job_error("could not encode job error"))?;
620        if encoded.len() > usize::try_from(ACTIVE_RESULT_RESERVE_BYTES).unwrap_or(usize::MAX) {
621            encoded = serde_json::to_string(&BridgeError::new(
622                ErrorCode::Internal,
623                "durable job failed with oversized error metadata",
624            ))
625            .map_err(|_| job_error("could not encode bounded job error"))?;
626        }
627        self.finish(auth_scope, id, status, None, Some(encoded), now)
628            .await
629    }
630
631    async fn finish(
632        &self,
633        auth_scope: &str,
634        id: &str,
635        status: ImageJobStatus,
636        response: Option<String>,
637        error: Option<String>,
638        now: u64,
639    ) -> Result<(), BridgeError> {
640        validate_auth_scope(auth_scope)?;
641        let auth_scope = auth_scope.to_owned();
642        let id = id.to_owned();
643        let status = status_name(status).to_owned();
644        let now = to_i64(now)?;
645        self.connection
646            .call(move |connection| {
647                let changed = connection.execute(
648                    "UPDATE image_jobs
649                     SET status=?2, updated_at=?3, completed_at=?3, progress_stage='completed',
650                         response_json=?4, error_json=?5
651                     WHERE id=?1 AND auth_scope=?6 AND status='running'",
652                    params![id, status, now, response, error, auth_scope],
653                )?;
654                if changed != 1 {
655                    return Err(tokio_rusqlite::rusqlite::Error::QueryReturnedNoRows);
656                }
657                Ok::<(), tokio_rusqlite::rusqlite::Error>(())
658            })
659            .await
660            .map_err(|_| job_error("could not finish durable job"))
661    }
662
663    /// Durably requests cancellation and immediately cancels queued work.
664    pub async fn request_cancel(
665        &self,
666        auth_scope: &str,
667        id: &str,
668        now: u64,
669    ) -> Result<ImageJob, BridgeError> {
670        validate_auth_scope(auth_scope)?;
671        let auth_scope = auth_scope.to_owned();
672        let lookup_scope = auth_scope.clone();
673        let id = id.to_owned();
674        let lookup_id = id.clone();
675        let now = to_i64(now)?;
676        self.connection
677            .call(move |connection| {
678                let transaction = connection.transaction()?;
679                let status: Option<String> = transaction
680                    .query_row(
681                        "SELECT status FROM image_jobs WHERE id=?1 AND auth_scope=?2",
682                        params![id, auth_scope],
683                        |row| row.get(0),
684                    )
685                    .optional()?;
686                match status.as_deref() {
687                    Some("queued") => {
688                        transaction.execute(
689                            "UPDATE image_jobs SET status='cancelled', cancel_requested=1,
690                             updated_at=?2, completed_at=?2, progress_stage='completed'
691                             WHERE id=?1 AND auth_scope=?3",
692                            params![id, now, auth_scope],
693                        )?;
694                    }
695                    Some("running") => {
696                        transaction.execute(
697                            "UPDATE image_jobs SET cancel_requested=1, updated_at=?2
698                             WHERE id=?1 AND auth_scope=?3",
699                            params![id, now, auth_scope],
700                        )?;
701                    }
702                    Some(_) => {}
703                    None => return Err(tokio_rusqlite::rusqlite::Error::QueryReturnedNoRows),
704                }
705                transaction.commit()?;
706                Ok::<(), tokio_rusqlite::rusqlite::Error>(())
707            })
708            .await
709            .map_err(|_| not_found())?;
710        self.get(lookup_scope.as_str(), lookup_id.as_str()).await
711    }
712
713    /// Returns one complete job record.
714    pub async fn get(&self, auth_scope: &str, id: &str) -> Result<ImageJob, BridgeError> {
715        validate_auth_scope(auth_scope)?;
716        let auth_scope = auth_scope.to_owned();
717        let id = id.to_owned();
718        let row = self
719            .connection
720            .call(move |connection| {
721                connection
722                    .query_row(
723                        "SELECT id,status,created_at,updated_at,started_at,completed_at,
724                                request_json,progress_stage,partial_images,response_json,error_json,
725                                cancel_requested,favorite,deleted_at
726                         FROM image_jobs WHERE id=?1 AND auth_scope=?2",
727                        params![id, auth_scope],
728                        read_job_row,
729                    )
730                    .optional()
731            })
732            .await
733            .map_err(|_| job_error("could not read durable job"))?
734            .ok_or_else(not_found)?;
735        decode_job(row)
736    }
737
738    /// Lists newest-first summaries using a stable `(created,id)` cursor.
739    pub async fn list(
740        &self,
741        auth_scope: &str,
742        filter: ImageJobListFilter,
743    ) -> Result<Vec<ImageJobSummary>, BridgeError> {
744        validate_auth_scope(auth_scope)?;
745        let auth_scope = auth_scope.to_owned();
746        let before_created = filter
747            .before
748            .as_ref()
749            .map(|(created, _)| to_i64(*created))
750            .transpose()?;
751        let before_id = filter.before.map(|(_, id)| id);
752        let limit = i64::try_from(filter.limit).unwrap_or(i64::MAX);
753        let status = filter.status.map(status_name).map(str::to_owned);
754        let search = filter
755            .search
756            .map(|value| literal_like_pattern(&value.to_lowercase()));
757        let visibility = filter.visibility;
758        let favorite = filter.favorite;
759        let rows = self
760            .connection
761            .call(move |connection| {
762                let mut predicates = vec!["auth_scope=?"];
763                let mut parameters = vec![SqlValue::Text(auth_scope)];
764                match visibility {
765                    ImageJobVisibility::Active => predicates.push("deleted_at IS NULL"),
766                    ImageJobVisibility::Hidden => predicates.push("deleted_at IS NOT NULL"),
767                    ImageJobVisibility::All => {}
768                }
769                if let Some(status) = status {
770                    predicates.push("status=?");
771                    parameters.push(SqlValue::Text(status));
772                }
773                if let Some(favorite) = favorite {
774                    predicates.push("favorite=?");
775                    parameters.push(SqlValue::Integer(i64::from(favorite)));
776                }
777                if let Some(search) = search {
778                    predicates.push("prompt_search LIKE ? ESCAPE '\\'");
779                    parameters.push(SqlValue::Text(search));
780                }
781                if let (Some(created), Some(id)) = (before_created, before_id) {
782                    predicates.push("(created_at < ? OR (created_at=? AND id < ?))");
783                    parameters.push(SqlValue::Integer(created));
784                    parameters.push(SqlValue::Integer(created));
785                    parameters.push(SqlValue::Text(id));
786                }
787                let condition = if predicates.is_empty() {
788                    "1".to_owned()
789                } else {
790                    predicates.join(" AND ")
791                };
792                let query = format!(
793                    "SELECT id,status,created_at,updated_at,started_at,completed_at,
794                            progress_stage,partial_images,favorite,deleted_at
795                     FROM image_jobs WHERE {condition}
796                     ORDER BY created_at DESC, id DESC LIMIT ?"
797                );
798                parameters.push(SqlValue::Integer(limit));
799                let mut statement = connection.prepare(&query)?;
800                statement
801                    .query_map(params_from_iter(parameters), read_summary)?
802                    .collect::<Result<Vec<_>, _>>()
803            })
804            .await
805            .map_err(|_| job_error("could not list durable jobs"))?;
806        rows.into_iter().map(decode_summary).collect()
807    }
808
809    /// Updates favorite and soft-delete gallery state without removing job evidence.
810    pub async fn update_history(
811        &self,
812        auth_scope: &str,
813        id: &str,
814        favorite: Option<bool>,
815        deleted: Option<bool>,
816        now: u64,
817    ) -> Result<ImageJob, BridgeError> {
818        validate_auth_scope(auth_scope)?;
819        if favorite.is_none() && deleted.is_none() {
820            return Err(BridgeError::new(
821                ErrorCode::InvalidRequest,
822                "history update must change favorite or deleted state",
823            ));
824        }
825        let existing = self.get(auth_scope, id).await?;
826        if deleted.is_some() && !existing.summary.status.terminal() {
827            return Err(BridgeError::new(
828                ErrorCode::InvalidRequest,
829                "only terminal jobs can be deleted from history",
830            )
831            .with_detail("field", "deleted"));
832        }
833        let id = id.to_owned();
834        let auth_scope = auth_scope.to_owned();
835        let lookup_scope = auth_scope.clone();
836        let lookup_id = id.clone();
837        let now = to_i64(now)?;
838        self.connection
839            .call(move |connection| {
840                let changed = connection.execute(
841                    "UPDATE image_jobs
842                     SET favorite=COALESCE(?2,favorite),
843                         deleted_at=CASE WHEN ?3 IS NULL THEN deleted_at
844                                         WHEN ?3 THEN ?4 ELSE NULL END,
845                         updated_at=?4
846                     WHERE id=?1 AND auth_scope=?5",
847                    params![id, favorite, deleted, now, auth_scope],
848                )?;
849                if changed != 1 {
850                    return Err(tokio_rusqlite::rusqlite::Error::QueryReturnedNoRows);
851                }
852                Ok::<(), tokio_rusqlite::rusqlite::Error>(())
853            })
854            .await
855            .map_err(|_| job_error("could not update durable job history"))?;
856        self.get(&lookup_scope, &lookup_id).await
857    }
858
859    /// Removes terminal records outside time/count retention bounds.
860    pub async fn prune(
861        &self,
862        now: u64,
863        retention_secs: u64,
864        max_retained: usize,
865        max_retained_bytes: u64,
866    ) -> Result<usize, BridgeError> {
867        let cutoff = to_i64(now.saturating_sub(retention_secs))?;
868        let maximum = i64::try_from(max_retained).unwrap_or(i64::MAX);
869        let maximum_bytes = i64::try_from(max_retained_bytes).unwrap_or(i64::MAX);
870        self.connection
871            .call(move |connection| {
872                let transaction = connection.transaction()?;
873                let expired = transaction.execute(
874                    "DELETE FROM image_jobs
875                     WHERE status IN ('succeeded','failed','cancelled','interrupted')
876                       AND favorite=0
877                       AND completed_at <= ?1",
878                    [cutoff],
879                )?;
880                let excess = transaction.execute(
881                    "WITH retained AS (
882                       SELECT id,
883                         ROW_NUMBER() OVER (ORDER BY created_at DESC,id DESC) AS ordinal,
884                         SUM(
885                           length(CAST(id AS BLOB)) + length(CAST(auth_scope AS BLOB))
886                           + length(CAST(request_json AS BLOB))
887                           + length(CAST(prompt_search AS BLOB))
888                           + length(CAST(COALESCE(submission_key_hash,'') AS BLOB))
889                           + length(CAST(COALESCE(request_fingerprint,'') AS BLOB))
890                           + length(CAST(COALESCE(response_json,'') AS BLOB))
891                           + length(CAST(COALESCE(error_json,'') AS BLOB)) + 128
892                         ) OVER (ORDER BY created_at DESC,id DESC) AS cumulative_bytes
893                       FROM image_jobs
894                       WHERE status IN ('succeeded','failed','cancelled','interrupted')
895                         AND favorite=0
896                     )
897                     DELETE FROM image_jobs WHERE id IN (
898                       SELECT id FROM retained WHERE ordinal > ?1 OR cumulative_bytes > ?2
899                     )",
900                    params![maximum, maximum_bytes],
901                )?;
902                transaction.commit()?;
903                Ok::<usize, tokio_rusqlite::rusqlite::Error>(expired + excess)
904            })
905            .await
906            .map_err(|_| job_error("could not prune durable jobs"))
907    }
908}
909
910struct JobRow {
911    id: String,
912    status: String,
913    created: i64,
914    updated: i64,
915    started: Option<i64>,
916    completed: Option<i64>,
917    request: String,
918    progress_stage: Option<String>,
919    partial_images: i64,
920    response: Option<String>,
921    error: Option<String>,
922    cancel_requested: bool,
923    favorite: bool,
924    deleted: Option<i64>,
925}
926
927struct SummaryRow {
928    id: String,
929    status: String,
930    created: i64,
931    updated: i64,
932    started: Option<i64>,
933    completed: Option<i64>,
934    progress_stage: Option<String>,
935    partial_images: i64,
936    favorite: bool,
937    deleted: Option<i64>,
938}
939
940fn read_job_row(
941    row: &tokio_rusqlite::rusqlite::Row<'_>,
942) -> tokio_rusqlite::rusqlite::Result<JobRow> {
943    Ok(JobRow {
944        id: row.get(0)?,
945        status: row.get(1)?,
946        created: row.get(2)?,
947        updated: row.get(3)?,
948        started: row.get(4)?,
949        completed: row.get(5)?,
950        request: row.get(6)?,
951        progress_stage: row.get(7)?,
952        partial_images: row.get(8)?,
953        response: row.get(9)?,
954        error: row.get(10)?,
955        cancel_requested: row.get(11)?,
956        favorite: row.get(12)?,
957        deleted: row.get(13)?,
958    })
959}
960
961fn read_summary(
962    row: &tokio_rusqlite::rusqlite::Row<'_>,
963) -> tokio_rusqlite::rusqlite::Result<SummaryRow> {
964    Ok(SummaryRow {
965        id: row.get(0)?,
966        status: row.get(1)?,
967        created: row.get(2)?,
968        updated: row.get(3)?,
969        started: row.get(4)?,
970        completed: row.get(5)?,
971        progress_stage: row.get(6)?,
972        partial_images: row.get(7)?,
973        favorite: row.get(8)?,
974        deleted: row.get(9)?,
975    })
976}
977
978fn decode_job(row: JobRow) -> Result<ImageJob, BridgeError> {
979    let summary = decode_summary(SummaryRow {
980        id: row.id,
981        status: row.status,
982        created: row.created,
983        updated: row.updated,
984        started: row.started,
985        completed: row.completed,
986        progress_stage: row.progress_stage,
987        partial_images: row.partial_images,
988        favorite: row.favorite,
989        deleted: row.deleted,
990    })?;
991    Ok(ImageJob {
992        summary,
993        request: serde_json::from_str(&row.request)
994            .map_err(|_| job_error("stored job request is invalid"))?,
995        result: row
996            .response
997            .map(|value| serde_json::from_str(&value))
998            .transpose()
999            .map_err(|_| job_error("stored job result is invalid"))?,
1000        error: row
1001            .error
1002            .map(|value| serde_json::from_str(&value))
1003            .transpose()
1004            .map_err(|_| job_error("stored job error is invalid"))?,
1005        cancel_requested: row.cancel_requested,
1006    })
1007}
1008
1009fn decode_summary(row: SummaryRow) -> Result<ImageJobSummary, BridgeError> {
1010    let partial_images = u32::try_from(row.partial_images)
1011        .map_err(|_| job_error("stored partial image count is invalid"))?;
1012    Ok(ImageJobSummary {
1013        id: row.id,
1014        status: parse_status(&row.status)?,
1015        created: from_i64(row.created)?,
1016        updated: from_i64(row.updated)?,
1017        started: row.started.map(from_i64).transpose()?,
1018        completed: row.completed.map(from_i64).transpose()?,
1019        progress: row.progress_stage.map(|stage| ImageJobProgress {
1020            stage,
1021            partial_images,
1022        }),
1023        favorite: row.favorite,
1024        deleted: row.deleted.map(from_i64).transpose()?,
1025    })
1026}
1027
1028const fn status_name(status: ImageJobStatus) -> &'static str {
1029    match status {
1030        ImageJobStatus::Queued => "queued",
1031        ImageJobStatus::Running => "running",
1032        ImageJobStatus::Succeeded => "succeeded",
1033        ImageJobStatus::Failed => "failed",
1034        ImageJobStatus::Cancelled => "cancelled",
1035        ImageJobStatus::Interrupted => "interrupted",
1036    }
1037}
1038
1039fn parse_status(value: &str) -> Result<ImageJobStatus, BridgeError> {
1040    match value {
1041        "queued" => Ok(ImageJobStatus::Queued),
1042        "running" => Ok(ImageJobStatus::Running),
1043        "succeeded" => Ok(ImageJobStatus::Succeeded),
1044        "failed" => Ok(ImageJobStatus::Failed),
1045        "cancelled" => Ok(ImageJobStatus::Cancelled),
1046        "interrupted" => Ok(ImageJobStatus::Interrupted),
1047        _ => Err(job_error("stored job status is invalid")),
1048    }
1049}
1050
1051fn to_i64(value: u64) -> Result<i64, BridgeError> {
1052    i64::try_from(value).map_err(|_| job_error("job timestamp is out of range"))
1053}
1054
1055fn from_i64(value: i64) -> Result<u64, BridgeError> {
1056    u64::try_from(value).map_err(|_| job_error("stored job timestamp is invalid"))
1057}
1058
1059fn literal_like_pattern(value: &str) -> String {
1060    let mut pattern = String::with_capacity(value.len().saturating_add(2));
1061    pattern.push('%');
1062    for character in value.chars() {
1063        if matches!(character, '%' | '_' | '\\') {
1064            pattern.push('\\');
1065        }
1066        pattern.push(character);
1067    }
1068    pattern.push('%');
1069    pattern
1070}
1071
1072const fn logical_bytes_query() -> &'static str {
1073    "SELECT COALESCE(SUM(
1074       length(CAST(id AS BLOB)) + length(CAST(auth_scope AS BLOB))
1075       + length(CAST(request_json AS BLOB)) + length(CAST(prompt_search AS BLOB))
1076       + length(CAST(COALESCE(submission_key_hash,'') AS BLOB))
1077       + length(CAST(COALESCE(request_fingerprint,'') AS BLOB))
1078       + CASE WHEN status IN ('queued','running') THEN 262144 ELSE
1079           length(CAST(COALESCE(response_json,'') AS BLOB))
1080           + length(CAST(COALESCE(error_json,'') AS BLOB)) END
1081       + 128
1082     ),0) FROM image_jobs"
1083}
1084
1085fn logical_new_job_bytes(
1086    id: &str,
1087    auth_scope: &str,
1088    request_json: &str,
1089    prompt_search: &str,
1090    submission_key_hash: Option<&str>,
1091    request_fingerprint: Option<&str>,
1092) -> Result<i64, BridgeError> {
1093    let bytes = id
1094        .len()
1095        .saturating_add(auth_scope.len())
1096        .saturating_add(request_json.len())
1097        .saturating_add(prompt_search.len())
1098        .saturating_add(submission_key_hash.map_or(0, str::len))
1099        .saturating_add(request_fingerprint.map_or(0, str::len))
1100        .saturating_add(usize::try_from(ACTIVE_RESULT_RESERVE_BYTES).unwrap_or(usize::MAX))
1101        .saturating_add(128);
1102    i64::try_from(bytes).map_err(|_| job_error("durable job is too large to account"))
1103}
1104
1105fn durable_request_fingerprint(request: &ImageRequest) -> Result<String, BridgeError> {
1106    let mut canonical = request.clone();
1107    canonical.idempotency_key = None;
1108    canonical.timeout_ms = None;
1109    let encoded = serde_json::to_vec(&canonical)
1110        .map_err(|_| job_error("could not fingerprint durable job request"))?;
1111    Ok(base16ct::lower::encode_string(&Sha256::digest(encoded)))
1112}
1113
1114fn validate_auth_scope(scope: &str) -> Result<(), BridgeError> {
1115    if scope.is_empty() || scope.len() > 256 || scope.chars().any(char::is_control) {
1116        Err(BridgeError::new(
1117            ErrorCode::InvalidRequest,
1118            "durable job authorization scope is invalid",
1119        ))
1120    } else {
1121        Ok(())
1122    }
1123}
1124
1125fn validate_submission_key(key: &str) -> Result<(), BridgeError> {
1126    if key.trim().is_empty() || key.len() > 512 || key.chars().any(char::is_control) {
1127        Err(BridgeError::new(
1128            ErrorCode::InvalidRequest,
1129            "durable job idempotency key is invalid",
1130        ))
1131    } else {
1132        Ok(())
1133    }
1134}
1135
1136fn not_found() -> BridgeError {
1137    BridgeError::new(ErrorCode::InvalidRequest, "durable job was not found")
1138        .with_detail("resource", "job")
1139}
1140
1141fn job_error(message: impl Into<String>) -> BridgeError {
1142    BridgeError::new(ErrorCode::Internal, message)
1143}
1144
1145#[cfg(test)]
1146mod tests {
1147    #![allow(clippy::unwrap_used)]
1148
1149    use std::sync::Arc;
1150
1151    use super::*;
1152
1153    const SCOPE: &str = "test-scope";
1154    const MAX_BYTES: u64 = 1024 * 1024 * 1024;
1155
1156    fn fixture_response(id: &str) -> ImageResponse {
1157        ImageResponse {
1158            id: id.to_owned(),
1159            created: 14,
1160            provider: "test".to_owned(),
1161            model: "test".to_owned(),
1162            requested: imagegen_bridge_core::GenerationParameters::default(),
1163            effective: imagegen_bridge_core::GenerationParameters::default(),
1164            normalizations: Vec::new(),
1165            attempts: Vec::new(),
1166            data: Vec::new(),
1167            failures: Vec::new(),
1168            revised_prompt: None,
1169            usage: None,
1170            session: None,
1171            timings: imagegen_bridge_core::Timings::default(),
1172            warnings: Vec::new(),
1173        }
1174    }
1175
1176    #[tokio::test]
1177    async fn lifecycle_survives_reopen_and_is_cursor_stable() {
1178        let directory = tempfile::tempdir().unwrap();
1179        let path = directory.path().join("jobs.sqlite3");
1180        let store = SqliteImageJobStore::open(&path).await.unwrap();
1181        store
1182            .create(
1183                SCOPE,
1184                "019f-job-a",
1185                &ImageRequest::generate("first"),
1186                10,
1187                10,
1188                MAX_BYTES,
1189            )
1190            .await
1191            .unwrap();
1192        store
1193            .create(
1194                SCOPE,
1195                "019f-job-b",
1196                &ImageRequest::generate("second"),
1197                11,
1198                10,
1199                MAX_BYTES,
1200            )
1201            .await
1202            .unwrap();
1203        assert!(store.claim(SCOPE, "019f-job-a", 12).await.unwrap());
1204        store
1205            .progress(SCOPE, "019f-job-a", "provider", 2, 13)
1206            .await
1207            .unwrap();
1208        let response = fixture_response("019f-job-a");
1209        store
1210            .succeed(SCOPE, "019f-job-a", &response, 14)
1211            .await
1212            .unwrap();
1213        let first = store
1214            .list(
1215                SCOPE,
1216                ImageJobListFilter {
1217                    limit: 1,
1218                    ..ImageJobListFilter::default()
1219                },
1220            )
1221            .await
1222            .unwrap();
1223        assert_eq!(first[0].id, "019f-job-b");
1224        let second = store
1225            .list(
1226                SCOPE,
1227                ImageJobListFilter {
1228                    before: Some((first[0].created, first[0].id.clone())),
1229                    limit: 2,
1230                    ..ImageJobListFilter::default()
1231                },
1232            )
1233            .await
1234            .unwrap();
1235        assert_eq!(second[0].id, "019f-job-a");
1236        store.close().await.unwrap();
1237
1238        let reopened = SqliteImageJobStore::open(&path).await.unwrap();
1239        let job = reopened.get(SCOPE, "019f-job-a").await.unwrap();
1240        assert_eq!(job.summary.status, ImageJobStatus::Succeeded);
1241        assert_eq!(job.summary.progress.unwrap().partial_images, 2);
1242        assert_eq!(job.result.unwrap().id, "019f-job-a");
1243    }
1244
1245    #[tokio::test]
1246    async fn recovery_never_retries_ambiguous_running_work() {
1247        let directory = tempfile::tempdir().unwrap();
1248        let store = SqliteImageJobStore::open(&directory.path().join("jobs.sqlite3"))
1249            .await
1250            .unwrap();
1251        store
1252            .create(
1253                SCOPE,
1254                "019f-job",
1255                &ImageRequest::generate("test"),
1256                10,
1257                10,
1258                MAX_BYTES,
1259            )
1260            .await
1261            .unwrap();
1262        store.claim(SCOPE, "019f-job", 11).await.unwrap();
1263        assert_eq!(store.recover_interrupted(12).await.unwrap(), 1);
1264        let job = store.get(SCOPE, "019f-job").await.unwrap();
1265        assert_eq!(job.summary.status, ImageJobStatus::Interrupted);
1266        assert!(!job.error.unwrap().retryable);
1267    }
1268
1269    #[tokio::test]
1270    async fn queued_cancellation_is_immediate_and_durable() {
1271        let directory = tempfile::tempdir().unwrap();
1272        let store = SqliteImageJobStore::open(&directory.path().join("jobs.sqlite3"))
1273            .await
1274            .unwrap();
1275        store
1276            .create(
1277                SCOPE,
1278                "019f-job",
1279                &ImageRequest::generate("test"),
1280                10,
1281                10,
1282                MAX_BYTES,
1283            )
1284            .await
1285            .unwrap();
1286        let job = store.request_cancel(SCOPE, "019f-job", 11).await.unwrap();
1287        assert_eq!(job.summary.status, ImageJobStatus::Cancelled);
1288        assert!(job.cancel_requested);
1289        assert!(!store.claim(SCOPE, "019f-job", 12).await.unwrap());
1290
1291        let statistics = store.statistics().await.unwrap();
1292        assert_eq!(statistics.total, 1);
1293        assert_eq!(statistics.cancelled, 1);
1294        assert_eq!(statistics.queued, 0);
1295        assert_eq!(statistics.running, 0);
1296        assert_eq!(statistics.hidden, 0);
1297        assert!(statistics.database_bytes > 0);
1298    }
1299
1300    #[tokio::test]
1301    async fn pending_capacity_rejects_without_persisting_extra_work() {
1302        let directory = tempfile::tempdir().unwrap();
1303        let store = SqliteImageJobStore::open(&directory.path().join("jobs.sqlite3"))
1304            .await
1305            .unwrap();
1306        store
1307            .create(
1308                SCOPE,
1309                "019f-job-a",
1310                &ImageRequest::generate("first"),
1311                10,
1312                1,
1313                MAX_BYTES,
1314            )
1315            .await
1316            .unwrap();
1317        let error = store
1318            .create(
1319                SCOPE,
1320                "019f-job-b",
1321                &ImageRequest::generate("second"),
1322                11,
1323                1,
1324                MAX_BYTES,
1325            )
1326            .await
1327            .unwrap_err();
1328        assert_eq!(error.code, ErrorCode::Overloaded);
1329        assert!(error.retryable);
1330        assert!(store.get(SCOPE, "019f-job-b").await.is_err());
1331    }
1332
1333    #[tokio::test]
1334    async fn logical_database_budget_rejects_before_persistence() {
1335        let directory = tempfile::tempdir().unwrap();
1336        let store = SqliteImageJobStore::open(&directory.path().join("jobs.sqlite3"))
1337            .await
1338            .unwrap();
1339        let error = store
1340            .create(
1341                SCOPE,
1342                "019f-too-large",
1343                &ImageRequest::generate("bounded"),
1344                10,
1345                10,
1346                ACTIVE_RESULT_RESERVE_BYTES - 1,
1347            )
1348            .await
1349            .unwrap_err();
1350        assert_eq!(error.code, ErrorCode::Overloaded);
1351        assert_eq!(store.statistics().await.unwrap().total, 0);
1352    }
1353
1354    #[tokio::test]
1355    async fn pruning_enforces_terminal_logical_byte_budget() {
1356        let directory = tempfile::tempdir().unwrap();
1357        let store = SqliteImageJobStore::open(&directory.path().join("jobs.sqlite3"))
1358            .await
1359            .unwrap();
1360        store
1361            .create(
1362                SCOPE,
1363                "019f-job-a",
1364                &ImageRequest::generate("first retained request"),
1365                10,
1366                10,
1367                MAX_BYTES,
1368            )
1369            .await
1370            .unwrap();
1371        assert!(store.claim(SCOPE, "019f-job-a", 11).await.unwrap());
1372        store
1373            .succeed(SCOPE, "019f-job-a", &fixture_response("019f-job-a"), 12)
1374            .await
1375            .unwrap();
1376        let one_job_bytes = store.statistics().await.unwrap().logical_bytes;
1377
1378        store
1379            .create(
1380                SCOPE,
1381                "019f-job-b",
1382                &ImageRequest::generate("second retained request"),
1383                13,
1384                10,
1385                MAX_BYTES,
1386            )
1387            .await
1388            .unwrap();
1389        assert!(store.claim(SCOPE, "019f-job-b", 14).await.unwrap());
1390        store
1391            .succeed(SCOPE, "019f-job-b", &fixture_response("019f-job-b"), 15)
1392            .await
1393            .unwrap();
1394
1395        let two_job_bytes = store.statistics().await.unwrap().logical_bytes;
1396        let byte_budget = one_job_bytes.max(two_job_bytes.saturating_sub(one_job_bytes));
1397        assert_eq!(store.prune(15, 100, 10, byte_budget).await.unwrap(), 1);
1398        assert!(store.get(SCOPE, "019f-job-a").await.is_err());
1399        assert!(store.get(SCOPE, "019f-job-b").await.is_ok());
1400        assert!(store.statistics().await.unwrap().logical_bytes <= byte_budget);
1401    }
1402
1403    #[tokio::test]
1404    async fn submission_idempotency_is_atomic_scoped_and_persistent() {
1405        let directory = tempfile::tempdir().unwrap();
1406        let path = directory.path().join("jobs.sqlite3");
1407        let store = SqliteImageJobStore::open(&path).await.unwrap();
1408        let mut request = ImageRequest::generate("same request");
1409        request.idempotency_key = Some("durable-key".to_owned());
1410
1411        let first = store
1412            .create("scope-a", "019f-job-a", &request, 10, 1, MAX_BYTES)
1413            .await
1414            .unwrap();
1415        assert!(first.created);
1416        assert_eq!(first.job.summary.id, "019f-job-a");
1417        assert_eq!(first.job.request.idempotency_key, None);
1418
1419        let replay = store
1420            .create("scope-a", "019f-job-b", &request, 11, 1, MAX_BYTES)
1421            .await
1422            .unwrap();
1423        assert!(!replay.created);
1424        assert_eq!(replay.job.summary.id, "019f-job-a");
1425
1426        let other_scope = store
1427            .create("scope-b", "019f-job-c", &request, 12, 2, MAX_BYTES)
1428            .await
1429            .unwrap();
1430        assert!(other_scope.created);
1431        assert_eq!(other_scope.job.summary.id, "019f-job-c");
1432
1433        let mut conflict = request.clone();
1434        conflict.prompt = "different request".to_owned();
1435        let error = store
1436            .create("scope-a", "019f-job-d", &conflict, 13, 2, MAX_BYTES)
1437            .await
1438            .unwrap_err();
1439        assert_eq!(error.code, ErrorCode::IdempotencyConflict);
1440        store.close().await.unwrap();
1441
1442        let reopened = SqliteImageJobStore::open(&path).await.unwrap();
1443        let replay = reopened
1444            .create("scope-a", "019f-job-e", &request, 14, 1, MAX_BYTES)
1445            .await
1446            .unwrap();
1447        assert!(!replay.created);
1448        assert_eq!(replay.job.summary.id, "019f-job-a");
1449    }
1450
1451    #[tokio::test]
1452    async fn caller_operations_never_cross_authorization_scopes() {
1453        let directory = tempfile::tempdir().unwrap();
1454        let store = SqliteImageJobStore::open(&directory.path().join("jobs.sqlite3"))
1455            .await
1456            .unwrap();
1457        store
1458            .create(
1459                "scope-a",
1460                "019f-owned",
1461                &ImageRequest::generate("private"),
1462                10,
1463                10,
1464                MAX_BYTES,
1465            )
1466            .await
1467            .unwrap();
1468
1469        assert!(store.get("scope-b", "019f-owned").await.is_err());
1470        assert!(
1471            store
1472                .list(
1473                    "scope-b",
1474                    ImageJobListFilter {
1475                        limit: 10,
1476                        ..ImageJobListFilter::default()
1477                    },
1478                )
1479                .await
1480                .unwrap()
1481                .is_empty()
1482        );
1483        assert!(
1484            store
1485                .request_cancel("scope-b", "019f-owned", 11)
1486                .await
1487                .is_err()
1488        );
1489        assert!(
1490            store
1491                .update_history("scope-b", "019f-owned", Some(true), None, 11)
1492                .await
1493                .is_err()
1494        );
1495        assert_eq!(
1496            store
1497                .get("scope-a", "019f-owned")
1498                .await
1499                .unwrap()
1500                .request
1501                .prompt,
1502            "private"
1503        );
1504    }
1505
1506    #[tokio::test]
1507    async fn concurrent_identical_submissions_converge_on_one_job() {
1508        let directory = tempfile::tempdir().unwrap();
1509        let store = Arc::new(
1510            SqliteImageJobStore::open(&directory.path().join("jobs.sqlite3"))
1511                .await
1512                .unwrap(),
1513        );
1514        let mut request = ImageRequest::generate("concurrent request");
1515        request.idempotency_key = Some("concurrent-key".to_owned());
1516        let first = store.create("scope-a", "019f-first", &request, 10, 10, MAX_BYTES);
1517        let second = store.create("scope-a", "019f-second", &request, 10, 10, MAX_BYTES);
1518        let (first, second) = tokio::join!(first, second);
1519        let first = first.unwrap();
1520        let second = second.unwrap();
1521        assert_ne!(first.created, second.created);
1522        assert_eq!(first.job.summary.id, second.job.summary.id);
1523        assert_eq!(store.statistics().await.unwrap().total, 1);
1524    }
1525
1526    #[cfg(unix)]
1527    #[tokio::test]
1528    async fn database_symlinks_are_rejected() {
1529        use std::os::unix::fs::symlink;
1530
1531        let directory = tempfile::tempdir().unwrap();
1532        let target = directory.path().join("target.sqlite3");
1533        std::fs::write(&target, []).unwrap();
1534        let link = directory.path().join("jobs.sqlite3");
1535        symlink(target, &link).unwrap();
1536        let error = SqliteImageJobStore::open(&link).await.unwrap_err();
1537        assert_eq!(error.code, ErrorCode::Internal);
1538    }
1539
1540    #[tokio::test]
1541    async fn history_updates_only_soft_delete_terminal_jobs() {
1542        let directory = tempfile::tempdir().unwrap();
1543        let store = SqliteImageJobStore::open(&directory.path().join("jobs.sqlite3"))
1544            .await
1545            .unwrap();
1546        store
1547            .create(
1548                SCOPE,
1549                "019f-job",
1550                &ImageRequest::generate("test"),
1551                10,
1552                10,
1553                MAX_BYTES,
1554            )
1555            .await
1556            .unwrap();
1557        let error = store
1558            .update_history(SCOPE, "019f-job", None, Some(true), 11)
1559            .await
1560            .unwrap_err();
1561        assert_eq!(error.code, ErrorCode::InvalidRequest);
1562        assert!(store.claim(SCOPE, "019f-job", 12).await.unwrap());
1563        store
1564            .succeed(SCOPE, "019f-job", &fixture_response("019f-job"), 13)
1565            .await
1566            .unwrap();
1567        let deleted = store
1568            .update_history(SCOPE, "019f-job", Some(true), Some(true), 14)
1569            .await
1570            .unwrap();
1571        assert!(deleted.summary.favorite);
1572        assert_eq!(deleted.summary.deleted, Some(14));
1573        assert!(
1574            store
1575                .list(
1576                    SCOPE,
1577                    ImageJobListFilter {
1578                        limit: 10,
1579                        ..ImageJobListFilter::default()
1580                    }
1581                )
1582                .await
1583                .unwrap()
1584                .is_empty()
1585        );
1586        let restored = store
1587            .update_history(SCOPE, "019f-job", None, Some(false), 15)
1588            .await
1589            .unwrap();
1590        assert_eq!(restored.summary.deleted, None);
1591        assert_eq!(store.prune(100, 1, 1, MAX_BYTES).await.unwrap(), 0);
1592        store
1593            .update_history(SCOPE, "019f-job", Some(false), None, 101)
1594            .await
1595            .unwrap();
1596        assert_eq!(store.prune(102, 1, 1, MAX_BYTES).await.unwrap(), 1);
1597    }
1598
1599    #[tokio::test]
1600    async fn history_filters_apply_before_cursor_pagination() {
1601        let directory = tempfile::tempdir().unwrap();
1602        let store = SqliteImageJobStore::open(&directory.path().join("jobs.sqlite3"))
1603            .await
1604            .unwrap();
1605        for (id, prompt, created) in [
1606            ("019f-job-a", "Red fox 100%", 10),
1607            ("019f-job-b", "BLUE_bird study", 11),
1608            ("019f-job-c", "ČERVENÁ liška", 12),
1609        ] {
1610            store
1611                .create(
1612                    SCOPE,
1613                    id,
1614                    &ImageRequest::generate(prompt),
1615                    created,
1616                    10,
1617                    MAX_BYTES,
1618                )
1619                .await
1620                .unwrap();
1621            assert!(store.claim(SCOPE, id, created + 1).await.unwrap());
1622            store
1623                .succeed(SCOPE, id, &fixture_response(id), created + 2)
1624                .await
1625                .unwrap();
1626        }
1627        store
1628            .update_history(SCOPE, "019f-job-a", Some(true), None, 20)
1629            .await
1630            .unwrap();
1631        store
1632            .update_history(SCOPE, "019f-job-b", None, Some(true), 21)
1633            .await
1634            .unwrap();
1635
1636        let percent = store
1637            .list(
1638                SCOPE,
1639                ImageJobListFilter {
1640                    limit: 10,
1641                    search: Some("100%".to_owned()),
1642                    favorite: Some(true),
1643                    ..ImageJobListFilter::default()
1644                },
1645            )
1646            .await
1647            .unwrap();
1648        assert_eq!(percent.len(), 1);
1649        assert_eq!(percent[0].id, "019f-job-a");
1650
1651        let underscore = store
1652            .list(
1653                SCOPE,
1654                ImageJobListFilter {
1655                    limit: 10,
1656                    visibility: ImageJobVisibility::Hidden,
1657                    search: Some("blue_".to_owned()),
1658                    ..ImageJobListFilter::default()
1659                },
1660            )
1661            .await
1662            .unwrap();
1663        assert_eq!(underscore.len(), 1);
1664        assert_eq!(underscore[0].id, "019f-job-b");
1665
1666        let unicode_case = store
1667            .list(
1668                SCOPE,
1669                ImageJobListFilter {
1670                    limit: 10,
1671                    search: Some("červená".to_owned()),
1672                    ..ImageJobListFilter::default()
1673                },
1674            )
1675            .await
1676            .unwrap();
1677        assert_eq!(unicode_case.len(), 1);
1678        assert_eq!(unicode_case[0].id, "019f-job-c");
1679
1680        let injection = store
1681            .list(
1682                SCOPE,
1683                ImageJobListFilter {
1684                    limit: 10,
1685                    visibility: ImageJobVisibility::All,
1686                    search: Some("' OR 1=1 --".to_owned()),
1687                    ..ImageJobListFilter::default()
1688                },
1689            )
1690            .await
1691            .unwrap();
1692        assert!(injection.is_empty());
1693
1694        let newest_favorite = store
1695            .list(
1696                SCOPE,
1697                ImageJobListFilter {
1698                    limit: 1,
1699                    visibility: ImageJobVisibility::All,
1700                    favorite: Some(true),
1701                    ..ImageJobListFilter::default()
1702                },
1703            )
1704            .await
1705            .unwrap();
1706        assert_eq!(newest_favorite[0].id, "019f-job-a");
1707        let after_favorite = store
1708            .list(
1709                SCOPE,
1710                ImageJobListFilter {
1711                    before: Some((newest_favorite[0].created, newest_favorite[0].id.clone())),
1712                    limit: 1,
1713                    visibility: ImageJobVisibility::All,
1714                    favorite: Some(true),
1715                    ..ImageJobListFilter::default()
1716                },
1717            )
1718            .await
1719            .unwrap();
1720        assert!(after_favorite.is_empty());
1721    }
1722
1723    #[tokio::test]
1724    async fn version_one_database_migrates_prompt_search_without_losing_jobs() {
1725        let directory = tempfile::tempdir().unwrap();
1726        let path = directory.path().join("jobs.sqlite3");
1727        let connection = Connection::open(&path).await.unwrap();
1728        let request = serde_json::to_string(&ImageRequest::generate("legacy copper fox")).unwrap();
1729        connection
1730            .call(move |connection| {
1731                connection.execute_batch(
1732                    "CREATE TABLE job_schema_migrations (
1733                       version INTEGER PRIMARY KEY,
1734                       applied_at INTEGER NOT NULL
1735                     );
1736                     CREATE TABLE image_jobs (
1737                       id TEXT PRIMARY KEY,
1738                       status TEXT NOT NULL,
1739                       created_at INTEGER NOT NULL,
1740                       updated_at INTEGER NOT NULL,
1741                       started_at INTEGER,
1742                       completed_at INTEGER,
1743                       request_json TEXT NOT NULL,
1744                       progress_stage TEXT,
1745                       partial_images INTEGER NOT NULL DEFAULT 0,
1746                       response_json TEXT,
1747                       error_json TEXT,
1748                       cancel_requested INTEGER NOT NULL DEFAULT 0,
1749                       favorite INTEGER NOT NULL DEFAULT 0,
1750                       deleted_at INTEGER
1751                     );
1752                     INSERT INTO job_schema_migrations(version, applied_at) VALUES (1, 10);",
1753                )?;
1754                connection.execute(
1755                    "INSERT INTO image_jobs(id,status,created_at,updated_at,request_json)
1756                     VALUES ('019f-legacy','queued',10,10,?1)",
1757                    [request],
1758                )?;
1759                Ok::<(), tokio_rusqlite::rusqlite::Error>(())
1760            })
1761            .await
1762            .unwrap();
1763        connection.close().await.unwrap();
1764
1765        let store = SqliteImageJobStore::open(&path).await.unwrap();
1766        let results = store
1767            .list(
1768                "legacy-unowned",
1769                ImageJobListFilter {
1770                    limit: 10,
1771                    search: Some("COPPER".to_owned()),
1772                    ..ImageJobListFilter::default()
1773                },
1774            )
1775            .await
1776            .unwrap();
1777        assert_eq!(results.len(), 1);
1778        assert_eq!(results[0].id, "019f-legacy");
1779        let status = inspect_sqlite_job_schema(&path).await.unwrap();
1780        assert_eq!(status.version, Some(3));
1781        assert_eq!(status.current_version, 3);
1782        store.close().await.unwrap();
1783
1784        let connection = Connection::open(&path).await.unwrap();
1785        connection
1786            .call(|connection| {
1787                connection.execute("UPDATE image_jobs SET prompt_search=''", [])?;
1788                connection.execute("DELETE FROM job_schema_migrations WHERE version=2", [])?;
1789                Ok::<(), tokio_rusqlite::rusqlite::Error>(())
1790            })
1791            .await
1792            .unwrap();
1793        connection.close().await.unwrap();
1794        let repaired = SqliteImageJobStore::open(&path).await.unwrap();
1795        assert_eq!(
1796            repaired
1797                .list(
1798                    "legacy-unowned",
1799                    ImageJobListFilter {
1800                        limit: 10,
1801                        search: Some("legacy copper".to_owned()),
1802                        ..ImageJobListFilter::default()
1803                    }
1804                )
1805                .await
1806                .unwrap()
1807                .len(),
1808            1
1809        );
1810    }
1811}