use runledger_core::jobs::JobTypeName;
use crate::{DbTx, Error, QueryError, QueryErrorCategory, Result};
use super::row_decode::parse_job_type_name;
use super::types::JobScheduleJobTypeReference;
const SCHEDULE_DEFINITION_GUARD_LOCK_TIMEOUT: &str = "5s";
const SCHEDULE_DEFINITION_GUARD_LOCK_TIMEOUT_MS: i64 = 5_000;
const DEFINITION_DISABLE_STATEMENT_TIMEOUT: &str = "30s";
const DEFINITION_DISABLE_STATEMENT_TIMEOUT_MS: i64 = 30_000;
#[derive(Clone, Copy)]
pub(in crate::jobs) enum GuardLockContext {
ActiveScheduleWrite,
DefinitionDisable,
}
pub(in crate::jobs) enum ScheduleDefinitionLockError {
Schedule(Error),
Definition(Error),
}
impl ScheduleDefinitionLockError {
pub(in crate::jobs) fn into_error(self) -> Error {
match self {
Self::Schedule(error) | Self::Definition(error) => error,
}
}
}
impl GuardLockContext {
fn set_schedule_lock_timeout_context(self) -> &'static str {
match self {
Self::ActiveScheduleWrite => "set active schedule guard schedule lock timeout",
Self::DefinitionDisable => "set job definition disable schedule lock timeout",
}
}
fn restore_schedule_lock_timeout_context(self) -> &'static str {
match self {
Self::ActiveScheduleWrite => "restore active schedule guard schedule lock timeout",
Self::DefinitionDisable => "restore job definition disable schedule lock timeout",
}
}
fn schedule_lock_context(self) -> &'static str {
match self {
Self::ActiveScheduleWrite => {
"lock job schedules before active schedule definition check"
}
Self::DefinitionDisable => "lock job schedules before disabling job definitions",
}
}
fn set_definition_lock_timeout_context(self) -> &'static str {
match self {
Self::ActiveScheduleWrite => "set active schedule guard definition lock timeout",
Self::DefinitionDisable => "set job definition disable definition lock timeout",
}
}
fn restore_definition_lock_timeout_context(self) -> &'static str {
match self {
Self::ActiveScheduleWrite => "restore active schedule guard definition lock timeout",
Self::DefinitionDisable => "restore job definition disable definition lock timeout",
}
}
fn definition_lock_context(self) -> &'static str {
match self {
Self::ActiveScheduleWrite => {
"lock job definitions before active schedule definition check"
}
Self::DefinitionDisable => "lock job definitions before disabling job definitions",
}
}
}
pub(in crate::jobs) async fn cap_definition_disable_statement_timeout_tx(
tx: &mut DbTx<'_>,
) -> Result<()> {
cap_local_statement_timeout_tx(
tx,
DEFINITION_DISABLE_STATEMENT_TIMEOUT,
DEFINITION_DISABLE_STATEMENT_TIMEOUT_MS,
"set job definition disable statement timeout",
)
.await?;
Ok(())
}
pub(in crate::jobs) async fn lock_schedules_then_definitions_tx(
tx: &mut DbTx<'_>,
context: GuardLockContext,
) -> std::result::Result<(), ScheduleDefinitionLockError> {
lock_job_schedules_for_guard_tx(tx, context)
.await
.map_err(ScheduleDefinitionLockError::Schedule)?;
lock_job_definitions_for_guard_tx(tx, context)
.await
.map_err(ScheduleDefinitionLockError::Definition)
}
pub(in crate::jobs) async fn lock_job_schedules_for_guard_tx(
tx: &mut DbTx<'_>,
context: GuardLockContext,
) -> Result<()> {
let previous_lock_timeout = cap_local_lock_timeout_tx(
tx,
SCHEDULE_DEFINITION_GUARD_LOCK_TIMEOUT,
SCHEDULE_DEFINITION_GUARD_LOCK_TIMEOUT_MS,
context.set_schedule_lock_timeout_context(),
)
.await?;
let lock_result = sqlx::query("LOCK TABLE job_schedules IN SHARE ROW EXCLUSIVE MODE")
.execute(&mut **tx)
.await;
match lock_result {
Ok(_) => {
set_local_lock_timeout_tx(
tx,
&previous_lock_timeout,
context.restore_schedule_lock_timeout_context(),
)
.await
}
Err(error) => Err(Error::from_query_sqlx_with_context(
context.schedule_lock_context(),
error,
)),
}
}
pub(in crate::jobs) async fn lock_job_definitions_for_guard_tx(
tx: &mut DbTx<'_>,
context: GuardLockContext,
) -> Result<()> {
let previous_lock_timeout = cap_local_lock_timeout_tx(
tx,
SCHEDULE_DEFINITION_GUARD_LOCK_TIMEOUT,
SCHEDULE_DEFINITION_GUARD_LOCK_TIMEOUT_MS,
context.set_definition_lock_timeout_context(),
)
.await?;
let lock_result = sqlx::query("LOCK TABLE job_definitions IN SHARE ROW EXCLUSIVE MODE")
.execute(&mut **tx)
.await;
match lock_result {
Ok(_) => {
set_local_lock_timeout_tx(
tx,
&previous_lock_timeout,
context.restore_definition_lock_timeout_context(),
)
.await
}
Err(error) => Err(Error::from_query_sqlx_with_context(
context.definition_lock_context(),
error,
)),
}
}
pub(in crate::jobs) async fn reject_unavailable_definition_for_active_schedule_tx(
tx: &mut DbTx<'_>,
job_type: &str,
) -> Result<()> {
if enabled_job_definition_exists_tx(tx, job_type).await? {
return Ok(());
}
Err(active_schedule_definition_unavailable_error(job_type))
}
async fn enabled_job_definition_exists_tx(tx: &mut DbTx<'_>, job_type: &str) -> Result<bool> {
let is_enabled = sqlx::query_scalar::<_, bool>(
"SELECT is_enabled
FROM job_definitions
WHERE job_type = $1",
)
.bind(job_type)
.fetch_optional(&mut **tx)
.await
.map_err(|error| {
Error::from_query_sqlx_with_context(
"check job definition before active schedule write",
error,
)
})?;
Ok(is_enabled == Some(true))
}
pub(in crate::jobs) async fn find_active_schedule_for_job_type_tx(
tx: &mut DbTx<'_>,
job_type: &str,
) -> Result<Option<JobScheduleJobTypeReference>> {
let row = sqlx::query_as::<_, (String, String)>(
"SELECT name, job_type
FROM job_schedules
WHERE is_active = true
AND job_type = $1
ORDER BY name ASC
LIMIT 1",
)
.bind(job_type)
.fetch_optional(&mut **tx)
.await
.map_err(|error| {
Error::from_query_sqlx_with_context("find active schedule for job definition", error)
})?;
row.map(|(name, job_type)| parse_schedule_job_type_reference(name, job_type))
.transpose()
}
pub(in crate::jobs) async fn find_active_schedule_for_job_types_tx(
tx: &mut DbTx<'_>,
job_types: &[JobTypeName],
) -> Result<Option<JobScheduleJobTypeReference>> {
if job_types.is_empty() {
return Ok(None);
}
let job_types = job_type_strings(job_types);
let row = sqlx::query_as::<_, (String, String)>(
"SELECT name, job_type
FROM job_schedules
WHERE is_active = true
AND job_type = ANY($1::text[])
ORDER BY name ASC
LIMIT 1",
)
.bind(job_types.as_slice())
.fetch_optional(&mut **tx)
.await
.map_err(|error| {
Error::from_query_sqlx_with_context("find active schedule for job definitions", error)
})?;
row.map(|(name, job_type)| parse_schedule_job_type_reference(name, job_type))
.transpose()
}
pub(in crate::jobs) async fn find_active_schedule_for_enabled_absent_job_types_tx(
tx: &mut DbTx<'_>,
catalog_job_types: &[JobTypeName],
scope_job_types: &[JobTypeName],
) -> Result<Option<JobScheduleJobTypeReference>> {
if scope_job_types.is_empty() {
return Ok(None);
}
let catalog_job_types = job_type_strings(catalog_job_types);
let scope_job_types = job_type_strings(scope_job_types);
let row = sqlx::query_as::<_, (String, String)>(
"SELECT job_schedules.name, job_schedules.job_type
FROM job_schedules
INNER JOIN job_definitions
ON job_definitions.job_type = job_schedules.job_type
WHERE job_schedules.is_active = true
AND job_schedules.job_type <> ALL($1::text[])
AND job_schedules.job_type = ANY($2::text[])
AND job_definitions.is_enabled = true
ORDER BY job_schedules.name ASC
LIMIT 1",
)
.bind(catalog_job_types.as_slice())
.bind(scope_job_types.as_slice())
.fetch_optional(&mut **tx)
.await
.map_err(|error| {
Error::from_query_sqlx_with_context(
"find active schedule for enabled absent job definitions",
error,
)
})?;
row.map(|(name, job_type)| parse_schedule_job_type_reference(name, job_type))
.transpose()
}
fn active_schedule_definition_unavailable_error(job_type: &str) -> Error {
Error::QueryError(QueryError::from_classified(
QueryErrorCategory::Validation,
"job_schedule.definition_not_found_or_disabled",
"Active job schedules require an enabled job definition.",
format!("active job schedule references missing or disabled job definition: {job_type}"),
))
}
pub(in crate::jobs) fn active_schedule_for_disabled_definition_error(
reference: &JobScheduleJobTypeReference,
) -> Error {
Error::QueryError(QueryError::from_classified(
QueryErrorCategory::Validation,
"job_definition.active_schedule_exists",
"Job definition cannot be disabled while active schedules reference it.",
format!(
"active schedule {} still references job type {}",
reference.schedule_name, reference.job_type
),
))
}
async fn cap_local_lock_timeout_tx(
tx: &mut DbTx<'_>,
lock_timeout: &str,
lock_timeout_ms: i64,
context: &'static str,
) -> Result<String> {
sqlx::query_scalar::<_, String>(
"WITH previous AS MATERIALIZED (
SELECT
current_setting('lock_timeout') AS lock_timeout,
setting::bigint AS lock_timeout_ms
FROM pg_settings
WHERE name = 'lock_timeout'
)
SELECT previous.lock_timeout
FROM previous,
LATERAL (
SELECT set_config(
'lock_timeout',
CASE
WHEN previous.lock_timeout_ms = 0 THEN $1
WHEN previous.lock_timeout_ms <= $2 THEN previous.lock_timeout
ELSE $1
END,
true
)
) AS applied",
)
.bind(lock_timeout)
.bind(lock_timeout_ms)
.fetch_one(&mut **tx)
.await
.map_err(|error| Error::from_query_sqlx_with_context(context, error))
}
async fn cap_local_statement_timeout_tx(
tx: &mut DbTx<'_>,
statement_timeout: &str,
statement_timeout_ms: i64,
context: &'static str,
) -> Result<String> {
sqlx::query_scalar::<_, String>(
"WITH previous AS MATERIALIZED (
SELECT
current_setting('statement_timeout') AS statement_timeout,
setting::bigint AS statement_timeout_ms
FROM pg_settings
WHERE name = 'statement_timeout'
)
SELECT previous.statement_timeout
FROM previous,
LATERAL (
SELECT set_config(
'statement_timeout',
CASE
WHEN previous.statement_timeout_ms = 0 THEN $1
WHEN previous.statement_timeout_ms <= $2 THEN previous.statement_timeout
ELSE $1
END,
true
)
) AS applied",
)
.bind(statement_timeout)
.bind(statement_timeout_ms)
.fetch_one(&mut **tx)
.await
.map_err(|error| Error::from_query_sqlx_with_context(context, error))
}
async fn set_local_lock_timeout_tx(
tx: &mut DbTx<'_>,
lock_timeout: &str,
context: &'static str,
) -> Result<()> {
sqlx::query_scalar::<_, String>("SELECT set_config('lock_timeout', $1, true)")
.bind(lock_timeout)
.fetch_one(&mut **tx)
.await
.map_err(|error| Error::from_query_sqlx_with_context(context, error))?;
Ok(())
}
fn job_type_strings(job_types: &[JobTypeName]) -> Vec<String> {
job_types
.iter()
.map(|job_type| job_type.as_str().to_owned())
.collect()
}
fn parse_schedule_job_type_reference(
schedule_name: String,
job_type: String,
) -> Result<JobScheduleJobTypeReference> {
Ok(JobScheduleJobTypeReference {
schedule_name,
job_type: parse_job_type_name(job_type)?,
})
}