use chrono::{DateTime, Utc};
use serde_json::Value;
use sqlx::{Row, postgres::PgPool};
use uuid::Uuid;
use crate::{Result, ServerError};
#[derive(Debug, Clone)]
pub struct AsyncOperation {
pub op_id: Uuid,
pub tenant_key: Option<String>,
pub submitter: String,
pub operation: String,
pub document: String,
pub variables: Option<Value>,
pub security_context: Value,
pub locale: Option<String>,
pub session_headers: fraiseql_core::runtime::SessionHeaders,
pub state: String,
pub cancellation_requested: bool,
pub attempts: i32,
pub result: Option<Value>,
pub error: Option<String>,
pub created_at: DateTime<Utc>,
pub started_at: Option<DateTime<Utc>>,
pub finished_at: Option<DateTime<Utc>>,
}
fn row_to_op(row: &sqlx::postgres::PgRow) -> AsyncOperation {
AsyncOperation {
op_id: row.get("op_id"),
tenant_key: row.get("tenant_key"),
submitter: row.get("submitter"),
operation: row.get("operation"),
document: row.get("document"),
variables: row.get("variables"),
security_context: row.get("security_context"),
locale: row.get("locale"),
session_headers: row
.get::<sqlx::types::Json<fraiseql_core::runtime::SessionHeaders>, _>("session_headers")
.0,
state: row.get("state"),
cancellation_requested: row.get("cancellation_requested"),
attempts: row.get("attempts"),
result: row.get("result"),
error: row.get("error"),
created_at: row.get("created_at"),
started_at: row.get("started_at"),
finished_at: row.get("finished_at"),
}
}
fn db_err(what: &str, e: &sqlx::Error) -> ServerError {
ServerError::Database(format!("async-operations: failed to {what}: {e}"))
}
#[derive(Debug)]
pub struct ClaimedOperation {
pub op: AsyncOperation,
pub claim_token: Uuid,
}
#[derive(Debug, Clone)]
pub struct AsyncOperationStore {
db: PgPool,
}
pub const ASYNC_OPERATIONS_SCHEMA_SQL: &str = r"
CREATE SCHEMA IF NOT EXISTS _system;
CREATE TABLE IF NOT EXISTS _system.async_operations (
op_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_key TEXT,
submitter TEXT NOT NULL,
operation TEXT NOT NULL,
document TEXT NOT NULL,
variables JSONB,
security_context JSONB NOT NULL,
state TEXT NOT NULL DEFAULT 'queued'
CHECK (state IN ('queued','running','succeeded','failed','cancelled')),
cancellation_requested BOOLEAN NOT NULL DEFAULT false,
claim_token UUID,
attempts INTEGER NOT NULL DEFAULT 0,
max_attempts INTEGER NOT NULL,
result JSONB,
error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
heartbeat_at TIMESTAMPTZ NOT NULL DEFAULT now(),
started_at TIMESTAMPTZ,
finished_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_async_operations_claim
ON _system.async_operations (state, heartbeat_at);
-- #1512: the request locale, resolved at submission. Added in place so a table
-- created by an earlier release gains it.
ALTER TABLE _system.async_operations ADD COLUMN IF NOT EXISTS locale TEXT;
-- #1520: the headers session variables name, read at submission.
ALTER TABLE _system.async_operations
ADD COLUMN IF NOT EXISTS session_headers JSONB NOT NULL DEFAULT '{}'::jsonb;
";
impl AsyncOperationStore {
#[must_use]
pub const fn new(db: PgPool) -> Self {
Self { db }
}
pub async fn init(&self) -> Result<()> {
crate::migration_lock::run_migration(&self.db, ASYNC_OPERATIONS_SCHEMA_SQL)
.await
.map_err(|e| db_err("initialize _system.async_operations", &e))?;
Ok(())
}
#[allow(clippy::too_many_arguments)] pub async fn submit(
&self,
tenant_key: Option<&str>,
submitter: &str,
operation: &str,
document: &str,
variables: Option<&Value>,
security_context: &Value,
scope: &crate::request_scope::RequestScope,
max_attempts: u32,
) -> Result<Uuid> {
let row = sqlx::query(
"INSERT INTO _system.async_operations
(tenant_key, submitter, operation, document, variables, security_context,
locale, session_headers, max_attempts)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING op_id",
)
.bind(tenant_key)
.bind(submitter)
.bind(operation)
.bind(document)
.bind(variables)
.bind(security_context)
.bind(scope.locale.as_deref())
.bind(sqlx::types::Json(&scope.session_headers))
.bind(i32::try_from(max_attempts).unwrap_or(1))
.fetch_one(&self.db)
.await
.map_err(|e| db_err("submit operation", &e))?;
Ok(row.get("op_id"))
}
pub async fn get_scoped(
&self,
op_id: Uuid,
submitter: &str,
tenant_key: Option<&str>,
) -> Result<Option<AsyncOperation>> {
let row = sqlx::query(
"SELECT * FROM _system.async_operations
WHERE op_id = $1 AND submitter = $2 AND tenant_key IS NOT DISTINCT FROM $3",
)
.bind(op_id)
.bind(submitter)
.bind(tenant_key)
.fetch_optional(&self.db)
.await
.map_err(|e| db_err("get operation", &e))?;
Ok(row.as_ref().map(row_to_op))
}
#[allow(clippy::cast_precision_loss)] pub async fn claim(&self, limit: i64, stale_secs: u64) -> Result<Vec<ClaimedOperation>> {
let claim_token = Uuid::new_v4();
let rows = sqlx::query(
"UPDATE _system.async_operations o
SET state = 'running', claim_token = $1, attempts = o.attempts + 1,
started_at = now(), heartbeat_at = now()
WHERE o.op_id IN (
SELECT op_id FROM _system.async_operations
WHERE (state = 'queued' AND NOT cancellation_requested)
OR (state = 'running' AND heartbeat_at < now() - make_interval(secs => $2))
ORDER BY created_at
LIMIT $3
FOR UPDATE SKIP LOCKED
)
RETURNING *",
)
.bind(claim_token)
.bind(stale_secs as f64)
.bind(limit)
.fetch_all(&self.db)
.await
.map_err(|e| db_err("claim operations", &e))?;
Ok(rows
.iter()
.map(|r| ClaimedOperation {
op: row_to_op(r),
claim_token,
})
.collect())
}
pub async fn heartbeat(&self, op_id: Uuid, claim_token: Uuid) -> Result<bool> {
let result = sqlx::query(
"UPDATE _system.async_operations
SET heartbeat_at = now()
WHERE op_id = $1 AND claim_token = $2 AND state = 'running'",
)
.bind(op_id)
.bind(claim_token)
.execute(&self.db)
.await
.map_err(|e| db_err("heartbeat", &e))?;
Ok(result.rows_affected() == 1)
}
pub async fn complete(&self, op_id: Uuid, claim_token: Uuid, result: &Value) -> Result<bool> {
let done = sqlx::query(
"UPDATE _system.async_operations
SET state = 'succeeded', result = $3, finished_at = now(), claim_token = NULL
WHERE op_id = $1 AND claim_token = $2 AND state = 'running'",
)
.bind(op_id)
.bind(claim_token)
.bind(result)
.execute(&self.db)
.await
.map_err(|e| db_err("complete operation", &e))?;
Ok(done.rows_affected() == 1)
}
pub async fn fail(
&self,
op_id: Uuid,
claim_token: Uuid,
error: &str,
partial_result: Option<&Value>,
) -> Result<bool> {
let done = sqlx::query(
"UPDATE _system.async_operations
SET state = CASE WHEN attempts >= max_attempts THEN 'failed' ELSE 'queued' END,
error = $3,
result = $4,
finished_at = CASE WHEN attempts >= max_attempts THEN now() ELSE NULL END,
claim_token = NULL,
heartbeat_at = now()
WHERE op_id = $1 AND claim_token = $2 AND state = 'running'",
)
.bind(op_id)
.bind(claim_token)
.bind(error)
.bind(partial_result)
.execute(&self.db)
.await
.map_err(|e| db_err("fail operation", &e))?;
Ok(done.rows_affected() == 1)
}
pub async fn cancel_queued(
&self,
op_id: Uuid,
submitter: &str,
tenant_key: Option<&str>,
) -> Result<bool> {
let done = sqlx::query(
"UPDATE _system.async_operations
SET state = 'cancelled', finished_at = now()
WHERE op_id = $1 AND submitter = $2 AND tenant_key IS NOT DISTINCT FROM $3
AND state = 'queued'",
)
.bind(op_id)
.bind(submitter)
.bind(tenant_key)
.execute(&self.db)
.await
.map_err(|e| db_err("cancel queued operation", &e))?;
Ok(done.rows_affected() == 1)
}
pub async fn request_cancel(
&self,
op_id: Uuid,
submitter: &str,
tenant_key: Option<&str>,
) -> Result<bool> {
let done = sqlx::query(
"UPDATE _system.async_operations
SET cancellation_requested = true
WHERE op_id = $1 AND submitter = $2 AND tenant_key IS NOT DISTINCT FROM $3
AND state = 'running'",
)
.bind(op_id)
.bind(submitter)
.bind(tenant_key)
.execute(&self.db)
.await
.map_err(|e| db_err("request cancellation", &e))?;
Ok(done.rows_affected() == 1)
}
pub async fn cancel_unstarted(&self, op_id: Uuid, claim_token: Uuid) -> Result<bool> {
let done = sqlx::query(
"UPDATE _system.async_operations
SET state = 'cancelled', finished_at = now(), claim_token = NULL
WHERE op_id = $1 AND claim_token = $2 AND state = 'running'",
)
.bind(op_id)
.bind(claim_token)
.execute(&self.db)
.await
.map_err(|e| db_err("cancel unstarted operation", &e))?;
Ok(done.rows_affected() == 1)
}
#[allow(clippy::cast_precision_loss)] pub async fn sweep_finished(&self, ttl_secs: u64) -> Result<u64> {
let done = sqlx::query(
"DELETE FROM _system.async_operations
WHERE state IN ('succeeded','failed','cancelled')
AND finished_at < now() - make_interval(secs => $1)",
)
.bind(ttl_secs as f64)
.execute(&self.db)
.await
.map_err(|e| db_err("sweep finished operations", &e))?;
Ok(done.rows_affected())
}
}
#[cfg(test)]
mod tests;