2026-03-24 15:12:56 +08:00
use async_trait ::async_trait ;
2026-04-12 20:45:47 +08:00
use futures_util ::{ stream ::TryStream , TryStreamExt };
use sqlx ::{ postgres ::PgRow , PgPool , Postgres , QueryBuilder , Row };
2026-03-24 15:12:56 +08:00
2026-07-15 23:47:19 +08:00
use aether_data_contracts ::repository ::video_tasks ::{
2026-03-31 19:19:04 +08:00
StoredVideoTask , UpsertVideoTask , VideoTaskLookupKey , VideoTaskModelCount ,
VideoTaskQueryFilter , VideoTaskReadRepository , VideoTaskStatus , VideoTaskStatusCount ,
VideoTaskWriteRepository ,
2026-03-24 15:12:56 +08:00
};
2026-07-15 23:47:19 +08:00
use aether_data_contracts ::DataLayerError ;
use crate ::error ::SqlxResultExt ;
2026-03-24 15:12:56 +08:00
2026-04-12 20:45:47 +08:00
const SELECT_VIDEO_TASK_COLUMNS_PREFIX : & str = r #"
2026-03-24 15:12:56 +08:00
id,
short_id,
2026-03-31 19:19:04 +08:00
request_id,
2026-03-24 15:12:56 +08:00
user_id,
2026-03-31 19:19:04 +08:00
api_key_id,
username,
api_key_name,
2026-03-24 15:12:56 +08:00
external_task_id,
2026-03-31 19:19:04 +08:00
provider_id,
endpoint_id,
key_id,
client_api_format,
2026-03-24 15:12:56 +08:00
provider_api_format,
2026-03-31 19:19:04 +08:00
format_converted,
2026-03-24 15:12:56 +08:00
model,
2026-04-12 20:45:47 +08:00
"# ;
const SELECT_VIDEO_TASK_COLUMNS_SUFFIX : & str = r #"
2026-03-24 15:12:56 +08:00
status,
progress_percent,
2026-03-31 19:19:04 +08:00
progress_message,
retry_count,
poll_interval_seconds,
CAST(EXTRACT(EPOCH FROM next_poll_at) AS BIGINT) AS next_poll_at_unix_secs,
poll_count,
max_poll_count,
2026-04-10 01:46:14 +08:00
CAST(EXTRACT(EPOCH FROM created_at) AS BIGINT) AS created_at_unix_ms,
2026-03-31 19:19:04 +08:00
CAST(EXTRACT(EPOCH FROM submitted_at) AS BIGINT) AS submitted_at_unix_secs,
CAST(EXTRACT(EPOCH FROM completed_at) AS BIGINT) AS completed_at_unix_secs,
CAST(EXTRACT(EPOCH FROM updated_at) AS BIGINT) AS updated_at_unix_secs,
2026-03-24 15:12:56 +08:00
error_code,
error_message,
2026-03-31 19:19:04 +08:00
video_url,
request_metadata
"# ;
2026-04-12 20:45:47 +08:00
fn select_video_task_columns (
prompt_sql : & str ,
original_request_body_sql : & str ,
duration_seconds_sql : & str ,
resolution_sql : & str ,
aspect_ratio_sql : & str ,
size_sql : & str ,
) -> String {
format! (
" {SELECT_VIDEO_TASK_COLUMNS_PREFIX}
{prompt_sql} AS prompt,
{original_request_body_sql} AS original_request_body,
{duration_seconds_sql} AS duration_seconds,
{resolution_sql} AS resolution,
{aspect_ratio_sql} AS aspect_ratio,
{size_sql} AS size,
{SELECT_VIDEO_TASK_COLUMNS_SUFFIX} "
)
}
fn select_video_task_full_columns () -> String {
select_video_task_columns (
"prompt" ,
"original_request_body" ,
"duration_seconds" ,
"resolution" ,
"aspect_ratio" ,
"size" ,
)
}
fn select_video_task_claim_columns () -> String {
select_video_task_columns (
2026-09-07 21:14:27 +08:00
"prompt" ,
2026-04-12 20:45:47 +08:00
"NULL::jsonb" ,
2026-09-07 21:14:27 +08:00
"duration_seconds" ,
"resolution" ,
"aspect_ratio" ,
"size" ,
2026-04-12 20:45:47 +08:00
)
}
2026-03-31 19:19:04 +08:00
fn select_video_task_sql ( where_clause : & str ) -> String {
2026-04-12 20:45:47 +08:00
format! (
"SELECT \n {} \n FROM video_tasks \n {where_clause} \n " ,
select_video_task_full_columns ()
)
2026-03-31 19:19:04 +08:00
}
fn find_by_id_sql () -> String {
select_video_task_sql ( "WHERE id = $1 \n LIMIT 1" )
}
2026-09-04 03:45:52 +08:00
fn find_by_id_for_user_sql () -> String {
select_video_task_sql ( "WHERE id = $1 AND user_id = $2 \n LIMIT 1" )
}
2026-03-31 19:19:04 +08:00
fn find_by_short_id_sql () -> String {
select_video_task_sql ( "WHERE short_id = $1 \n LIMIT 1" )
}
2026-09-04 03:45:52 +08:00
fn find_by_short_id_for_user_sql () -> String {
select_video_task_sql ( "WHERE short_id = $1 AND user_id = $2 \n LIMIT 1" )
}
2026-03-31 19:19:04 +08:00
fn find_by_user_external_sql () -> String {
select_video_task_sql ( "WHERE user_id = $1 AND external_task_id = $2 \n LIMIT 1" )
}
fn list_active_sql () -> String {
select_video_task_sql ( "WHERE status = ANY($1) \n ORDER BY updated_at DESC \n LIMIT $2" )
}
fn list_due_sql () -> String {
select_video_task_sql (
"WHERE status = ANY($1) \n AND next_poll_at IS NOT NULL \n AND next_poll_at <= TO_TIMESTAMP($2) \n AND poll_count < max_poll_count \n ORDER BY next_poll_at ASC \n LIMIT $3" ,
)
}
2026-04-12 20:45:47 +08:00
fn select_video_task_page_summary_columns () -> & 'static str {
r #"
id,
NULL::TEXT AS short_id,
request_id,
user_id,
NULL::TEXT AS api_key_id,
username,
NULL::TEXT AS api_key_name,
external_task_id,
provider_id,
NULL::TEXT AS endpoint_id,
NULL::TEXT AS key_id,
NULL::TEXT AS client_api_format,
NULL::TEXT AS provider_api_format,
FALSE AS format_converted,
model,
CASE
WHEN prompt IS NULL THEN NULL
WHEN char_length(prompt) <= 100 THEN prompt
ELSE LEFT(prompt, 100) || '...'
END AS prompt,
NULL::jsonb AS original_request_body,
duration_seconds,
resolution,
aspect_ratio,
NULL::TEXT AS size,
status,
progress_percent,
progress_message,
0::INTEGER AS retry_count,
1::INTEGER AS poll_interval_seconds,
NULL::BIGINT AS next_poll_at_unix_secs,
poll_count,
max_poll_count,
CAST(EXTRACT(EPOCH FROM created_at) AS BIGINT) AS created_at_unix_ms,
CAST(EXTRACT(EPOCH FROM submitted_at) AS BIGINT) AS submitted_at_unix_secs,
CAST(EXTRACT(EPOCH FROM completed_at) AS BIGINT) AS completed_at_unix_secs,
CAST(EXTRACT(EPOCH FROM updated_at) AS BIGINT) AS updated_at_unix_secs,
error_code,
error_message,
video_url,
NULL::jsonb AS request_metadata
"#
}
2026-03-31 19:19:04 +08:00
fn claim_due_sql () -> String {
2026-04-12 20:45:47 +08:00
let columns = select_video_task_claim_columns ();
2026-03-31 19:19:04 +08:00
format! (
"WITH due AS (
SELECT id
FROM video_tasks
WHERE status = ANY($1)
AND next_poll_at IS NOT NULL
AND next_poll_at <= TO_TIMESTAMP($2)
AND poll_count < max_poll_count
ORDER BY next_poll_at ASC, updated_at ASC
FOR UPDATE SKIP LOCKED
LIMIT $3
)
UPDATE video_tasks
SET next_poll_at = TO_TIMESTAMP($4),
updated_at = TO_TIMESTAMP($5)
WHERE id IN (SELECT id FROM due)
RETURNING
2026-04-12 20:45:47 +08:00
{columns}
2026-03-31 19:19:04 +08:00
"
)
}
fn upsert_sql () -> String {
2026-04-12 20:45:47 +08:00
let columns = select_video_task_full_columns ();
2026-03-31 19:19:04 +08:00
format! (
"INSERT INTO video_tasks (
id,
short_id,
request_id,
user_id,
api_key_id,
username,
api_key_name,
external_task_id,
provider_id,
endpoint_id,
key_id,
client_api_format,
provider_api_format,
format_converted,
model,
prompt,
original_request_body,
duration_seconds,
resolution,
aspect_ratio,
size,
status,
progress_percent,
progress_message,
retry_count,
poll_interval_seconds,
next_poll_at,
poll_count,
max_poll_count,
video_url,
error_code,
error_message,
request_metadata,
created_at,
submitted_at,
completed_at,
updated_at
) VALUES (
$1,
$2,
$3,
$4,
$5,
$6,
$7,
$8,
$9,
$10,
$11,
$12,
$13,
$14,
$15,
$16,
$17,
$18,
$19,
$20,
$21,
$22,
$23,
$24,
$25,
$26,
TO_TIMESTAMP($27),
$28,
$29,
$30,
$31,
$32,
$33,
TO_TIMESTAMP($34),
TO_TIMESTAMP($35),
TO_TIMESTAMP($36),
TO_TIMESTAMP($37)
)
ON CONFLICT (id) DO UPDATE SET
short_id = EXCLUDED.short_id,
request_id = EXCLUDED.request_id,
user_id = EXCLUDED.user_id,
api_key_id = EXCLUDED.api_key_id,
username = EXCLUDED.username,
api_key_name = EXCLUDED.api_key_name,
external_task_id = EXCLUDED.external_task_id,
provider_id = EXCLUDED.provider_id,
endpoint_id = EXCLUDED.endpoint_id,
key_id = EXCLUDED.key_id,
client_api_format = EXCLUDED.client_api_format,
provider_api_format = EXCLUDED.provider_api_format,
format_converted = EXCLUDED.format_converted,
model = EXCLUDED.model,
prompt = EXCLUDED.prompt,
original_request_body = EXCLUDED.original_request_body,
duration_seconds = EXCLUDED.duration_seconds,
resolution = EXCLUDED.resolution,
aspect_ratio = EXCLUDED.aspect_ratio,
size = EXCLUDED.size,
status = EXCLUDED.status,
progress_percent = EXCLUDED.progress_percent,
progress_message = EXCLUDED.progress_message,
retry_count = EXCLUDED.retry_count,
poll_interval_seconds = EXCLUDED.poll_interval_seconds,
next_poll_at = EXCLUDED.next_poll_at,
poll_count = EXCLUDED.poll_count,
max_poll_count = EXCLUDED.max_poll_count,
video_url = EXCLUDED.video_url,
error_code = EXCLUDED.error_code,
error_message = EXCLUDED.error_message,
request_metadata = EXCLUDED.request_metadata,
2026-09-04 03:45:52 +08:00
created_at = COALESCE(video_tasks.created_at, EXCLUDED.created_at),
2026-03-31 19:19:04 +08:00
submitted_at = EXCLUDED.submitted_at,
completed_at = EXCLUDED.completed_at,
updated_at = EXCLUDED.updated_at
2026-09-04 03:45:52 +08:00
WHERE video_tasks.short_id IS NOT DISTINCT FROM EXCLUDED.short_id
AND video_tasks.request_id IS NOT DISTINCT FROM EXCLUDED.request_id
AND video_tasks.user_id IS NOT DISTINCT FROM EXCLUDED.user_id
AND video_tasks.api_key_id IS NOT DISTINCT FROM EXCLUDED.api_key_id
AND video_tasks.external_task_id IS NOT DISTINCT FROM EXCLUDED.external_task_id
AND video_tasks.provider_id IS NOT DISTINCT FROM EXCLUDED.provider_id
AND video_tasks.endpoint_id IS NOT DISTINCT FROM EXCLUDED.endpoint_id
AND video_tasks.key_id IS NOT DISTINCT FROM EXCLUDED.key_id
AND video_tasks.client_api_format IS NOT DISTINCT FROM EXCLUDED.client_api_format
AND video_tasks.provider_api_format IS NOT DISTINCT FROM EXCLUDED.provider_api_format
AND video_tasks.format_converted IS NOT DISTINCT FROM EXCLUDED.format_converted
AND video_tasks.model IS NOT DISTINCT FROM EXCLUDED.model
AND video_tasks.duration_seconds IS NOT DISTINCT FROM EXCLUDED.duration_seconds
AND video_tasks.resolution IS NOT DISTINCT FROM EXCLUDED.resolution
AND video_tasks.aspect_ratio IS NOT DISTINCT FROM EXCLUDED.aspect_ratio
AND video_tasks.size IS NOT DISTINCT FROM EXCLUDED.size
2026-03-31 19:19:04 +08:00
RETURNING
2026-04-12 20:45:47 +08:00
{columns}
2026-03-31 19:19:04 +08:00
"
)
}
fn update_if_active_sql () -> String {
2026-04-12 20:45:47 +08:00
let columns = select_video_task_full_columns ();
2026-03-31 19:19:04 +08:00
format! (
"UPDATE video_tasks SET
short_id = $2,
request_id = $3,
user_id = $4,
api_key_id = $5,
username = $6,
api_key_name = $7,
external_task_id = $8,
provider_id = $9,
endpoint_id = $10,
key_id = $11,
client_api_format = $12,
provider_api_format = $13,
format_converted = $14,
model = $15,
prompt = $16,
original_request_body = $17,
duration_seconds = $18,
resolution = $19,
aspect_ratio = $20,
size = $21,
status = $22,
progress_percent = $23,
progress_message = $24,
retry_count = $25,
poll_interval_seconds = $26,
next_poll_at = TO_TIMESTAMP($27),
poll_count = $28,
max_poll_count = $29,
video_url = $30,
error_code = $31,
error_message = $32,
request_metadata = $33,
2026-09-04 03:45:52 +08:00
created_at = COALESCE(created_at, TO_TIMESTAMP($34)),
2026-03-31 19:19:04 +08:00
submitted_at = TO_TIMESTAMP($35),
completed_at = TO_TIMESTAMP($36),
updated_at = TO_TIMESTAMP($37)
2026-03-24 15:12:56 +08:00
WHERE id = $1
2026-03-31 19:19:04 +08:00
AND status = ANY($38)
2026-09-04 03:45:52 +08:00
AND short_id IS NOT DISTINCT FROM $2
AND request_id IS NOT DISTINCT FROM $3
AND user_id IS NOT DISTINCT FROM $4
AND api_key_id IS NOT DISTINCT FROM $5
AND external_task_id IS NOT DISTINCT FROM $8
AND provider_id IS NOT DISTINCT FROM $9
AND endpoint_id IS NOT DISTINCT FROM $10
AND key_id IS NOT DISTINCT FROM $11
AND client_api_format IS NOT DISTINCT FROM $12
AND provider_api_format IS NOT DISTINCT FROM $13
AND format_converted IS NOT DISTINCT FROM $14
AND model IS NOT DISTINCT FROM $15
AND duration_seconds IS NOT DISTINCT FROM $18
AND resolution IS NOT DISTINCT FROM $19
AND aspect_ratio IS NOT DISTINCT FROM $20
AND size IS NOT DISTINCT FROM $21
2026-03-31 19:19:04 +08:00
RETURNING
2026-04-12 20:45:47 +08:00
{columns}
2026-03-31 19:19:04 +08:00
"
)
}
2026-03-24 15:12:56 +08:00
#[derive(Debug, Clone)]
2026-03-31 19:19:04 +08:00
pub struct SqlxVideoTaskRepository {
2026-03-24 15:12:56 +08:00
pool : PgPool ,
}
2026-03-31 19:19:04 +08:00
pub type SqlxVideoTaskReadRepository = SqlxVideoTaskRepository ;
impl SqlxVideoTaskRepository {
2026-03-24 15:12:56 +08:00
pub fn new ( pool : PgPool ) -> Self {
Self { pool }
}
pub fn pool ( & self ) -> & PgPool {
& self . pool
}
pub async fn find (
& self ,
key : VideoTaskLookupKey < '_ > ,
) -> Result < Option < StoredVideoTask > , DataLayerError > {
match key {
VideoTaskLookupKey ::Id ( id ) => self . find_by_id ( id ). await ,
VideoTaskLookupKey ::ShortId ( short_id ) => self . find_by_short_id ( short_id ). await ,
VideoTaskLookupKey ::UserExternal {
user_id ,
external_task_id ,
} => self . find_by_user_external ( user_id , external_task_id ). await ,
}
}
2026-09-04 03:45:52 +08:00
pub async fn find_for_user (
& self ,
key : VideoTaskLookupKey < '_ > ,
user_id : & str ,
) -> Result < Option < StoredVideoTask > , DataLayerError > {
match key {
VideoTaskLookupKey ::Id ( id ) => self . find_by_id_for_user ( id , user_id ). await ,
VideoTaskLookupKey ::ShortId ( short_id ) => {
self . find_by_short_id_for_user ( short_id , user_id ). await
}
VideoTaskLookupKey ::UserExternal {
user_id : lookup_user_id ,
external_task_id ,
} if lookup_user_id == user_id => {
self . find_by_user_external ( user_id , external_task_id ). await
}
VideoTaskLookupKey ::UserExternal { .. } => Ok ( None ),
}
}
2026-03-24 15:12:56 +08:00
pub async fn find_by_id ( & self , id : & str ) -> Result < Option < StoredVideoTask > , DataLayerError > {
2026-03-31 19:19:04 +08:00
let sql = find_by_id_sql ();
let row = sqlx ::query ( & sql )
2026-03-24 15:12:56 +08:00
. bind ( id )
. fetch_optional ( & self . pool )
2026-04-07 02:50:19 +08:00
. await
. map_postgres_err () ? ;
2026-03-24 15:12:56 +08:00
row . as_ref (). map ( map_video_task_row ). transpose ()
}
2026-09-04 03:45:52 +08:00
pub async fn find_by_id_for_user (
& self ,
id : & str ,
user_id : & str ,
) -> Result < Option < StoredVideoTask > , DataLayerError > {
let sql = find_by_id_for_user_sql ();
let row = sqlx ::query ( & sql )
. bind ( id )
. bind ( user_id )
. fetch_optional ( & self . pool )
. await
. map_postgres_err () ? ;
row . as_ref (). map ( map_video_task_row ). transpose ()
}
2026-03-24 15:12:56 +08:00
pub async fn find_by_short_id (
& self ,
short_id : & str ,
) -> Result < Option < StoredVideoTask > , DataLayerError > {
2026-03-31 19:19:04 +08:00
let sql = find_by_short_id_sql ();
let row = sqlx ::query ( & sql )
2026-03-24 15:12:56 +08:00
. bind ( short_id )
. fetch_optional ( & self . pool )
2026-04-07 02:50:19 +08:00
. await
. map_postgres_err () ? ;
2026-03-24 15:12:56 +08:00
row . as_ref (). map ( map_video_task_row ). transpose ()
}
2026-09-04 03:45:52 +08:00
pub async fn find_by_short_id_for_user (
& self ,
short_id : & str ,
user_id : & str ,
) -> Result < Option < StoredVideoTask > , DataLayerError > {
let sql = find_by_short_id_for_user_sql ();
let row = sqlx ::query ( & sql )
. bind ( short_id )
. bind ( user_id )
. fetch_optional ( & self . pool )
. await
. map_postgres_err () ? ;
row . as_ref (). map ( map_video_task_row ). transpose ()
}
2026-03-24 15:12:56 +08:00
pub async fn find_by_user_external (
& self ,
user_id : & str ,
external_task_id : & str ,
) -> Result < Option < StoredVideoTask > , DataLayerError > {
2026-03-31 19:19:04 +08:00
let sql = find_by_user_external_sql ();
let row = sqlx ::query ( & sql )
2026-03-24 15:12:56 +08:00
. bind ( user_id )
. bind ( external_task_id )
. fetch_optional ( & self . pool )
2026-04-07 02:50:19 +08:00
. await
. map_postgres_err () ? ;
2026-03-24 15:12:56 +08:00
row . as_ref (). map ( map_video_task_row ). transpose ()
}
pub async fn list_active ( & self , limit : usize ) -> Result < Vec < StoredVideoTask > , DataLayerError > {
if limit == 0 {
return Ok ( Vec ::new ());
}
let active_statuses = vec! [ "pending" , "submitted" , "queued" , "processing" ];
2026-03-31 19:19:04 +08:00
let sql = list_active_sql ();
2026-04-12 20:45:47 +08:00
collect_query_rows (
sqlx ::query ( & sql )
. bind ( active_statuses )
. bind ( i64 ::try_from ( limit ). map_err ( | _ | {
DataLayerError ::UnexpectedValue ( format! ( "invalid active task limit: {limit} " ))
}) ? )
. fetch ( & self . pool ),
map_video_task_row ,
)
. await
2026-03-24 15:12:56 +08:00
}
2026-03-31 19:19:04 +08:00
pub async fn list_due (
& self ,
now_unix_secs : u64 ,
limit : usize ,
) -> Result < Vec < StoredVideoTask > , DataLayerError > {
if limit == 0 {
return Ok ( Vec ::new ());
}
let active_statuses = vec! [ "submitted" , "queued" , "processing" ];
let sql = list_due_sql ();
2026-04-12 20:45:47 +08:00
collect_query_rows (
sqlx ::query ( & sql )
. bind ( active_statuses )
. bind ( now_unix_secs as f64 )
. bind ( i64 ::try_from ( limit ). map_err ( | _ | {
DataLayerError ::UnexpectedValue ( format! ( "invalid due task limit: {limit} " ))
}) ? )
. fetch ( & self . pool ),
map_video_task_row ,
)
. await
2026-03-31 19:19:04 +08:00
}
pub async fn list_page (
& self ,
filter : & VideoTaskQueryFilter ,
offset : usize ,
limit : usize ,
) -> Result < Vec < StoredVideoTask > , DataLayerError > {
if limit == 0 {
return Ok ( Vec ::new ());
}
let offset = i64 ::try_from ( offset )
. map_err ( | _ | DataLayerError ::UnexpectedValue ( format! ( "invalid offset: {offset} " ))) ? ;
let limit = i64 ::try_from ( limit )
. map_err ( | _ | DataLayerError ::UnexpectedValue ( format! ( "invalid limit: {limit} " ))) ? ;
let mut builder = QueryBuilder ::< Postgres > ::new ( "SELECT \n " );
2026-04-12 20:45:47 +08:00
builder . push ( select_video_task_full_columns ());
2026-03-31 19:19:04 +08:00
builder . push ( " \n FROM video_tasks" );
push_video_task_filter ( & mut builder , filter , None );
builder . push ( " \n ORDER BY created_at DESC, updated_at DESC" );
builder . push ( " \n OFFSET " );
builder . push_bind ( offset );
builder . push ( " \n LIMIT " );
builder . push_bind ( limit );
2026-04-12 20:45:47 +08:00
let query = builder . build ();
collect_query_rows ( query . fetch ( & self . pool ), map_video_task_row ). await
}
pub async fn list_page_summary (
& self ,
filter : & VideoTaskQueryFilter ,
offset : usize ,
limit : usize ,
) -> Result < Vec < StoredVideoTask > , DataLayerError > {
if limit == 0 {
return Ok ( Vec ::new ());
}
let offset = i64 ::try_from ( offset )
. map_err ( | _ | DataLayerError ::UnexpectedValue ( format! ( "invalid offset: {offset} " ))) ? ;
let limit = i64 ::try_from ( limit )
. map_err ( | _ | DataLayerError ::UnexpectedValue ( format! ( "invalid limit: {limit} " ))) ? ;
let mut builder = QueryBuilder ::< Postgres > ::new ( "SELECT \n " );
builder . push ( select_video_task_page_summary_columns ());
builder . push ( " \n FROM video_tasks" );
push_video_task_filter ( & mut builder , filter , None );
builder . push ( " \n ORDER BY created_at DESC, updated_at DESC" );
builder . push ( " \n OFFSET " );
builder . push_bind ( offset );
builder . push ( " \n LIMIT " );
builder . push_bind ( limit );
let query = builder . build ();
collect_query_rows ( query . fetch ( & self . pool ), map_video_task_row ). await
2026-03-31 19:19:04 +08:00
}
pub async fn count ( & self , filter : & VideoTaskQueryFilter ) -> Result < u64 , DataLayerError > {
let mut builder =
QueryBuilder ::< Postgres > ::new ( "SELECT COUNT(id) AS total FROM video_tasks" );
push_video_task_filter ( & mut builder , filter , None );
2026-04-07 02:50:19 +08:00
let row = builder
. build ()
. fetch_one ( & self . pool )
. await
. map_postgres_err () ? ;
let total = row . try_get ::< i64 , _ > ( "total" ). map_postgres_err () ? ;
2026-03-31 19:19:04 +08:00
u64 ::try_from ( total )
. map_err ( | _ | DataLayerError ::UnexpectedValue ( format! ( "invalid count result: {total} " )))
}
pub async fn count_by_status (
& self ,
filter : & VideoTaskQueryFilter ,
) -> Result < Vec < VideoTaskStatusCount > , DataLayerError > {
let mut builder =
QueryBuilder ::< Postgres > ::new ( "SELECT status, COUNT(id) AS total FROM video_tasks" );
push_video_task_filter ( & mut builder , filter , None );
builder . push ( " \n GROUP BY status \n ORDER BY status ASC" );
2026-04-12 20:45:47 +08:00
let query = builder . build ();
let mut rows = query . fetch ( & self . pool );
let mut items = Vec ::new ();
while let Some ( row ) = rows . try_next (). await . map_postgres_err () ? {
let entry = {
2026-04-07 02:50:19 +08:00
let status = VideoTaskStatus ::from_database (
row . try_get ::< String , _ > ( "status" )
. map_postgres_err () ?
. as_str (),
) ? ;
let total = row . try_get ::< i64 , _ > ( "total" ). map_postgres_err () ? ;
2026-04-12 20:45:47 +08:00
VideoTaskStatusCount {
2026-03-31 19:19:04 +08:00
status ,
count : u64 ::try_from ( total ). map_err ( | _ | {
DataLayerError ::UnexpectedValue ( format! (
"invalid status count result: {total} "
))
}) ? ,
2026-04-12 20:45:47 +08:00
}
};
items . push ( entry );
}
Ok ( items )
2026-03-31 19:19:04 +08:00
}
pub async fn count_distinct_users (
& self ,
filter : & VideoTaskQueryFilter ,
) -> Result < u64 , DataLayerError > {
let mut builder = QueryBuilder ::< Postgres > ::new (
"SELECT COUNT(DISTINCT user_id) AS total FROM video_tasks" ,
);
push_video_task_filter ( & mut builder , filter , None );
let mut has_where = has_video_task_filter ( filter , None );
push_sql_clause ( & mut builder , & mut has_where , "user_id IS NOT NULL" );
push_sql_clause ( & mut builder , & mut has_where , "user_id <> ''" );
2026-04-07 02:50:19 +08:00
let row = builder
. build ()
. fetch_one ( & self . pool )
. await
. map_postgres_err () ? ;
let total = row . try_get ::< i64 , _ > ( "total" ). map_postgres_err () ? ;
2026-03-31 19:19:04 +08:00
u64 ::try_from ( total )
. map_err ( | _ | DataLayerError ::UnexpectedValue ( format! ( "invalid count result: {total} " )))
}
pub async fn top_models (
& self ,
filter : & VideoTaskQueryFilter ,
limit : usize ,
) -> Result < Vec < VideoTaskModelCount > , DataLayerError > {
if limit == 0 {
return Ok ( Vec ::new ());
}
let limit = i64 ::try_from ( limit )
. map_err ( | _ | DataLayerError ::UnexpectedValue ( format! ( "invalid limit: {limit} " ))) ? ;
let mut builder =
QueryBuilder ::< Postgres > ::new ( "SELECT model, COUNT(id) AS total FROM video_tasks" );
push_video_task_filter ( & mut builder , filter , None );
let mut has_where = has_video_task_filter ( filter , None );
push_sql_clause ( & mut builder , & mut has_where , "model IS NOT NULL" );
push_sql_clause ( & mut builder , & mut has_where , "model <> ''" );
builder . push ( " \n GROUP BY model \n ORDER BY total DESC, model ASC \n LIMIT " );
builder . push_bind ( limit );
2026-04-12 20:45:47 +08:00
let query = builder . build ();
let mut rows = query . fetch ( & self . pool );
let mut items = Vec ::new ();
while let Some ( row ) = rows . try_next (). await . map_postgres_err () ? {
let entry = {
2026-04-07 02:50:19 +08:00
let model = row . try_get ::< String , _ > ( "model" ). map_postgres_err () ? ;
let total = row . try_get ::< i64 , _ > ( "total" ). map_postgres_err () ? ;
2026-04-12 20:45:47 +08:00
VideoTaskModelCount {
2026-03-31 19:19:04 +08:00
model ,
count : u64 ::try_from ( total ). map_err ( | _ | {
DataLayerError ::UnexpectedValue ( format! (
"invalid model count result: {total} "
))
}) ? ,
2026-04-12 20:45:47 +08:00
}
};
items . push ( entry );
}
Ok ( items )
2026-03-31 19:19:04 +08:00
}
pub async fn count_created_since (
& self ,
filter : & VideoTaskQueryFilter ,
created_since_unix_secs : u64 ,
) -> Result < u64 , DataLayerError > {
let mut builder =
QueryBuilder ::< Postgres > ::new ( "SELECT COUNT(id) AS total FROM video_tasks" );
push_video_task_filter ( & mut builder , filter , Some ( created_since_unix_secs ));
2026-04-07 02:50:19 +08:00
let row = builder
. build ()
. fetch_one ( & self . pool )
. await
. map_postgres_err () ? ;
let total = row . try_get ::< i64 , _ > ( "total" ). map_postgres_err () ? ;
2026-03-31 19:19:04 +08:00
u64 ::try_from ( total )
. map_err ( | _ | DataLayerError ::UnexpectedValue ( format! ( "invalid count result: {total} " )))
}
2026-09-04 03:45:52 +08:00
pub async fn upsert (
& self ,
mut task : UpsertVideoTask ,
) -> Result < StoredVideoTask , DataLayerError > {
task . sanitize_for_persistence ();
let task_id = task . id . clone ();
2026-03-31 19:19:04 +08:00
let sql = upsert_sql ();
let row = sqlx ::query ( & sql )
. bind ( task . id )
. bind ( task . short_id )
. bind ( task . request_id )
. bind ( task . user_id )
. bind ( task . api_key_id )
. bind ( task . username )
. bind ( task . api_key_name )
. bind ( task . external_task_id )
. bind ( task . provider_id )
. bind ( task . endpoint_id )
. bind ( task . key_id )
. bind ( task . client_api_format )
. bind ( task . provider_api_format )
. bind ( task . format_converted )
. bind ( task . model )
. bind ( task . prompt )
. bind ( task . original_request_body )
. bind (
task . duration_seconds
. map ( i32 ::try_from )
. transpose ()
. map_err ( | _ | {
DataLayerError ::UnexpectedValue (
"invalid video task duration_seconds" . to_string (),
)
}) ? ,
)
. bind ( task . resolution )
. bind ( task . aspect_ratio )
. bind ( task . size )
. bind ( map_status_for_database ( task . status ))
. bind ( i32 ::from ( task . progress_percent ))
. bind ( task . progress_message )
. bind ( i32 ::try_from ( task . retry_count ). map_err ( | _ | {
DataLayerError ::UnexpectedValue ( "invalid video task retry_count" . to_string ())
}) ? )
. bind ( i32 ::try_from ( task . poll_interval_seconds ). map_err ( | _ | {
DataLayerError ::UnexpectedValue (
"invalid video task poll_interval_seconds" . to_string (),
)
}) ? )
. bind ( task . next_poll_at_unix_secs . map ( | value | value as f64 ))
. bind ( i32 ::try_from ( task . poll_count ). map_err ( | _ | {
DataLayerError ::UnexpectedValue ( "invalid video task poll_count" . to_string ())
}) ? )
. bind ( i32 ::try_from ( task . max_poll_count ). map_err ( | _ | {
DataLayerError ::UnexpectedValue ( "invalid video task max_poll_count" . to_string ())
}) ? )
. bind ( task . video_url )
. bind ( task . error_code )
. bind ( task . error_message )
. bind ( task . request_metadata )
2026-04-10 01:46:14 +08:00
. bind ( task . created_at_unix_ms as f64 )
2026-03-31 19:19:04 +08:00
. bind ( task . submitted_at_unix_secs . map ( | value | value as f64 ))
. bind ( task . completed_at_unix_secs . map ( | value | value as f64 ))
. bind ( task . updated_at_unix_secs as f64 )
2026-09-04 03:45:52 +08:00
. fetch_optional ( & self . pool )
2026-04-07 02:50:19 +08:00
. await
2026-09-04 03:45:52 +08:00
. map_postgres_err () ?
. ok_or_else ( || {
DataLayerError ::InvalidInput ( format! (
"video task {task_id} conflicts with persisted immutable identity"
))
}) ? ;
2026-03-31 19:19:04 +08:00
map_video_task_row ( & row )
}
pub async fn update_if_active (
& self ,
2026-09-04 03:45:52 +08:00
mut task : UpsertVideoTask ,
2026-03-31 19:19:04 +08:00
) -> Result < Option < StoredVideoTask > , DataLayerError > {
2026-09-04 03:45:52 +08:00
task . sanitize_for_persistence ();
2026-03-31 19:19:04 +08:00
let sql = update_if_active_sql ();
let row = sqlx ::query ( & sql )
. bind ( task . id )
. bind ( task . short_id )
. bind ( task . request_id )
. bind ( task . user_id )
. bind ( task . api_key_id )
. bind ( task . username )
. bind ( task . api_key_name )
. bind ( task . external_task_id )
. bind ( task . provider_id )
. bind ( task . endpoint_id )
. bind ( task . key_id )
. bind ( task . client_api_format )
. bind ( task . provider_api_format )
. bind ( task . format_converted )
. bind ( task . model )
. bind ( task . prompt )
. bind ( task . original_request_body )
. bind (
task . duration_seconds
. map ( i32 ::try_from )
. transpose ()
. map_err ( | _ | {
DataLayerError ::UnexpectedValue (
"invalid video task duration_seconds" . to_string (),
)
}) ? ,
)
. bind ( task . resolution )
. bind ( task . aspect_ratio )
. bind ( task . size )
. bind ( map_status_for_database ( task . status ))
. bind ( i32 ::from ( task . progress_percent ))
. bind ( task . progress_message )
. bind ( i32 ::try_from ( task . retry_count ). map_err ( | _ | {
DataLayerError ::UnexpectedValue ( "invalid video task retry_count" . to_string ())
}) ? )
. bind ( i32 ::try_from ( task . poll_interval_seconds ). map_err ( | _ | {
DataLayerError ::UnexpectedValue (
"invalid video task poll_interval_seconds" . to_string (),
)
}) ? )
. bind ( task . next_poll_at_unix_secs . map ( | value | value as f64 ))
. bind ( i32 ::try_from ( task . poll_count ). map_err ( | _ | {
DataLayerError ::UnexpectedValue ( "invalid video task poll_count" . to_string ())
}) ? )
. bind ( i32 ::try_from ( task . max_poll_count ). map_err ( | _ | {
DataLayerError ::UnexpectedValue ( "invalid video task max_poll_count" . to_string ())
}) ? )
. bind ( task . video_url )
. bind ( task . error_code )
. bind ( task . error_message )
. bind ( task . request_metadata )
2026-04-10 01:46:14 +08:00
. bind ( task . created_at_unix_ms as f64 )
2026-03-31 19:19:04 +08:00
. bind ( task . submitted_at_unix_secs . map ( | value | value as f64 ))
. bind ( task . completed_at_unix_secs . map ( | value | value as f64 ))
. bind ( task . updated_at_unix_secs as f64 )
. bind ( vec! [ "pending" , "submitted" , "queued" , "processing" ])
. fetch_optional ( & self . pool )
2026-04-07 02:50:19 +08:00
. await
. map_postgres_err () ? ;
2026-03-31 19:19:04 +08:00
row . as_ref (). map ( map_video_task_row ). transpose ()
}
pub async fn claim_due (
& self ,
now_unix_secs : u64 ,
claim_until_unix_secs : u64 ,
limit : usize ,
) -> Result < Vec < StoredVideoTask > , DataLayerError > {
if limit == 0 {
return Ok ( Vec ::new ());
}
let limit = i64 ::try_from ( limit )
. map_err ( | _ | DataLayerError ::UnexpectedValue ( format! ( "invalid limit: {limit} " ))) ? ;
let sql = claim_due_sql ();
2026-04-12 20:45:47 +08:00
let mut tasks = collect_query_rows (
sqlx ::query ( & sql )
. bind ( vec! [ "submitted" , "queued" , "processing" ])
. bind ( now_unix_secs as f64 )
. bind ( limit )
. bind ( claim_until_unix_secs as f64 )
. bind ( now_unix_secs as f64 )
. fetch ( & self . pool ),
map_video_task_row ,
)
. await ? ;
2026-03-31 19:19:04 +08:00
tasks . sort_by ( | left , right | {
left . next_poll_at_unix_secs
. cmp ( & right . next_poll_at_unix_secs )
. then_with ( || left . updated_at_unix_secs . cmp ( & right . updated_at_unix_secs ))
});
Ok ( tasks )
}
2026-03-24 15:12:56 +08:00
}
#[async_trait]
2026-03-31 19:19:04 +08:00
impl VideoTaskReadRepository for SqlxVideoTaskRepository {
2026-03-24 15:12:56 +08:00
async fn find (
& self ,
key : VideoTaskLookupKey < '_ > ,
) -> Result < Option < StoredVideoTask > , DataLayerError > {
Self ::find ( self , key ). await
}
2026-09-04 03:45:52 +08:00
async fn find_for_user (
& self ,
key : VideoTaskLookupKey < '_ > ,
user_id : & str ,
) -> Result < Option < StoredVideoTask > , DataLayerError > {
Self ::find_for_user ( self , key , user_id ). await
}
2026-03-24 15:12:56 +08:00
async fn list_active ( & self , limit : usize ) -> Result < Vec < StoredVideoTask > , DataLayerError > {
Self ::list_active ( self , limit ). await
}
2026-03-31 19:19:04 +08:00
async fn list_due (
& self ,
now_unix_secs : u64 ,
limit : usize ,
) -> Result < Vec < StoredVideoTask > , DataLayerError > {
Self ::list_due ( self , now_unix_secs , limit ). await
}
async fn list_page (
& self ,
filter : & VideoTaskQueryFilter ,
offset : usize ,
limit : usize ,
) -> Result < Vec < StoredVideoTask > , DataLayerError > {
Self ::list_page ( self , filter , offset , limit ). await
}
2026-04-12 20:45:47 +08:00
async fn list_page_summary (
& self ,
filter : & VideoTaskQueryFilter ,
offset : usize ,
limit : usize ,
) -> Result < Vec < StoredVideoTask > , DataLayerError > {
Self ::list_page_summary ( self , filter , offset , limit ). await
}
2026-03-31 19:19:04 +08:00
async fn count ( & self , filter : & VideoTaskQueryFilter ) -> Result < u64 , DataLayerError > {
Self ::count ( self , filter ). await
}
async fn count_by_status (
& self ,
filter : & VideoTaskQueryFilter ,
) -> Result < Vec < VideoTaskStatusCount > , DataLayerError > {
Self ::count_by_status ( self , filter ). await
}
async fn count_distinct_users (
& self ,
filter : & VideoTaskQueryFilter ,
) -> Result < u64 , DataLayerError > {
Self ::count_distinct_users ( self , filter ). await
}
async fn top_models (
& self ,
filter : & VideoTaskQueryFilter ,
limit : usize ,
) -> Result < Vec < VideoTaskModelCount > , DataLayerError > {
Self ::top_models ( self , filter , limit ). await
}
async fn count_created_since (
& self ,
filter : & VideoTaskQueryFilter ,
created_since_unix_secs : u64 ,
) -> Result < u64 , DataLayerError > {
Self ::count_created_since ( self , filter , created_since_unix_secs ). await
}
}
#[async_trait]
impl VideoTaskWriteRepository for SqlxVideoTaskRepository {
async fn upsert ( & self , task : UpsertVideoTask ) -> Result < StoredVideoTask , DataLayerError > {
Self ::upsert ( self , task ). await
}
async fn update_if_active (
& self ,
task : UpsertVideoTask ,
) -> Result < Option < StoredVideoTask > , DataLayerError > {
Self ::update_if_active ( self , task ). await
}
async fn claim_due (
& self ,
now_unix_secs : u64 ,
claim_until_unix_secs : u64 ,
limit : usize ,
) -> Result < Vec < StoredVideoTask > , DataLayerError > {
Self ::claim_due ( self , now_unix_secs , claim_until_unix_secs , limit ). await
}
}
fn has_video_task_filter (
filter : & VideoTaskQueryFilter ,
created_since_unix_secs : Option < u64 > ,
) -> bool {
filter . user_id . is_some ()
|| filter . status . is_some ()
|| filter . model_substring . is_some ()
|| filter . client_api_format . is_some ()
|| created_since_unix_secs . is_some ()
}
fn push_sql_clause < 'args > (
builder : & mut QueryBuilder < 'args , Postgres > ,
has_where : & mut bool ,
clause : & str ,
) {
if * has_where {
builder . push ( " \n AND " );
} else {
builder . push ( " \n WHERE " );
* has_where = true ;
}
builder . push ( clause );
}
fn push_video_task_filter < 'args > (
builder : & mut QueryBuilder < 'args , Postgres > ,
filter : & 'args VideoTaskQueryFilter ,
created_since_unix_secs : Option < u64 > ,
) {
let mut has_where = false ;
if let Some ( user_id ) = filter . user_id . as_deref () {
push_sql_clause ( builder , & mut has_where , "user_id = " );
builder . push_bind ( user_id );
}
if let Some ( status ) = filter . status {
push_sql_clause ( builder , & mut has_where , "status = " );
builder . push_bind ( map_status_for_database ( status ));
}
if let Some ( model_substring ) = filter . model_substring . as_deref () {
push_sql_clause ( builder , & mut has_where , "model ILIKE " );
builder . push_bind ( format! ( "% {} %" , escape_like_pattern ( model_substring . trim ())));
builder . push ( " ESCAPE ' \\ '" );
}
if let Some ( client_api_format ) = filter . client_api_format . as_deref () {
push_sql_clause ( builder , & mut has_where , "client_api_format = " );
builder . push_bind ( client_api_format );
}
if let Some ( created_since_unix_secs ) = created_since_unix_secs {
push_sql_clause ( builder , & mut has_where , "created_at >= TO_TIMESTAMP(" );
builder . push_bind ( created_since_unix_secs as f64 );
builder . push ( ")" );
}
}
fn escape_like_pattern ( value : & str ) -> String {
value
. replace ( '\\' , " \\\\ " )
. replace ( '%' , " \\ %" )
. replace ( '_' , " \\ _" )
}
fn map_status_for_database ( status : VideoTaskStatus ) -> & 'static str {
match status {
VideoTaskStatus ::Pending => "pending" ,
VideoTaskStatus ::Submitted => "submitted" ,
VideoTaskStatus ::Queued => "queued" ,
VideoTaskStatus ::Processing => "processing" ,
VideoTaskStatus ::Completed => "completed" ,
VideoTaskStatus ::Failed => "failed" ,
VideoTaskStatus ::Cancelled => "cancelled" ,
VideoTaskStatus ::Expired => "expired" ,
VideoTaskStatus ::Deleted => "deleted" ,
}
2026-03-24 15:12:56 +08:00
}
2026-04-12 20:45:47 +08:00
async fn collect_query_rows < T , S > (
mut rows : S ,
map_row : fn ( & PgRow ) -> Result < T , DataLayerError > ,
) -> Result < Vec < T > , DataLayerError >
where
S : TryStream < Ok = PgRow , Error = sqlx ::Error > + Unpin ,
{
let mut items = Vec ::new ();
while let Some ( row ) = rows . try_next (). await . map_postgres_err () ? {
items . push ( map_row ( & row ) ? );
}
Ok ( items )
}
fn map_video_task_row ( row : & PgRow ) -> Result < StoredVideoTask , DataLayerError > {
2026-04-07 02:50:19 +08:00
let status = VideoTaskStatus ::from_database (
row . try_get ::< String , _ > ( "status" )
. map_postgres_err () ?
. as_str (),
) ? ;
2026-03-24 15:12:56 +08:00
StoredVideoTask ::new (
2026-04-07 02:50:19 +08:00
row . try_get ( "id" ). map_postgres_err () ? ,
row . try_get ( "short_id" ). map_postgres_err () ? ,
row . try_get ( "request_id" ). map_postgres_err () ? ,
row . try_get ( "user_id" ). map_postgres_err () ? ,
row . try_get ( "api_key_id" ). map_postgres_err () ? ,
row . try_get ( "username" ). map_postgres_err () ? ,
row . try_get ( "api_key_name" ). map_postgres_err () ? ,
row . try_get ( "external_task_id" ). map_postgres_err () ? ,
row . try_get ( "provider_id" ). map_postgres_err () ? ,
row . try_get ( "endpoint_id" ). map_postgres_err () ? ,
row . try_get ( "key_id" ). map_postgres_err () ? ,
row . try_get ( "client_api_format" ). map_postgres_err () ? ,
row . try_get ( "provider_api_format" ). map_postgres_err () ? ,
row . try_get ( "format_converted" ). map_postgres_err () ? ,
row . try_get ( "model" ). map_postgres_err () ? ,
row . try_get ( "prompt" ). map_postgres_err () ? ,
row . try_get ( "original_request_body" ). map_postgres_err () ? ,
row . try_get ( "duration_seconds" ). map_postgres_err () ? ,
row . try_get ( "resolution" ). map_postgres_err () ? ,
row . try_get ( "aspect_ratio" ). map_postgres_err () ? ,
row . try_get ( "size" ). map_postgres_err () ? ,
2026-03-24 15:12:56 +08:00
status ,
2026-04-07 02:50:19 +08:00
row . try_get ( "progress_percent" ). map_postgres_err () ? ,
row . try_get ( "progress_message" ). map_postgres_err () ? ,
row . try_get ( "retry_count" ). map_postgres_err () ? ,
row . try_get ( "poll_interval_seconds" ). map_postgres_err () ? ,
row . try_get ( "next_poll_at_unix_secs" ). map_postgres_err () ? ,
row . try_get ( "poll_count" ). map_postgres_err () ? ,
row . try_get ( "max_poll_count" ). map_postgres_err () ? ,
2026-04-10 01:46:14 +08:00
row . try_get ( "created_at_unix_ms" ). map_postgres_err () ? ,
2026-04-07 02:50:19 +08:00
row . try_get ( "submitted_at_unix_secs" ). map_postgres_err () ? ,
row . try_get ( "completed_at_unix_secs" ). map_postgres_err () ? ,
row . try_get ( "updated_at_unix_secs" ). map_postgres_err () ? ,
row . try_get ( "error_code" ). map_postgres_err () ? ,
row . try_get ( "error_message" ). map_postgres_err () ? ,
row . try_get ( "video_url" ). map_postgres_err () ? ,
row . try_get ( "request_metadata" ). map_postgres_err () ? ,
2026-03-24 15:12:56 +08:00
)
}
#[cfg(test)]
mod tests {
2026-09-07 21:14:27 +08:00
use super ::{ claim_due_sql , update_if_active_sql , upsert_sql , SqlxVideoTaskRepository };
2026-07-15 23:47:19 +08:00
use crate ::{ PostgresPoolConfig , PostgresPoolFactory };
use aether_data_contracts ::repository ::video_tasks ::{
2026-03-31 19:19:04 +08:00
UpsertVideoTask , VideoTaskLookupKey , VideoTaskQueryFilter , VideoTaskReadRepository ,
VideoTaskStatus , VideoTaskWriteRepository ,
};
2026-03-24 15:12:56 +08:00
2026-03-31 19:19:04 +08:00
fn build_pool () -> sqlx ::PgPool {
2026-03-24 15:12:56 +08:00
let factory = PostgresPoolFactory ::new ( PostgresPoolConfig {
database_url : "postgres://localhost/aether" . to_string (),
min_connections : 1 ,
max_connections : 4 ,
acquire_timeout_ms : 1_000 ,
idle_timeout_ms : 5_000 ,
max_lifetime_ms : 30_000 ,
statement_cache_capacity : 64 ,
require_ssl : false ,
})
. expect ( "factory should build" );
2026-03-31 19:19:04 +08:00
factory . connect_lazy (). expect ( "pool should build" )
}
2026-09-04 03:45:52 +08:00
#[test]
fn write_sql_guards_every_immutable_identity_field () {
let upsert = upsert_sql ();
let update = update_if_active_sql ();
let immutable_columns = [
( "short_id" , "$2" ),
( "request_id" , "$3" ),
( "user_id" , "$4" ),
( "api_key_id" , "$5" ),
( "external_task_id" , "$8" ),
( "provider_id" , "$9" ),
( "endpoint_id" , "$10" ),
( "key_id" , "$11" ),
( "client_api_format" , "$12" ),
( "provider_api_format" , "$13" ),
( "format_converted" , "$14" ),
( "model" , "$15" ),
( "duration_seconds" , "$18" ),
( "resolution" , "$19" ),
( "aspect_ratio" , "$20" ),
( "size" , "$21" ),
];
for ( column , parameter ) in immutable_columns {
assert! (
upsert . contains ( & format! (
"video_tasks. {column} IS NOT DISTINCT FROM EXCLUDED. {column} "
)),
"upsert should guard {column}"
);
assert! (
update . contains ( & format! ( " {column} IS NOT DISTINCT FROM {parameter} " )),
"active update should guard {column}"
);
}
assert! (
upsert . contains ( "created_at = COALESCE(video_tasks.created_at, EXCLUDED.created_at)" )
);
assert! ( update . contains ( "created_at = COALESCE(created_at, TO_TIMESTAMP($34))" ));
}
2026-09-07 21:14:27 +08:00
#[test]
fn poll_claim_returns_business_fields_required_by_identity_guards () {
let sql = claim_due_sql ();
for field in [
"prompt" ,
"duration_seconds" ,
"resolution" ,
"aspect_ratio" ,
"size" ,
] {
assert! (
sql . contains ( & format! ( " {field} AS {field} " )),
"claim must retain {field}"
);
}
assert! ( sql . contains ( "NULL::jsonb AS original_request_body" ));
assert! ( sql . contains ( "FOR UPDATE SKIP LOCKED" ));
}
#[tokio::test]
#[ignore = "requires AETHER_TEST_DATABASE_URL and PostgreSQL migrations" ]
async fn live_video_task_capture_claim_and_completion_preserve_business_fields () {
let database_url = std ::env ::var ( "AETHER_TEST_DATABASE_URL" )
. expect ( "AETHER_TEST_DATABASE_URL must point at the test database" );
let pool = sqlx ::postgres ::PgPoolOptions ::new ()
. max_connections ( 1 )
. connect ( & database_url )
. await
. expect ( "test database should connect" );
crate ::run_migrations ( & pool )
. await
. expect ( "test database should migrate" );
sqlx ::query ( "CREATE TEMP TABLE video_tasks (LIKE public.video_tasks INCLUDING ALL)" )
. execute ( & pool )
. await
. expect ( "isolated task table should be created" );
let repository = SqlxVideoTaskRepository ::new ( pool );
for api_format in [ "openai:video" , "gemini:video" ] {
let task_id = uuid ::Uuid ::new_v4 (). to_string ();
let original = UpsertVideoTask {
id : task_id . clone (),
short_id : Some ( uuid ::Uuid ::new_v4 (). simple (). to_string ()[ .. 16 ]. to_string ()),
request_id : format ! ( "request-{task_id}" ),
user_id : None ,
api_key_id : None ,
username : Some ( "alice" . to_string ()),
api_key_name : Some ( "video-client" . to_string ()),
external_task_id : Some ( "upstream-task-1" . to_string ()),
provider_id : None ,
endpoint_id : None ,
key_id : None ,
client_api_format : Some ( api_format . to_string ()),
provider_api_format : Some ( api_format . to_string ()),
format_converted : false ,
model : Some ( "video-model" . to_string ()),
prompt : Some ( "business prompt" . to_string ()),
original_request_body : Some ( serde_json ::json! ({ "token" : "private" })),
duration_seconds : Some ( 8 ),
resolution : Some ( "1080p" . to_string ()),
aspect_ratio : Some ( "16:9" . to_string ()),
size : Some ( "1920x1080" . to_string ()),
status : VideoTaskStatus ::Submitted ,
progress_percent : 0 ,
progress_message : None ,
retry_count : 0 ,
poll_interval_seconds : 10 ,
next_poll_at_unix_secs : Some ( 10 ),
poll_count : 0 ,
max_poll_count : 360 ,
created_at_unix_ms : 1 ,
submitted_at_unix_secs : Some ( 1 ),
completed_at_unix_secs : None ,
updated_at_unix_secs : 1 ,
error_code : None ,
error_message : None ,
video_url : None ,
request_metadata : Some ( serde_json ::json! ({ "authorization" : "private" })),
};
let stored = repository
. upsert ( original . clone ())
. await
. expect ( "task should persist" );
assert_eq! ( stored . prompt , original . prompt );
assert_eq! ( stored . username , original . username );
assert_eq! ( stored . api_key_name , original . api_key_name );
assert! ( stored . original_request_body . is_none ());
assert! ( stored . request_metadata . is_none ());
let mut claimed = repository
. claim_due ( 20 , 50 , 10 )
. await
. expect ( "task should be claimed" );
assert_eq! ( claimed . len (), 1 );
let mut completion : UpsertVideoTask = claimed . pop (). expect ( "claimed task" ). into ();
stored
. ensure_immutable_identity_matches ( & completion )
. expect ( "claim must preserve task identity" );
assert_eq! ( completion . prompt , original . prompt );
let mut mismatched = completion . clone ();
mismatched . duration_seconds = Some ( 99 );
assert! ( repository
. update_if_active ( mismatched )
. await
. expect ( "guarded update should execute" )
. is_none ());
completion . status = VideoTaskStatus ::Completed ;
completion . progress_percent = 100 ;
completion . next_poll_at_unix_secs = None ;
completion . completed_at_unix_secs = Some ( 21 );
completion . updated_at_unix_secs = 21 ;
completion . video_url = Some (
"https://cdn.example.test/video.mp4?alt=media&signature=a%2Fb%2Bc%3D&part=2&part=1"
. to_string (),
);
let completed = repository
. update_if_active ( completion . clone ())
. await
. expect ( "completion should execute" )
. expect ( "matching active task should complete" );
assert_eq! ( completed . video_url , completion . video_url );
let reloaded = repository
. find ( VideoTaskLookupKey ::Id ( & task_id ))
. await
. expect ( "task should reload" )
. expect ( "task should exist" );
assert_eq! ( reloaded . status , VideoTaskStatus ::Completed );
assert_eq! ( reloaded . prompt , original . prompt );
assert_eq! ( reloaded . video_url , completion . video_url );
assert_eq! ( reloaded . duration_seconds , original . duration_seconds );
assert_eq! ( reloaded . size , original . size );
assert_eq! ( reloaded . username , original . username );
assert! ( reloaded . request_metadata . is_none ());
}
repository . pool (). close (). await ;
}
2026-03-31 19:19:04 +08:00
#[tokio::test]
async fn repository_constructs_from_lazy_pool () {
let repository = SqlxVideoTaskRepository ::new ( build_pool ());
2026-03-24 15:12:56 +08:00
let _ = repository . pool ();
}
#[tokio::test]
async fn read_trait_delegates_to_sqlx_repository () {
2026-03-31 19:19:04 +08:00
let repository = SqlxVideoTaskRepository ::new ( build_pool ());
2026-03-24 15:12:56 +08:00
let _ = VideoTaskReadRepository ::find ( & repository , VideoTaskLookupKey ::Id ( "task-1" )). await ;
}
2026-03-31 19:19:04 +08:00
#[tokio::test]
async fn read_trait_delegates_due_listing_to_sqlx_repository () {
let repository = SqlxVideoTaskRepository ::new ( build_pool ());
let _ = VideoTaskReadRepository ::list_due ( & repository , 1 , 10 ). await ;
}
#[tokio::test]
async fn read_trait_delegates_query_methods_to_sqlx_repository () {
let repository = SqlxVideoTaskRepository ::new ( build_pool ());
let filter = VideoTaskQueryFilter ::default ();
let _ = VideoTaskReadRepository ::list_page ( & repository , & filter , 0 , 10 ). await ;
let _ = VideoTaskReadRepository ::count ( & repository , & filter ). await ;
let _ = VideoTaskReadRepository ::count_by_status ( & repository , & filter ). await ;
let _ = VideoTaskReadRepository ::top_models ( & repository , & filter , 10 ). await ;
let _ = VideoTaskReadRepository ::count_created_since ( & repository , & filter , 0 ). await ;
}
#[tokio::test]
async fn write_trait_delegates_to_sqlx_repository () {
let repository = SqlxVideoTaskRepository ::new ( build_pool ());
let _ = VideoTaskWriteRepository ::upsert (
& repository ,
UpsertVideoTask {
id : "task-1" . to_string (),
short_id : Some ( "short-task-1" . to_string ()),
request_id : "request-1" . to_string (),
user_id : None ,
api_key_id : None ,
username : None ,
api_key_name : None ,
external_task_id : Some ( "ext-1" . to_string ()),
provider_id : Some ( "provider-1" . to_string ()),
endpoint_id : Some ( "endpoint-1" . to_string ()),
key_id : Some ( "key-1" . to_string ()),
client_api_format : Some ( "openai:video" . to_string ()),
provider_api_format : Some ( "openai:video" . to_string ()),
format_converted : false ,
model : Some ( "sora-2" . to_string ()),
prompt : Some ( "hello" . to_string ()),
original_request_body : Some ( serde_json ::json! ({ "prompt" : "hello" })),
duration_seconds : Some ( 4 ),
resolution : Some ( "720p" . to_string ()),
aspect_ratio : Some ( "16:9" . to_string ()),
size : Some ( "1280x720" . to_string ()),
status : VideoTaskStatus ::Submitted ,
progress_percent : 0 ,
progress_message : None ,
retry_count : 0 ,
poll_interval_seconds : 10 ,
next_poll_at_unix_secs : Some ( 10 ),
poll_count : 0 ,
max_poll_count : 360 ,
2026-04-10 01:46:14 +08:00
created_at_unix_ms : 1 ,
2026-03-31 19:19:04 +08:00
submitted_at_unix_secs : Some ( 1 ),
completed_at_unix_secs : None ,
updated_at_unix_secs : 1 ,
error_code : None ,
error_message : None ,
video_url : None ,
request_metadata : None ,
},
)
. await ;
}
#[tokio::test]
async fn write_trait_delegates_active_update_to_sqlx_repository () {
let repository = SqlxVideoTaskRepository ::new ( build_pool ());
let _ = VideoTaskWriteRepository ::update_if_active (
& repository ,
UpsertVideoTask {
id : "task-1" . to_string (),
short_id : Some ( "short-task-1" . to_string ()),
request_id : "request-1" . to_string (),
user_id : None ,
api_key_id : None ,
username : None ,
api_key_name : None ,
external_task_id : Some ( "ext-1" . to_string ()),
provider_id : Some ( "provider-1" . to_string ()),
endpoint_id : Some ( "endpoint-1" . to_string ()),
key_id : Some ( "key-1" . to_string ()),
client_api_format : Some ( "openai:video" . to_string ()),
provider_api_format : Some ( "openai:video" . to_string ()),
format_converted : false ,
model : Some ( "sora-2" . to_string ()),
prompt : Some ( "hello" . to_string ()),
original_request_body : Some ( serde_json ::json! ({ "prompt" : "hello" })),
duration_seconds : Some ( 4 ),
resolution : Some ( "720p" . to_string ()),
aspect_ratio : Some ( "16:9" . to_string ()),
size : Some ( "1280x720" . to_string ()),
status : VideoTaskStatus ::Processing ,
progress_percent : 50 ,
progress_message : Some ( "polling" . to_string ()),
retry_count : 1 ,
poll_interval_seconds : 10 ,
next_poll_at_unix_secs : Some ( 20 ),
poll_count : 1 ,
max_poll_count : 360 ,
2026-04-10 01:46:14 +08:00
created_at_unix_ms : 1 ,
2026-03-31 19:19:04 +08:00
submitted_at_unix_secs : Some ( 1 ),
completed_at_unix_secs : None ,
updated_at_unix_secs : 20 ,
error_code : None ,
error_message : None ,
video_url : None ,
request_metadata : None ,
},
)
. await ;
}
#[tokio::test]
async fn write_trait_delegates_due_claim_to_sqlx_repository () {
let repository = SqlxVideoTaskRepository ::new ( build_pool ());
let _ = VideoTaskWriteRepository ::claim_due ( & repository , 1 , 30 , 10 ). await ;
}
2026-03-24 15:12:56 +08:00
}