1use 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#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct SqliteJobSubmission {
23 pub job: ImageJob,
25 pub created: bool,
27}
28
29#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
31pub enum ImageJobVisibility {
32 #[default]
34 Active,
35 Hidden,
37 All,
39}
40
41#[derive(Debug, Clone, Default, PartialEq, Eq)]
43pub struct ImageJobListFilter {
44 pub before: Option<(u64, String)>,
46 pub limit: usize,
48 pub visibility: ImageJobVisibility,
50 pub status: Option<ImageJobStatus>,
52 pub favorite: Option<bool>,
54 pub search: Option<String>,
56}
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub struct SqliteJobSchemaStatus {
61 pub initialized: bool,
63 pub version: Option<u32>,
65 pub current_version: u32,
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
71pub struct SqliteJobStatistics {
72 pub total: u64,
74 pub queued: u64,
76 pub running: u64,
78 pub succeeded: u64,
80 pub failed: u64,
82 pub cancelled: u64,
84 pub interrupted: u64,
86 pub hidden: u64,
88 pub database_bytes: u64,
90 pub logical_bytes: u64,
92}
93
94pub 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
143pub 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}