#![cfg_attr(not(test), deny(clippy::disallowed_methods))]
#![cfg_attr(
not(test),
deny(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::unreachable,
clippy::todo,
clippy::unimplemented,
clippy::indexing_slicing,
clippy::string_slice,
clippy::arithmetic_side_effects,
)
)]
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use std::time::Duration;
use sha2::{Digest as _, Sha256};
use crate::config::{SchedulerBackend, SchedulerConfig};
use crate::state::AppState;
use crate::task::TaskCoordination;
use crate::{AutumnError, AutumnResult};
pub type SchedulerFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
pub trait SchedulerCoordinator: Send + Sync {
fn backend(&self) -> &'static str;
fn replica_id(&self) -> &str;
fn is_fleet_distributed(&self) -> bool {
self.backend() == "postgres"
}
fn try_acquire<'a>(
&'a self,
task_name: &'a str,
tick_key: &'a str,
coordination: TaskCoordination,
) -> SchedulerFuture<'a, AutumnResult<Option<SchedulerLease>>>;
fn try_acquire_for_period<'a>(
&'a self,
task_name: &'a str,
tick_key: &'a str,
coordination: TaskCoordination,
period: Duration,
) -> SchedulerFuture<'a, AutumnResult<Option<SchedulerLease>>> {
let _ = period;
self.try_acquire(task_name, tick_key, coordination)
}
}
pub struct SchedulerLease {
backend: String,
leader_id: String,
fencing_token: Option<i64>,
#[cfg(test)]
release_count: Option<Arc<std::sync::atomic::AtomicUsize>>,
#[cfg(feature = "postgres")]
postgres: Option<PostgresTickRow>,
#[cfg(feature = "sqlite")]
sqlite: Option<SqliteTableLease>,
}
impl SchedulerLease {
pub(crate) fn local(backend: impl Into<String>, leader_id: impl Into<String>) -> Self {
Self {
backend: backend.into(),
leader_id: leader_id.into(),
fencing_token: None,
#[cfg(test)]
release_count: None,
#[cfg(feature = "postgres")]
postgres: None,
#[cfg(feature = "sqlite")]
sqlite: None,
}
}
#[cfg(test)]
pub(crate) fn tracked(
backend: impl Into<String>,
leader_id: impl Into<String>,
release_count: Arc<std::sync::atomic::AtomicUsize>,
) -> Self {
Self {
release_count: Some(release_count),
..Self::local(backend, leader_id)
}
}
#[cfg(test)]
pub(crate) const fn with_fencing_token(mut self, token: i64) -> Self {
self.fencing_token = Some(token);
self
}
#[cfg(feature = "postgres")]
fn postgres(leader_id: impl Into<String>, row: PostgresTickRow) -> Self {
Self {
fencing_token: Some(row.generation),
postgres: Some(row),
..Self::local("postgres", leader_id)
}
}
#[cfg(feature = "sqlite")]
fn sqlite(leader_id: impl Into<String>, lease: SqliteTableLease) -> Self {
Self {
sqlite: Some(lease),
..Self::local("sqlite", leader_id)
}
}
#[must_use]
pub fn backend(&self) -> &str {
&self.backend
}
#[must_use]
pub fn leader_id(&self) -> &str {
&self.leader_id
}
#[must_use]
pub const fn fencing_token(&self) -> Option<i64> {
self.fencing_token
}
#[allow(
clippy::unused_async,
reason = "public async API; only the sqlite lease awaits in its body"
)]
pub async fn release(self) -> AutumnResult<()> {
#[cfg(test)]
if let Some(release_count) = self.release_count {
release_count.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
}
#[cfg(feature = "sqlite")]
if let Some(lease) = self.sqlite {
return lease.release().await;
}
Ok(())
}
pub async fn release_and_free(self) -> AutumnResult<()> {
self.free().await?;
self.release().await
}
#[allow(
clippy::unused_async,
reason = "only the postgres and sqlite rows await in the body"
)]
pub(crate) async fn free(&self) -> AutumnResult<()> {
#[cfg(feature = "postgres")]
if let Some(row) = self.postgres.as_ref() {
row.free().await?;
}
#[cfg(feature = "sqlite")]
if let Some(lease) = self.sqlite.as_ref() {
lease.free().await?;
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ScheduledTick {
tick_key: String,
fencing_token: Option<i64>,
}
impl ScheduledTick {
pub(crate) fn new(tick_key: impl Into<String>, lease: &SchedulerLease) -> Self {
Self {
tick_key: tick_key.into(),
fencing_token: lease.fencing_token(),
}
}
#[must_use]
pub fn tick_key(&self) -> &str {
&self.tick_key
}
#[must_use]
pub const fn fencing_token(&self) -> Option<i64> {
self.fencing_token
}
}
tokio::task_local! {
static CURRENT_TICK: ScheduledTick;
}
#[must_use]
pub fn current_tick() -> Option<ScheduledTick> {
CURRENT_TICK.try_with(Clone::clone).ok()
}
pub(crate) async fn with_tick<F: Future>(tick: ScheduledTick, future: F) -> F::Output {
CURRENT_TICK.scope(tick, future).await
}
#[must_use]
pub(crate) fn in_process_fleet_hint(
backend: SchedulerBackend,
jobs_backend: &str,
replicas: Option<&str>,
kubernetes: bool,
) -> Option<&'static str> {
if backend != SchedulerBackend::InProcess {
return None;
}
if matches!(jobs_backend, "postgres" | "redis") {
return Some("jobs.backend is a shared queue");
}
if replicas
.and_then(|value| value.trim().parse::<u64>().ok())
.is_some_and(|count| count > 1)
{
return Some("AUTUMN_REPLICAS is more than 1");
}
if kubernetes {
return Some("KUBERNETES_SERVICE_HOST is set");
}
None
}
#[derive(Debug, Clone)]
pub struct InProcessSchedulerCoordinator {
replica_id: String,
}
impl InProcessSchedulerCoordinator {
#[must_use]
pub fn new(replica_id: impl Into<String>) -> Self {
Self {
replica_id: replica_id.into(),
}
}
}
impl SchedulerCoordinator for InProcessSchedulerCoordinator {
fn backend(&self) -> &'static str {
"in_process"
}
fn replica_id(&self) -> &str {
&self.replica_id
}
fn try_acquire<'a>(
&'a self,
_task_name: &'a str,
_tick_key: &'a str,
coordination: TaskCoordination,
) -> SchedulerFuture<'a, AutumnResult<Option<SchedulerLease>>> {
Box::pin(async move {
let backend = match coordination {
TaskCoordination::Fleet => "in_process",
TaskCoordination::PerReplica => "per_replica",
};
Ok(Some(SchedulerLease::local(
backend,
self.replica_id.clone(),
)))
})
}
}
#[cfg(feature = "postgres")]
#[derive(Clone)]
pub struct PostgresTickSchedulerCoordinator {
pool: diesel_async::pooled_connection::deadpool::Pool<diesel_async::AsyncPgConnection>,
replica_id: String,
key_prefix: String,
retention: Duration,
ready: Arc<tokio::sync::OnceCell<()>>,
}
#[cfg(feature = "postgres")]
#[deprecated(
since = "0.9.0",
note = "renamed to PostgresTickSchedulerCoordinator: it uses a tick table, not \
advisory locks (#3052)"
)]
pub type PostgresAdvisorySchedulerCoordinator = PostgresTickSchedulerCoordinator;
#[cfg(feature = "postgres")]
const DEFAULT_TICK_RETENTION: Duration = Duration::from_secs(300);
#[cfg(feature = "postgres")]
const MIN_TICK_RETENTION: Duration = Duration::from_secs(1);
#[cfg(feature = "db")]
const MAX_TICK_HOLD: Duration = Duration::from_secs(100 * 365 * 24 * 60 * 60);
#[cfg(feature = "db")]
fn tick_hold(retention: Duration, period: Duration) -> Duration {
retention.saturating_add(period).min(MAX_TICK_HOLD)
}
#[cfg(feature = "postgres")]
struct PostgresTickRow {
pool: diesel_async::pooled_connection::deadpool::Pool<diesel_async::AsyncPgConnection>,
key_prefix: String,
task_name: String,
tick_key: String,
generation: i64,
}
#[cfg(feature = "postgres")]
impl PostgresTickRow {
async fn free(&self) -> AutumnResult<()> {
use diesel_async::RunQueryDsl as _;
let mut conn = self.pool.get().await.map_err(|error| {
AutumnError::service_unavailable_msg(format!(
"scheduler postgres connection unavailable: {error}"
))
})?;
diesel::sql_query(
"DELETE FROM autumn_scheduler_ticks \
WHERE key_prefix = $1 AND task_name = $2 AND tick_key = $3 AND generation = $4",
)
.bind::<diesel::sql_types::Text, _>(&self.key_prefix)
.bind::<diesel::sql_types::Text, _>(&self.task_name)
.bind::<diesel::sql_types::Text, _>(&self.tick_key)
.bind::<diesel::sql_types::BigInt, _>(self.generation)
.execute(&mut *conn)
.await
.map_err(|error| {
AutumnError::internal_server_error_msg(format!(
"postgres scheduler tick free failed: {error}"
))
})?;
Ok(())
}
}
#[cfg(feature = "postgres")]
pub const PG_TICK_TABLE_DDL: &str = "\
CREATE TABLE IF NOT EXISTS autumn_scheduler_ticks (
key_prefix TEXT NOT NULL,
task_name TEXT NOT NULL,
tick_key TEXT NOT NULL,
owner TEXT NOT NULL,
generation BIGSERIAL NOT NULL,
claimed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL,
PRIMARY KEY (key_prefix, task_name, tick_key)
);
CREATE INDEX IF NOT EXISTS idx_autumn_scheduler_ticks_expires_at
ON autumn_scheduler_ticks (expires_at);";
#[cfg(feature = "postgres")]
pub(crate) const PG_TICK_DDL_LOCK_KEY: i64 = 3_052_305_230_523_052;
#[cfg(feature = "postgres")]
impl PostgresTickSchedulerCoordinator {
#[must_use]
pub fn new(
pool: diesel_async::pooled_connection::deadpool::Pool<diesel_async::AsyncPgConnection>,
replica_id: impl Into<String>,
key_prefix: impl Into<String>,
) -> Self {
Self {
pool,
replica_id: replica_id.into(),
key_prefix: key_prefix.into(),
retention: DEFAULT_TICK_RETENTION,
ready: Arc::new(tokio::sync::OnceCell::new()),
}
}
#[must_use]
pub fn with_tick_retention(mut self, retention: Duration) -> Self {
self.retention = retention.clamp(MIN_TICK_RETENTION, MAX_TICK_HOLD);
self
}
async fn ensure_table(&self, conn: &mut diesel_async::AsyncPgConnection) -> AutumnResult<()> {
self.ready
.get_or_try_init(|| create_tick_table(conn))
.await?;
Ok(())
}
}
#[cfg(feature = "postgres")]
#[derive(diesel::QueryableByName)]
struct TableExistsRow {
#[diesel(sql_type = diesel::sql_types::Bool)]
present: bool,
}
#[cfg(feature = "postgres")]
async fn create_tick_table(conn: &mut diesel_async::AsyncPgConnection) -> AutumnResult<()> {
use diesel_async::{RunQueryDsl as _, SimpleAsyncConnection as _};
let setup_error = |error: diesel::result::Error| {
AutumnError::internal_server_error_msg(format!(
"postgres scheduler tick table setup failed: {error}"
))
};
let exists =
diesel::sql_query("SELECT to_regclass('autumn_scheduler_ticks') IS NOT NULL AS present")
.get_result::<TableExistsRow>(&mut *conn)
.await
.map_err(setup_error)?;
if exists.present {
return Ok(());
}
conn.batch_execute(&format!(
"SELECT pg_advisory_xact_lock({PG_TICK_DDL_LOCK_KEY});\n{PG_TICK_TABLE_DDL}"
))
.await
.map_err(setup_error)
}
#[cfg(feature = "postgres")]
#[derive(diesel::QueryableByName)]
struct TickGenerationRow {
#[diesel(sql_type = diesel::sql_types::BigInt)]
generation: i64,
}
#[cfg(feature = "postgres")]
impl SchedulerCoordinator for PostgresTickSchedulerCoordinator {
fn backend(&self) -> &'static str {
"postgres"
}
fn replica_id(&self) -> &str {
&self.replica_id
}
fn try_acquire<'a>(
&'a self,
task_name: &'a str,
tick_key: &'a str,
coordination: TaskCoordination,
) -> SchedulerFuture<'a, AutumnResult<Option<SchedulerLease>>> {
self.try_acquire_for_period(task_name, tick_key, coordination, Duration::ZERO)
}
fn try_acquire_for_period<'a>(
&'a self,
task_name: &'a str,
tick_key: &'a str,
coordination: TaskCoordination,
period: Duration,
) -> SchedulerFuture<'a, AutumnResult<Option<SchedulerLease>>> {
Box::pin(async move {
use diesel_async::RunQueryDsl as _;
if coordination == TaskCoordination::PerReplica {
return Ok(Some(SchedulerLease::local(
"per_replica",
self.replica_id.clone(),
)));
}
let mut conn = self.pool.get().await.map_err(|error| {
AutumnError::service_unavailable_msg(format!(
"scheduler postgres connection unavailable: {error}"
))
})?;
self.ensure_table(&mut conn).await?;
diesel::sql_query("DELETE FROM autumn_scheduler_ticks WHERE expires_at < now()")
.execute(&mut *conn)
.await
.map_err(|error| {
AutumnError::internal_server_error_msg(format!(
"postgres scheduler tick prune failed: {error}"
))
})?;
let claimed = diesel::sql_query(
"INSERT INTO autumn_scheduler_ticks \
(key_prefix, task_name, tick_key, owner, expires_at) \
SELECT $1, $2, $3, $4, now() + make_interval(secs => $5) \
WHERE pg_try_advisory_xact_lock($6) \
ON CONFLICT DO NOTHING \
RETURNING generation",
)
.bind::<diesel::sql_types::Text, _>(&self.key_prefix)
.bind::<diesel::sql_types::Text, _>(task_name)
.bind::<diesel::sql_types::Text, _>(tick_key)
.bind::<diesel::sql_types::Text, _>(&self.replica_id)
.bind::<diesel::sql_types::Double, _>(tick_hold(self.retention, period).as_secs_f64())
.bind::<diesel::sql_types::BigInt, _>(advisory_lock_key(
&self.key_prefix,
task_name,
tick_key,
))
.load::<TickGenerationRow>(&mut *conn)
.await
.map_err(|error| {
AutumnError::internal_server_error_msg(format!(
"postgres scheduler tick claim failed: {error}"
))
})?;
Ok(claimed.into_iter().next().map(|row| {
SchedulerLease::postgres(
self.replica_id.clone(),
PostgresTickRow {
pool: self.pool.clone(),
key_prefix: self.key_prefix.clone(),
task_name: task_name.to_owned(),
tick_key: tick_key.to_owned(),
generation: row.generation,
},
)
}))
})
}
}
#[cfg(feature = "sqlite")]
const MIN_SCHEDULER_LEASE_TTL: Duration = Duration::from_secs(1);
#[cfg(feature = "sqlite")]
#[derive(Clone)]
pub struct SqliteLeaseSchedulerCoordinator {
pool: diesel_async::pooled_connection::deadpool::Pool<crate::db::RuntimeConnection>,
replica_id: String,
key_prefix: String,
lease_ttl: Duration,
clock: Arc<dyn crate::time::ClockSource>,
ready: Arc<tokio::sync::OnceCell<()>>,
}
#[cfg(feature = "sqlite")]
impl SqliteLeaseSchedulerCoordinator {
#[must_use]
pub fn new(
pool: diesel_async::pooled_connection::deadpool::Pool<crate::db::RuntimeConnection>,
replica_id: impl Into<String>,
key_prefix: impl Into<String>,
lease_ttl: Duration,
clock: Arc<dyn crate::time::ClockSource>,
) -> Self {
Self {
pool,
replica_id: replica_id.into(),
key_prefix: key_prefix.into(),
lease_ttl: lease_ttl.max(MIN_SCHEDULER_LEASE_TTL),
clock,
ready: Arc::new(tokio::sync::OnceCell::new()),
}
}
async fn ensure_table(&self, conn: &mut crate::db::RuntimeConnection) -> AutumnResult<()> {
let ready = Arc::clone(&self.ready);
ready.get_or_try_init(|| Self::create_table(conn)).await?;
Ok(())
}
async fn create_table(conn: &mut crate::db::RuntimeConnection) -> AutumnResult<()> {
use diesel_async::RunQueryDsl as _;
for statement in [
"CREATE TABLE IF NOT EXISTS autumn_scheduler_leases ( \
lock_key BIGINT PRIMARY KEY NOT NULL, \
task_name TEXT NOT NULL, \
tick_key TEXT NOT NULL, \
owner TEXT NOT NULL, \
acquired_at BIGINT NOT NULL, \
expires_at BIGINT NOT NULL)",
"CREATE INDEX IF NOT EXISTS idx_autumn_scheduler_leases_expiry \
ON autumn_scheduler_leases (expires_at)",
] {
diesel::sql_query(statement)
.execute(&mut *conn)
.await
.map_err(|error| {
AutumnError::internal_server_error_msg(format!(
"sqlite scheduler lease table setup failed: {error}"
))
})?;
}
Ok(())
}
fn now_ms(&self) -> i64 {
self.clock.now().timestamp_millis()
}
}
#[cfg(feature = "sqlite")]
fn next_lease_owner(replica_id: &str) -> String {
use std::sync::atomic::{AtomicU64, Ordering};
static SEQ: AtomicU64 = AtomicU64::new(0);
let seq = SEQ.fetch_add(1, Ordering::Relaxed);
format!("{replica_id}#{}#{seq}", std::process::id())
}
#[cfg(feature = "sqlite")]
impl SchedulerCoordinator for SqliteLeaseSchedulerCoordinator {
fn backend(&self) -> &'static str {
"sqlite"
}
fn replica_id(&self) -> &str {
&self.replica_id
}
fn is_fleet_distributed(&self) -> bool {
true
}
fn try_acquire<'a>(
&'a self,
task_name: &'a str,
tick_key: &'a str,
coordination: TaskCoordination,
) -> SchedulerFuture<'a, AutumnResult<Option<SchedulerLease>>> {
self.try_acquire_for_period(task_name, tick_key, coordination, Duration::ZERO)
}
fn try_acquire_for_period<'a>(
&'a self,
task_name: &'a str,
tick_key: &'a str,
coordination: TaskCoordination,
period: Duration,
) -> SchedulerFuture<'a, AutumnResult<Option<SchedulerLease>>> {
Box::pin(async move {
use diesel_async::RunQueryDsl as _;
if coordination == TaskCoordination::PerReplica {
return Ok(Some(SchedulerLease::local(
"per_replica",
self.replica_id.clone(),
)));
}
let key = advisory_lock_key(&self.key_prefix, task_name, tick_key);
let mut conn = self.pool.get().await.map_err(|error| {
AutumnError::service_unavailable_msg(format!(
"scheduler sqlite lease connection unavailable: {error}"
))
})?;
self.ensure_table(&mut conn).await?;
let now_ms = self.now_ms();
let hold = tick_hold(self.lease_ttl, period);
let ttl_ms = i64::try_from(hold.as_millis()).unwrap_or(i64::MAX);
let expires_at = now_ms.saturating_add(ttl_ms);
diesel::sql_query("DELETE FROM autumn_scheduler_leases WHERE expires_at <= ?")
.bind::<diesel::sql_types::BigInt, _>(now_ms)
.execute(&mut *conn)
.await
.map_err(|error| {
AutumnError::internal_server_error_msg(format!(
"sqlite scheduler lease reap failed: {error}"
))
})?;
let owner = next_lease_owner(&self.replica_id);
let inserted = diesel::sql_query(
"INSERT INTO autumn_scheduler_leases \
(lock_key, task_name, tick_key, owner, acquired_at, expires_at) \
VALUES (?, ?, ?, ?, ?, ?) \
ON CONFLICT(lock_key) DO NOTHING",
)
.bind::<diesel::sql_types::BigInt, _>(key)
.bind::<diesel::sql_types::Text, _>(task_name)
.bind::<diesel::sql_types::Text, _>(tick_key)
.bind::<diesel::sql_types::Text, _>(&owner)
.bind::<diesel::sql_types::BigInt, _>(now_ms)
.bind::<diesel::sql_types::BigInt, _>(expires_at)
.execute(&mut *conn)
.await
.map_err(|error| {
AutumnError::internal_server_error_msg(format!(
"sqlite scheduler lease acquire failed: {error}"
))
})?;
if inserted == 0 {
return Ok(None);
}
Ok(Some(SchedulerLease::sqlite(
self.replica_id.clone(),
SqliteTableLease {
key,
owner,
pool: self.pool.clone(),
},
)))
})
}
}
#[cfg(feature = "sqlite")]
struct SqliteTableLease {
key: i64,
owner: String,
pool: diesel_async::pooled_connection::deadpool::Pool<crate::db::RuntimeConnection>,
}
#[cfg(feature = "sqlite")]
impl SqliteTableLease {
async fn free(&self) -> AutumnResult<()> {
use diesel_async::RunQueryDsl as _;
let mut conn = self.pool.get().await.map_err(|error| {
AutumnError::service_unavailable_msg(format!(
"scheduler sqlite lease connection unavailable: {error}"
))
})?;
diesel::sql_query("DELETE FROM autumn_scheduler_leases WHERE lock_key = ? AND owner = ?")
.bind::<diesel::sql_types::BigInt, _>(self.key)
.bind::<diesel::sql_types::Text, _>(&self.owner)
.execute(&mut *conn)
.await
.map_err(|error| {
AutumnError::internal_server_error_msg(format!(
"sqlite scheduler lease free failed: {error}"
))
})?;
Ok(())
}
#[allow(
clippy::unused_async,
reason = "`SchedulerLease::release` is async; this keeps one shape across backends"
)]
async fn release(self) -> AutumnResult<()> {
tracing::debug!(
lock_key = self.key,
"sqlite scheduler tick released; the row keeps the tick reserved until it expires"
);
Ok(())
}
}
pub fn coordinator_from_config(
config: &SchedulerConfig,
state: &AppState,
) -> AutumnResult<Arc<dyn SchedulerCoordinator>> {
let replica_id = config.resolved_replica_id();
match config.backend {
SchedulerBackend::InProcess => Ok(Arc::new(InProcessSchedulerCoordinator::new(replica_id))),
SchedulerBackend::Sqlite => {
#[cfg(feature = "sqlite")]
{
let pool = state.pool().cloned().ok_or_else(|| {
AutumnError::service_unavailable_msg(
"scheduler.backend = \"sqlite\" requires a configured database pool",
)
})?;
if state
.extension::<crate::config::AutumnConfig>()
.and_then(|config| {
config
.database
.effective_primary_url()
.map(crate::config::is_in_memory_sqlite_target)
})
.unwrap_or(false)
{
return Err(AutumnError::service_unavailable_msg(
"scheduler.backend = \"sqlite\" requires a FILE-backed database: an \
in-memory SQLite target is private to each process, so every replica \
would claim the same tick and run it. Point database.url at a \
sqlite:// file, or use scheduler.backend = \"in_process\"",
));
}
Ok(Arc::new(SqliteLeaseSchedulerCoordinator::new(
pool,
replica_id,
config.key_prefix.clone(),
Duration::from_secs(config.lease_ttl_secs),
state.clock_arc(),
)))
}
#[cfg(not(feature = "sqlite"))]
{
let _ = (state, replica_id);
Err(AutumnError::service_unavailable_msg(
"scheduler.backend = \"sqlite\" requires a build of autumn-web compiled \
with --features sqlite; on Postgres use scheduler.backend = \"postgres\"",
))
}
}
SchedulerBackend::Postgres => {
#[cfg(all(feature = "db", not(feature = "sqlite")))]
{
let pool = state.pool().cloned().ok_or_else(|| {
AutumnError::service_unavailable_msg(
"scheduler.backend = \"postgres\" requires a configured database pool",
)
})?;
Ok(Arc::new(
PostgresTickSchedulerCoordinator::new(
pool,
replica_id,
config.key_prefix.clone(),
)
.with_tick_retention(Duration::from_secs(config.lease_ttl_secs)),
))
}
#[cfg(all(feature = "db", feature = "sqlite"))]
{
let _ = (state, replica_id);
Err(AutumnError::service_unavailable_msg(
"scheduler.backend = \"postgres\" requires the Postgres backend and is \
unsupported under the sqlite feature; use scheduler.backend = \"in_process\" \
(the default) or scheduler.backend = \"sqlite\" to coordinate several \
processes on the host",
))
}
#[cfg(not(feature = "db"))]
{
let _ = state;
Err(AutumnError::service_unavailable_msg(
"scheduler.backend = \"postgres\" requires the autumn-web db feature",
))
}
}
}
}
#[must_use]
pub fn fixed_delay_tick_key(task_name: &str, delay: Duration, unix_elapsed: Duration) -> String {
let interval = delay.as_nanos().max(1);
let bucket = unix_elapsed.as_nanos().checked_div(interval).unwrap_or(0);
format!("{task_name}:{bucket}")
}
#[must_use]
pub fn cron_tick_key(task_name: &str, unix_secs: u64) -> String {
format!("{task_name}:{unix_secs}")
}
#[must_use]
pub fn advisory_lock_key(key_prefix: &str, task_name: &str, tick_key: &str) -> i64 {
let mut hasher = Sha256::new();
hasher.update(key_prefix.as_bytes());
hasher.update(b"\0");
hasher.update(task_name.as_bytes());
hasher.update(b"\0");
hasher.update(tick_key.as_bytes());
let digest = hasher.finalize();
let mut bytes = [0_u8; 8];
#[allow(
clippy::indexing_slicing,
reason = "infallible: SHA-256 digest is always 32 bytes, so [..8] is in bounds"
)]
let head = &digest[..8];
bytes.copy_from_slice(head);
i64::from_be_bytes(bytes)
}
#[must_use]
#[deprecated(
since = "0.7.0",
note = "reads real wall time off the injected-clock seam; use \
autumn_web::time::clock_unix_secs(state.clock()) instead (see #1797)"
)]
pub fn now_unix_secs() -> u64 {
#[allow(deprecated, reason = "the deprecated shim delegates to its own pair")]
now_unix_duration().as_secs()
}
#[must_use]
#[deprecated(
since = "0.7.0",
note = "reads real wall time off the injected-clock seam; use \
autumn_web::time::clock_unix_duration(state.clock()) instead (see #1797)"
)]
pub fn now_unix_duration() -> Duration {
#[allow(
clippy::disallowed_methods,
reason = "the body of the deprecated real-time shim itself; it exists only \
so an existing downstream caller keeps compiling while the \
deprecation steers it onto time::clock_unix_duration"
)]
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cron_tick_key_uses_task_name_and_second() {
assert_eq!(cron_tick_key("digest", 1_700_000_000), "digest:1700000000");
}
#[test]
fn in_process_coordinator_is_not_fleet_distributed() {
let coordinator = InProcessSchedulerCoordinator::new("replica-1");
assert_eq!(coordinator.backend(), "in_process");
assert!(!coordinator.is_fleet_distributed());
}
#[cfg(not(feature = "sqlite"))]
#[test]
fn sqlite_scheduler_backend_is_refused_without_the_sqlite_feature() {
let config = SchedulerConfig {
backend: SchedulerBackend::Sqlite,
..SchedulerConfig::default()
};
let state = AppState::for_test();
let message = match coordinator_from_config(&config, &state) {
Ok(_) => panic!("the sqlite coordinator needs a build with the sqlite feature"),
Err(error) => error.to_string(),
};
assert!(
message.contains("--features sqlite"),
"the refusal names the missing feature; got: {message}"
);
assert!(
message.contains("scheduler.backend = \"postgres\""),
"the refusal names the Postgres alternative; got: {message}"
);
}
#[test]
fn a_coordinator_reporting_the_postgres_backend_string_is_fleet_distributed() {
struct FakePostgresCoordinator;
impl SchedulerCoordinator for FakePostgresCoordinator {
fn backend(&self) -> &'static str {
"postgres"
}
fn replica_id(&self) -> &'static str {
"replica-1"
}
fn try_acquire<'a>(
&'a self,
_task_name: &'a str,
_tick_key: &'a str,
_coordination: TaskCoordination,
) -> SchedulerFuture<'a, AutumnResult<Option<SchedulerLease>>> {
Box::pin(async { Ok(None) })
}
}
assert!(FakePostgresCoordinator.is_fleet_distributed());
}
#[cfg(feature = "sqlite")]
fn sqlite_test_coordinator(ttl: Duration) -> SqliteLeaseSchedulerCoordinator {
let manager = diesel_async::pooled_connection::AsyncDieselConnectionManager::<
crate::db::RuntimeConnection,
>::new(":memory:");
let pool = diesel_async::pooled_connection::deadpool::Pool::builder(manager)
.max_size(1)
.runtime(deadpool::Runtime::Tokio1)
.build()
.expect("test pool builds without connecting");
SqliteLeaseSchedulerCoordinator::new(
pool,
"replica-1",
"test-prefix",
ttl,
std::sync::Arc::new(crate::time::SystemClock),
)
}
#[cfg(feature = "sqlite")]
#[test]
fn scheduler_coordinator_clamps_a_sub_second_lease_ttl() {
assert_eq!(
sqlite_test_coordinator(Duration::ZERO).lease_ttl,
Duration::from_secs(1),
"a zero TTL would expire the instant it is written"
);
assert_eq!(
sqlite_test_coordinator(Duration::from_micros(999)).lease_ttl,
Duration::from_secs(1),
"a sub-millisecond TTL leaves ttl_ms == 0"
);
}
#[cfg(feature = "sqlite")]
#[test]
fn scheduler_coordinator_keeps_a_sane_lease_ttl() {
assert_eq!(
sqlite_test_coordinator(Duration::from_secs(30)).lease_ttl,
Duration::from_secs(30)
);
assert_eq!(
sqlite_test_coordinator(Duration::from_secs(1)).lease_ttl,
Duration::from_secs(1),
"exactly the floor stays the floor"
);
}
#[test]
fn in_process_fleet_hint_flags_a_shared_jobs_backend() {
for jobs in ["postgres", "redis"] {
assert!(
in_process_fleet_hint(SchedulerBackend::InProcess, jobs, None, false).is_some(),
"{jobs}"
);
}
}
#[test]
fn in_process_fleet_hint_flags_replica_and_kubernetes_hints() {
let hint = |replicas, kubernetes| {
in_process_fleet_hint(SchedulerBackend::InProcess, "local", replicas, kubernetes)
};
assert!(hint(Some("3"), false).is_some());
assert!(hint(Some(" 2 "), false).is_some());
assert!(hint(None, true).is_some());
assert!(hint(Some("1"), false).is_none(), "one replica is safe");
assert!(hint(Some("many"), false).is_none(), "not a number");
assert!(hint(None, false).is_none(), "no hint");
}
#[test]
fn in_process_fleet_hint_is_silent_on_a_coordinated_backend() {
for backend in [SchedulerBackend::Postgres, SchedulerBackend::Sqlite] {
assert!(in_process_fleet_hint(backend, "postgres", Some("9"), true).is_none());
}
assert!(
in_process_fleet_hint(SchedulerBackend::InProcess, "sqlite", None, false).is_none(),
"a sqlite queue is single-host"
);
}
#[tokio::test]
async fn current_tick_is_set_only_inside_the_tick_scope() {
assert_eq!(current_tick(), None, "no tick outside a scheduled task");
let lease = SchedulerLease::local("postgres", "replica-a").with_fencing_token(42);
let tick = ScheduledTick::new("invoice:7", &lease);
let seen = with_tick(tick.clone(), async {
let inner = tokio::spawn(async { current_tick() }).await.expect("join");
(current_tick(), inner)
})
.await;
assert_eq!(seen.0, Some(tick));
assert_eq!(
seen.0.as_ref().map(ScheduledTick::tick_key),
Some("invoice:7")
);
assert_eq!(seen.0.and_then(|tick| tick.fencing_token()), Some(42));
assert_eq!(seen.1, None, "a spawned task does not inherit the tick");
assert_eq!(current_tick(), None);
}
#[cfg(feature = "postgres")]
#[test]
fn tick_hold_adds_the_period_and_is_capped() {
let minute = Duration::from_secs(60);
assert_eq!(tick_hold(minute, Duration::ZERO), minute);
assert_eq!(tick_hold(minute, 10 * minute), 11 * minute);
assert_eq!(tick_hold(Duration::MAX, minute), MAX_TICK_HOLD);
assert_eq!(tick_hold(minute, Duration::MAX), MAX_TICK_HOLD);
}
#[test]
fn local_lease_has_no_fencing_token() {
assert_eq!(
SchedulerLease::local("in_process", "replica-a").fencing_token(),
None
);
}
}