feat: add cron

This commit is contained in:
master 2025-06-26 02:56:55 +08:00
parent 003d8840fd
commit 3a8eb88e1a
25 changed files with 947 additions and 215 deletions

10
Cargo.lock generated
View File

@ -1580,6 +1580,15 @@ dependencies = [
"cfg-if", "cfg-if",
] ]
[[package]]
name = "croner"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c344b0690c1ad1c7176fe18eb173e0c927008fdaaa256e40dfd43ddd149c0843"
dependencies = [
"chrono",
]
[[package]] [[package]]
name = "crossbeam-channel" name = "crossbeam-channel"
version = "0.5.15" version = "0.5.15"
@ -6750,6 +6759,7 @@ dependencies = [
"cocoon", "cocoon",
"color-eyre", "color-eyre",
"convert_case 0.8.0", "convert_case 0.8.0",
"croner",
"ctor", "ctor",
"dotenvy", "dotenvy",
"downloader", "downloader",

View File

@ -164,6 +164,7 @@ quick-xml = { version = "0.37.5", features = [
"serde-types", "serde-types",
"serde", "serde",
] } ] }
croner = "2.2.0"
[dev-dependencies] [dev-dependencies]
inquire = { workspace = true } inquire = { workspace = true }

View File

@ -107,26 +107,12 @@ impl App {
Ok::<(), RecorderError>(()) Ok::<(), RecorderError>(())
}, },
async { async {
{ task.run(if graceful_shutdown {
let monitor = task.setup_monitor().await?; Some(Self::shutdown_signal)
if graceful_shutdown { } else {
monitor None
.run_with_signal(async move {
Self::shutdown_signal().await;
tracing::info!("apalis shutting down...");
Ok(())
}) })
.await?; .await?;
} else {
monitor.run().await?;
}
}
Ok::<(), RecorderError>(())
},
async {
let listener = task.setup_listener().await?;
listener.listen().await?;
Ok::<(), RecorderError>(()) Ok::<(), RecorderError>(())
} }

View File

@ -21,7 +21,6 @@ use openidconnect::{
OAuth2TokenResponse, PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, TokenResponse, OAuth2TokenResponse, PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, TokenResponse,
core::{CoreAuthenticationFlow, CoreClient, CoreProviderMetadata}, core::{CoreAuthenticationFlow, CoreClient, CoreProviderMetadata},
}; };
use sea_orm::DbErr;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
use snafu::ResultExt; use snafu::ResultExt;
@ -338,9 +337,9 @@ impl AuthServiceTrait for OidcAuthService {
} }
} }
let subscriber_auth = match crate::models::auth::Model::find_by_pid(ctx, sub).await { let subscriber_auth = match crate::models::auth::Model::find_by_pid(ctx, sub).await {
Err(RecorderError::DbError { Err(RecorderError::ModelEntityNotFound { .. }) => {
source: DbErr::RecordNotFound(..), crate::models::auth::Model::create_from_oidc(ctx, sub.to_string()).await
}) => crate::models::auth::Model::create_from_oidc(ctx, sub.to_string()).await, }
r => r, r => r,
} }
.map_err(|e| { .map_err(|e| {

View File

@ -18,6 +18,8 @@ use crate::{
#[derive(Snafu, Debug)] #[derive(Snafu, Debug)]
#[snafu(visibility(pub(crate)))] #[snafu(visibility(pub(crate)))]
pub enum RecorderError { pub enum RecorderError {
#[snafu(transparent)]
CronError { source: croner::errors::CronError },
#[snafu(display( #[snafu(display(
"HTTP {status} {reason}, source = {source:?}", "HTTP {status} {reason}, source = {source:?}",
status = status, status = status,
@ -120,8 +122,13 @@ pub enum RecorderError {
#[snafu(source(from(Box<dyn std::error::Error + Send + Sync>, OptDynErr::some)))] #[snafu(source(from(Box<dyn std::error::Error + Send + Sync>, OptDynErr::some)))]
source: OptDynErr, source: OptDynErr,
}, },
#[snafu(display("Model Entity {entity} not found or not belong to subscriber"))] #[snafu(display("Model Entity {entity} not found or not belong to subscriber{}", (
ModelEntityNotFound { entity: Cow<'static, str> }, detail.as_ref().map(|detail| format!(" : {detail}"))).unwrap_or_default()
))]
ModelEntityNotFound {
entity: Cow<'static, str>,
detail: Option<String>,
},
#[snafu(transparent)] #[snafu(transparent)]
FetchError { source: FetchError }, FetchError { source: FetchError },
#[snafu(display("Credential3rdError: {message}, source = {source}"))] #[snafu(display("Credential3rdError: {message}, source = {source}"))]
@ -185,9 +192,20 @@ impl RecorderError {
} }
} }
pub fn from_db_record_not_found<T: ToString>(detail: T) -> Self { pub fn from_model_not_found_detail<C: Into<Cow<'static, str>>, T: ToString>(
Self::DbError { model: C,
source: sea_orm::DbErr::RecordNotFound(detail.to_string()), detail: T,
) -> Self {
Self::ModelEntityNotFound {
entity: model.into(),
detail: Some(detail.to_string()),
}
}
pub fn from_model_not_found<C: Into<Cow<'static, str>>>(model: C) -> Self {
Self::ModelEntityNotFound {
entity: model.into(),
detail: None,
} }
} }
} }
@ -252,9 +270,9 @@ impl IntoResponse for RecorderError {
) )
.into_response() .into_response()
} }
Self::ModelEntityNotFound { entity } => ( merr @ Self::ModelEntityNotFound { .. } => (
StatusCode::NOT_FOUND, StatusCode::NOT_FOUND,
Json::<StandardErrorResponse>(StandardErrorResponse::from(entity.to_string())), Json::<StandardErrorResponse>(StandardErrorResponse::from(merr.to_string())),
) )
.into_response(), .into_response(),
err => ( err => (

View File

@ -4,7 +4,7 @@ use fetch::{HttpClient, HttpClientTrait};
use maplit::hashmap; use maplit::hashmap;
use scraper::{Html, Selector}; use scraper::{Html, Selector};
use sea_orm::{ use sea_orm::{
ActiveModelTrait, ActiveValue::Set, ColumnTrait, DbErr, EntityTrait, QueryFilter, TryIntoModel, ActiveModelTrait, ActiveValue::Set, ColumnTrait, EntityTrait, QueryFilter, TryIntoModel,
}; };
use url::Url; use url::Url;
use util::OptDynErr; use util::OptDynErr;
@ -227,8 +227,9 @@ impl MikanClient {
self.fork_with_userpass_credential(userpass_credential) self.fork_with_userpass_credential(userpass_credential)
.await .await
} else { } else {
Err(RecorderError::from_db_record_not_found( Err(RecorderError::from_model_not_found_detail(
DbErr::RecordNotFound(format!("credential={credential_id} not found")), "credential",
format!("credential id {credential_id} not found"),
)) ))
} }
} }

View File

@ -93,9 +93,7 @@ pub fn register_subscriber_tasks_entity_mutations(
.into_tuple::<String>() .into_tuple::<String>()
.one(db) .one(db)
.await? .await?
.ok_or_else(|| RecorderError::ModelEntityNotFound { .ok_or_else(|| RecorderError::from_model_not_found("SubscriberTask"))?;
entity: "SubscriberTask".into(),
})?;
let task = app_ctx.task(); let task = app_ctx.task();
task.retry_subscriber_task(job_id.clone()).await?; task.retry_subscriber_task(job_id.clone()).await?;
@ -104,9 +102,7 @@ pub fn register_subscriber_tasks_entity_mutations(
.filter(subscriber_tasks::Column::Id.eq(&job_id)) .filter(subscriber_tasks::Column::Id.eq(&job_id))
.one(db) .one(db)
.await? .await?
.ok_or_else(|| RecorderError::ModelEntityNotFound { .ok_or_else(|| RecorderError::from_model_not_found("SubscriberTask"))?;
entity: "SubscriberTask".into(),
})?;
Ok::<_, RecorderError>(Some(FieldValue::owned_any(task_model))) Ok::<_, RecorderError>(Some(FieldValue::owned_any(task_model)))
}) })

View File

@ -63,9 +63,7 @@ pub fn register_subscriptions_to_schema_builder(
.filter(filters_condition) .filter(filters_condition)
.one(db) .one(db)
.await? .await?
.ok_or_else(|| RecorderError::ModelEntityNotFound { .ok_or_else(|| RecorderError::from_model_not_found("Subscription"))?;
entity: "Subscription".into(),
})?;
let subscription = let subscription =
subscriptions::Subscription::try_from_model(&subscription_model)?; subscriptions::Subscription::try_from_model(&subscription_model)?;
@ -85,9 +83,7 @@ pub fn register_subscriptions_to_schema_builder(
.filter(subscriber_tasks::Column::Id.eq(task_id.to_string())) .filter(subscriber_tasks::Column::Id.eq(task_id.to_string()))
.one(db) .one(db)
.await? .await?
.ok_or_else(|| RecorderError::ModelEntityNotFound { .ok_or_else(|| RecorderError::from_model_not_found("SubscriberTask"))?;
entity: "SubscriberTask".into(),
})?;
Ok(Some(FieldValue::owned_any(task_model))) Ok(Some(FieldValue::owned_any(task_model)))
}) })
@ -121,9 +117,7 @@ pub fn register_subscriptions_to_schema_builder(
.filter(filters_condition) .filter(filters_condition)
.one(db) .one(db)
.await? .await?
.ok_or_else(|| RecorderError::ModelEntityNotFound { .ok_or_else(|| RecorderError::from_model_not_found("Subscription"))?;
entity: "Subscription".into(),
})?;
let subscription = let subscription =
subscriptions::Subscription::try_from_model(&subscription_model)?; subscriptions::Subscription::try_from_model(&subscription_model)?;
@ -141,9 +135,7 @@ pub fn register_subscriptions_to_schema_builder(
.filter(subscriber_tasks::Column::Id.eq(task_id.to_string())) .filter(subscriber_tasks::Column::Id.eq(task_id.to_string()))
.one(db) .one(db)
.await? .await?
.ok_or_else(|| RecorderError::ModelEntityNotFound { .ok_or_else(|| RecorderError::from_model_not_found("SubscriberTask"))?;
entity: "SubscriberTask".into(),
})?;
Ok(Some(FieldValue::owned_any(task_model))) Ok(Some(FieldValue::owned_any(task_model)))
}) })
@ -178,9 +170,7 @@ pub fn register_subscriptions_to_schema_builder(
.filter(filters_condition) .filter(filters_condition)
.one(db) .one(db)
.await? .await?
.ok_or_else(|| RecorderError::ModelEntityNotFound { .ok_or_else(|| RecorderError::from_model_not_found("Subscription"))?;
entity: "Subscription".into(),
})?;
let subscription = let subscription =
subscriptions::Subscription::try_from_model(&subscription_model)?; subscriptions::Subscription::try_from_model(&subscription_model)?;
@ -198,9 +188,7 @@ pub fn register_subscriptions_to_schema_builder(
.filter(subscriber_tasks::Column::Id.eq(task_id.to_string())) .filter(subscriber_tasks::Column::Id.eq(task_id.to_string()))
.one(db) .one(db)
.await? .await?
.ok_or_else(|| RecorderError::ModelEntityNotFound { .ok_or_else(|| RecorderError::from_model_not_found("SubscriberTask"))?;
entity: "SubscriberTask".into(),
})?;
Ok(Some(FieldValue::owned_any(task_model))) Ok(Some(FieldValue::owned_any(task_model)))
}) })

View File

@ -171,6 +171,27 @@ pub enum Feeds {
SubscriptionId, SubscriptionId,
} }
#[derive(DeriveIden)]
pub enum Cron {
Table,
Id,
CronSource,
SubscriberId,
SubscriptionId,
CronExpr,
NextRun,
LastRun,
LastError,
Enabled,
LockedBy,
LockedAt,
TimeoutMs,
Attempts,
MaxAttempts,
Priority,
Status,
}
macro_rules! create_postgres_enum_for_active_enum { macro_rules! create_postgres_enum_for_active_enum {
($manager: expr, $active_enum: expr, $($enum_value:expr),+) => { ($manager: expr, $active_enum: expr, $($enum_value:expr),+) => {
{ {

View File

@ -0,0 +1,278 @@
use async_trait::async_trait;
use sea_orm_migration::{prelude::*, schema::*};
use crate::{
migrations::defs::{
Cron, CustomSchemaManagerExt, GeneralIds, Subscribers, Subscriptions, table_auto_z,
},
models::cron::{
CHECK_AND_CLEANUP_EXPIRED_CRON_LOCKS_FUNCTION_NAME,
CHECK_AND_TRIGGER_DUE_CRONS_FUNCTION_NAME, CRON_DUE_EVENT, CronSource, CronSourceEnum,
CronStatus, CronStatusEnum, NOTIFY_DUE_CRON_WHEN_MUTATING_FUNCTION_NAME,
NOTIFY_DUE_CRON_WHEN_MUTATING_TRIGGER_NAME,
},
};
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
create_postgres_enum_for_active_enum!(manager, CronSourceEnum, CronSource::Subscription)
.await?;
create_postgres_enum_for_active_enum!(
manager,
CronStatusEnum,
CronStatus::Pending,
CronStatus::Running,
CronStatus::Completed,
CronStatus::Failed
)
.await?;
manager
.create_table(
table_auto_z(Cron::Table)
.col(pk_auto(Cron::Id))
.col(string(Cron::CronExpr))
.col(enumeration(
Cron::CronSource,
CronSourceEnum,
CronSource::iden_values(),
))
.col(integer_null(Cron::SubscriberId))
.col(integer_null(Cron::SubscriptionId))
.col(timestamp_with_time_zone_null(Cron::NextRun))
.col(timestamp_with_time_zone_null(Cron::LastRun))
.col(string_null(Cron::LastError))
.col(boolean(Cron::Enabled).default(true))
.col(string_null(Cron::LockedBy))
.col(timestamp_with_time_zone_null(Cron::LockedAt))
.col(integer_null(Cron::TimeoutMs))
.col(integer(Cron::Attempts))
.col(integer(Cron::MaxAttempts))
.col(integer(Cron::Priority))
.col(enumeration(
Cron::Status,
CronStatusEnum,
CronStatus::iden_values(),
))
.foreign_key(
ForeignKey::create()
.name("fk_cron_subscriber_id")
.from(Cron::Table, Cron::SubscriberId)
.to(Subscribers::Table, Subscribers::Id)
.on_delete(ForeignKeyAction::Cascade)
.on_update(ForeignKeyAction::Cascade),
)
.foreign_key(
ForeignKey::create()
.name("fk_cron_subscription_id")
.from(Cron::Table, Cron::SubscriptionId)
.to(Subscriptions::Table, Subscriptions::Id)
.on_delete(ForeignKeyAction::Cascade)
.on_update(ForeignKeyAction::Cascade),
)
.to_owned(),
)
.await?;
manager
.create_postgres_auto_update_ts_trigger_for_col(Cron::Table, GeneralIds::UpdatedAt)
.await?;
manager
.create_index(
IndexCreateStatement::new()
.if_not_exists()
.name("idx_cron_cron_source")
.table(Cron::Table)
.col(Cron::CronSource)
.to_owned(),
)
.await?;
manager
.create_index(
IndexCreateStatement::new()
.if_not_exists()
.name("idx_cron_next_run")
.table(Cron::Table)
.col(Cron::NextRun)
.to_owned(),
)
.await?;
let db = manager.get_connection();
db.execute_unprepared(&format!(
r#"CREATE OR REPLACE FUNCTION {NOTIFY_DUE_CRON_WHEN_MUTATING_FUNCTION_NAME}() RETURNS trigger AS $$
BEGIN
-- Check if the cron is due to run
IF NEW.{next_run} IS NOT NULL
AND NEW.{next_run} <= CURRENT_TIMESTAMP
AND NEW.{enabled} = true
AND NEW.{status} = '{pending}'
AND NEW.{attempts} < NEW.{max_attempts}
-- Check if not locked or lock timeout
AND (
NEW.{locked_at} IS NULL
OR (
NEW.{timeout_ms} IS NOT NULL
AND (NEW.{locked_at} + NEW.{timeout_ms} * INTERVAL '1 millisecond') <= CURRENT_TIMESTAMP
)
)
-- Make sure the cron is a new due event, not a repeat event
AND (
OLD.{next_run} IS NULL
OR OLD.{next_run} > CURRENT_TIMESTAMP
OR OLD.{enabled} = false
OR OLD.{status} != '{pending}'
OR OLD.{attempts} != NEW.{attempts}
)
THEN
PERFORM pg_notify('{CRON_DUE_EVENT}', row_to_json(NEW)::text);
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;"#,
next_run = &Cron::NextRun.to_string(),
enabled = &Cron::Enabled.to_string(),
locked_at = &Cron::LockedAt.to_string(),
timeout_ms = &Cron::TimeoutMs.to_string(),
status = &Cron::Status.to_string(),
pending = &CronStatus::Pending.to_string(),
attempts = &Cron::Attempts.to_string(),
max_attempts = &Cron::MaxAttempts.to_string(),
))
.await?;
db.execute_unprepared(&format!(
r#"CREATE TRIGGER {NOTIFY_DUE_CRON_WHEN_MUTATING_TRIGGER_NAME}
AFTER INSERT OR UPDATE ON {table}
FOR EACH ROW
EXECUTE FUNCTION {NOTIFY_DUE_CRON_WHEN_MUTATING_FUNCTION_NAME}();"#,
table = &Cron::Table.to_string(),
))
.await?;
db.execute_unprepared(&format!(
r#"CREATE OR REPLACE FUNCTION {CHECK_AND_CLEANUP_EXPIRED_CRON_LOCKS_FUNCTION_NAME}() RETURNS INTEGER AS $$
DECLARE
affected_count INTEGER;
BEGIN
UPDATE {table}
SET
{locked_by} = NULL,
{locked_at} = NULL,
{status} = '{pending}'
WHERE
{locked_by} IS NOT NULL
AND {timeout_ms} IS NOT NULL
AND {locked_at} + {timeout_ms} * INTERVAL '1 millisecond' <= CURRENT_TIMESTAMP
AND {status} = '{running}';
GET DIAGNOSTICS affected_count = ROW_COUNT;
RETURN affected_count;
END;
$$ LANGUAGE plpgsql;"#,
table = &Cron::Table.to_string(),
locked_by = &Cron::LockedBy.to_string(),
locked_at = &Cron::LockedAt.to_string(),
status = &Cron::Status.to_string(),
running = &CronStatus::Running.to_string(),
pending = &CronStatus::Pending.to_string(),
timeout_ms = &Cron::TimeoutMs.to_string(),
))
.await?;
db.execute_unprepared(&format!(
r#"CREATE OR REPLACE FUNCTION {CHECK_AND_TRIGGER_DUE_CRONS_FUNCTION_NAME}() RETURNS INTEGER AS $$
DECLARE
cron_record RECORD;
notification_count INTEGER := 0;
BEGIN
FOR cron_record IN
SELECT * FROM {table}
WHERE {next_run} IS NOT NULL
AND {next_run} <= CURRENT_TIMESTAMP
AND {enabled} = true
AND {status} = '{pending}'
AND {attempts} < {max_attempts}
AND (
{locked_at} IS NULL
OR (
{timeout_ms} IS NOT NULL
AND {locked_at} + {timeout_ms} * INTERVAL '1 millisecond' <= CURRENT_TIMESTAMP
)
)
ORDER BY {priority} ASC, {next_run} ASC
FOR UPDATE SKIP LOCKED
LOOP
PERFORM pg_notify('{CRON_DUE_EVENT}', row_to_json(cron_record)::text);
notification_count := notification_count + 1;
END LOOP;
RETURN notification_count;
END;
$$ LANGUAGE plpgsql;"#,
table = &Cron::Table.to_string(),
next_run = &Cron::NextRun.to_string(),
enabled = &Cron::Enabled.to_string(),
status = &Cron::Status.to_string(),
pending = &CronStatus::Pending.to_string(),
locked_at = &Cron::LockedAt.to_string(),
timeout_ms = &Cron::TimeoutMs.to_string(),
priority = &Cron::Priority.to_string(),
attempts = &Cron::Attempts.to_string(),
max_attempts = &Cron::MaxAttempts.to_string(),
))
.await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let db = manager.get_connection();
db.execute_unprepared(&format!(
r#"DROP TRIGGER IF EXISTS {NOTIFY_DUE_CRON_WHEN_MUTATING_TRIGGER_NAME} ON {table};"#,
table = &Cron::Table.to_string(),
))
.await?;
db.execute_unprepared(&format!(
r#"DROP FUNCTION IF EXISTS {NOTIFY_DUE_CRON_WHEN_MUTATING_FUNCTION_NAME}();"#,
))
.await?;
db.execute_unprepared(&format!(
r#"DROP FUNCTION IF EXISTS {CHECK_AND_CLEANUP_EXPIRED_CRON_LOCKS_FUNCTION_NAME}();"#,
))
.await?;
db.execute_unprepared(&format!(
r#"DROP FUNCTION IF EXISTS {CHECK_AND_TRIGGER_DUE_CRONS_FUNCTION_NAME}();"#,
))
.await?;
manager
.drop_table(
TableDropStatement::new()
.if_exists()
.table(Cron::Table)
.to_owned(),
)
.await?;
manager
.drop_postgres_enum_for_active_enum(CronSourceEnum)
.await?;
manager
.drop_postgres_enum_for_active_enum(CronStatusEnum)
.await?;
Ok(())
}
}

View File

@ -11,6 +11,7 @@ pub mod m20250520_021135_subscriber_tasks;
pub mod m20250622_015618_feeds; pub mod m20250622_015618_feeds;
pub mod m20250622_020819_bangumi_and_episode_type; pub mod m20250622_020819_bangumi_and_episode_type;
pub mod m20250625_060701_add_subscription_id_to_subscriber_tasks; pub mod m20250625_060701_add_subscription_id_to_subscriber_tasks;
pub mod m20250629_065628_add_cron;
pub struct Migrator; pub struct Migrator;
@ -26,6 +27,7 @@ impl MigratorTrait for Migrator {
Box::new(m20250622_015618_feeds::Migration), Box::new(m20250622_015618_feeds::Migration),
Box::new(m20250622_020819_bangumi_and_episode_type::Migration), Box::new(m20250622_020819_bangumi_and_episode_type::Migration),
Box::new(m20250625_060701_add_subscription_id_to_subscriber_tasks::Migration), Box::new(m20250625_060701_add_subscription_id_to_subscriber_tasks::Migration),
Box::new(m20250629_065628_add_cron::Migration),
] ]
} }
} }

View File

@ -63,7 +63,9 @@ impl Model {
.filter(Column::Pid.eq(pid)) .filter(Column::Pid.eq(pid))
.one(db) .one(db)
.await? .await?
.ok_or_else(|| RecorderError::from_db_record_not_found("auth::find_by_pid"))?; .ok_or_else(|| {
RecorderError::from_model_not_found_detail("auth", format!("pid {pid} not found"))
})?;
Ok(subscriber_auth) Ok(subscriber_auth)
} }

View File

@ -0,0 +1,9 @@
pub const CRON_DUE_EVENT: &str = "cron_due";
pub const CHECK_AND_CLEANUP_EXPIRED_CRON_LOCKS_FUNCTION_NAME: &str =
"check_and_cleanup_expired_cron_locks";
pub const CHECK_AND_TRIGGER_DUE_CRONS_FUNCTION_NAME: &str = "check_and_trigger_due_crons";
pub const NOTIFY_DUE_CRON_WHEN_MUTATING_FUNCTION_NAME: &str = "notify_due_cron_when_mutating";
pub const NOTIFY_DUE_CRON_WHEN_MUTATING_TRIGGER_NAME: &str =
"notify_due_cron_when_mutating_trigger";

View File

@ -0,0 +1,305 @@
mod core;
mod registry;
pub use core::{
CHECK_AND_CLEANUP_EXPIRED_CRON_LOCKS_FUNCTION_NAME, CHECK_AND_TRIGGER_DUE_CRONS_FUNCTION_NAME,
CRON_DUE_EVENT, NOTIFY_DUE_CRON_WHEN_MUTATING_FUNCTION_NAME,
NOTIFY_DUE_CRON_WHEN_MUTATING_TRIGGER_NAME,
};
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use croner::Cron;
use sea_orm::{
ActiveValue::Set, DeriveActiveEnum, DeriveDisplay, DeriveEntityModel, EnumIter, QuerySelect,
Statement, TransactionTrait, entity::prelude::*, sea_query::LockType,
sqlx::postgres::PgNotification,
};
use serde::{Deserialize, Serialize};
use crate::{
app::AppContextTrait,
errors::{RecorderError, RecorderResult},
models::subscriptions::{self},
};
#[derive(
Debug, Clone, PartialEq, Eq, DeriveActiveEnum, EnumIter, DeriveDisplay, Serialize, Deserialize,
)]
#[sea_orm(rs_type = "String", db_type = "Enum", enum_name = "cron_source")]
#[serde(rename_all = "snake_case")]
pub enum CronSource {
#[sea_orm(string_value = "subscription")]
Subscription,
}
#[derive(
Debug, Clone, PartialEq, Eq, DeriveActiveEnum, EnumIter, DeriveDisplay, Serialize, Deserialize,
)]
#[sea_orm(rs_type = "String", db_type = "Enum", enum_name = "cron_status")]
#[serde(rename_all = "snake_case")]
pub enum CronStatus {
#[sea_orm(string_value = "pending")]
Pending,
#[sea_orm(string_value = "running")]
Running,
#[sea_orm(string_value = "completed")]
Completed,
#[sea_orm(string_value = "failed")]
Failed,
}
#[derive(Debug, Clone, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "cron")]
pub struct Model {
#[sea_orm(default_expr = "Expr::current_timestamp()")]
pub created_at: DateTimeUtc,
#[sea_orm(default_expr = "Expr::current_timestamp()")]
pub updated_at: DateTimeUtc,
#[sea_orm(primary_key)]
pub id: i32,
pub cron_source: CronSource,
pub subscriber_id: Option<i32>,
pub subscription_id: Option<i32>,
pub cron_expr: String,
pub next_run: Option<DateTimeUtc>,
pub last_run: Option<DateTimeUtc>,
pub last_error: Option<String>,
pub locked_by: Option<String>,
pub locked_at: Option<DateTimeUtc>,
pub timeout_ms: i32,
#[sea_orm(default_expr = "0")]
pub attempts: i32,
#[sea_orm(default_expr = "1")]
pub max_attempts: i32,
#[sea_orm(default_expr = "0")]
pub priority: i32,
pub status: CronStatus,
#[sea_orm(default_expr = "true")]
pub enabled: bool,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::subscribers::Entity",
from = "Column::SubscriberId",
to = "super::subscribers::Column::Id",
on_update = "Cascade",
on_delete = "Cascade"
)]
Subscriber,
#[sea_orm(
belongs_to = "super::subscriptions::Entity",
from = "Column::SubscriptionId",
to = "super::subscriptions::Column::Id",
on_update = "Cascade",
on_delete = "Cascade"
)]
Subscription,
}
impl Related<super::subscribers::Entity> for Entity {
fn to() -> RelationDef {
Relation::Subscriber.def()
}
}
impl Related<super::subscriptions::Entity> for Entity {
fn to() -> RelationDef {
Relation::Subscription.def()
}
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelatedEntity)]
pub enum RelatedEntity {
#[sea_orm(entity = "super::subscribers::Entity")]
Subscriber,
#[sea_orm(entity = "super::subscriptions::Entity")]
Subscription,
}
#[async_trait]
impl ActiveModelBehavior for ActiveModel {}
impl Model {
pub async fn handle_cron_notification(
ctx: &dyn AppContextTrait,
notification: PgNotification,
worker_id: &str,
) -> RecorderResult<()> {
let payload: Self = serde_json::from_str(notification.payload())?;
let cron_id = payload.id;
tracing::debug!("Cron notification received for cron {cron_id} and worker {worker_id}");
match Self::try_acquire_lock_with_cron_id(ctx, cron_id, worker_id).await? {
Some(cron) => match cron.exec_cron(ctx).await {
Ok(()) => {
tracing::debug!("Cron {cron_id} executed successfully");
cron.mark_cron_completed(ctx).await?;
}
Err(e) => {
tracing::error!("Error executing cron {cron_id}: {e}");
cron.mark_cron_failed(ctx, &e.to_string()).await?;
}
},
None => {
tracing::debug!(
"Cron lock not acquired for cron {cron_id} and worker {worker_id}, skipping..."
);
}
}
Ok(())
}
async fn try_acquire_lock_with_cron_id(
ctx: &dyn AppContextTrait,
cron_id: i32,
worker_id: &str,
) -> RecorderResult<Option<Self>> {
let db = ctx.db();
let txn = db.begin().await?;
let cron = Entity::find_by_id(cron_id)
.lock(LockType::Update)
.one(&txn)
.await?;
if let Some(cron) = cron {
if cron.enabled
&& cron.attempts < cron.max_attempts
&& cron.status == CronStatus::Pending
&& (cron.locked_at.is_none_or(|locked_at| {
locked_at + chrono::Duration::milliseconds(cron.timeout_ms as i64) <= Utc::now()
}))
&& cron.next_run.is_some_and(|next_run| next_run <= Utc::now())
{
let cron_active_model = ActiveModel {
id: Set(cron.id),
locked_by: Set(Some(worker_id.to_string())),
locked_at: Set(Some(Utc::now())),
status: Set(CronStatus::Running),
attempts: Set(cron.attempts + 1),
..Default::default()
};
let cron_model = cron_active_model.update(&txn).await?;
txn.commit().await?;
return Ok(Some(cron_model));
}
txn.commit().await?;
return Ok(Some(cron));
}
txn.rollback().await?;
Ok(None)
}
async fn exec_cron(&self, ctx: &dyn AppContextTrait) -> RecorderResult<()> {
match self.cron_source {
CronSource::Subscription => {
let subscription_id = self.subscription_id.unwrap_or_else(|| {
unreachable!("Subscription cron must have a subscription id")
});
let subscription = subscriptions::Entity::find_by_id(subscription_id)
.one(ctx.db())
.await?
.ok_or_else(|| RecorderError::from_model_not_found("Subscription"))?;
subscription.exec_cron(ctx).await?;
}
}
Ok(())
}
async fn mark_cron_completed(&self, ctx: &dyn AppContextTrait) -> RecorderResult<()> {
let db = ctx.db();
let next_run = self.calculate_next_run(&self.cron_expr)?;
ActiveModel {
id: Set(self.id),
next_run: Set(Some(next_run)),
last_run: Set(Some(Utc::now())),
status: Set(CronStatus::Pending),
locked_by: Set(None),
locked_at: Set(None),
attempts: Set(0),
last_error: Set(None),
..Default::default()
}
.update(db)
.await?;
Ok(())
}
async fn mark_cron_failed(&self, ctx: &dyn AppContextTrait, error: &str) -> RecorderResult<()> {
let db = ctx.db();
let should_retry = self.attempts < self.max_attempts;
let status = if should_retry {
CronStatus::Pending
} else {
CronStatus::Failed
};
let next_run = if should_retry {
Some(Utc::now() + chrono::Duration::seconds(5))
} else {
Some(self.calculate_next_run(&self.cron_expr)?)
};
ActiveModel {
id: Set(self.id),
next_run: Set(next_run),
status: Set(status),
locked_by: Set(None),
locked_at: Set(None),
last_run: Set(Some(Utc::now())),
last_error: Set(Some(error.to_string())),
attempts: Set(if should_retry { self.attempts + 1 } else { 0 }),
..Default::default()
}
.update(db)
.await?;
Ok(())
}
pub async fn cleanup_expired_locks(ctx: &dyn AppContextTrait) -> RecorderResult<i32> {
let db = ctx.db();
let result = db
.execute(Statement::from_string(
db.get_database_backend(),
format!("SELECT {CHECK_AND_CLEANUP_EXPIRED_CRON_LOCKS_FUNCTION_NAME}()"),
))
.await?;
Ok(result.rows_affected() as i32)
}
pub async fn check_and_trigger_due_crons(ctx: &dyn AppContextTrait) -> RecorderResult<()> {
let db = ctx.db();
db.execute(Statement::from_string(
db.get_database_backend(),
format!("SELECT {CHECK_AND_TRIGGER_DUE_CRONS_FUNCTION_NAME}()"),
))
.await?;
Ok(())
}
fn calculate_next_run(&self, cron_expr: &str) -> RecorderResult<DateTime<Utc>> {
let cron_expr = Cron::new(cron_expr).parse()?;
let next = cron_expr.find_next_occurrence(&Utc::now(), false)?;
Ok(next)
}
}

View File

@ -0,0 +1 @@

View File

@ -122,9 +122,7 @@ impl Model {
.filter(Column::FeedType.eq(FeedType::Rss)) .filter(Column::FeedType.eq(FeedType::Rss))
.one(db) .one(db)
.await? .await?
.ok_or(RecorderError::ModelEntityNotFound { .ok_or(RecorderError::from_model_not_found("Feed"))?;
entity: "Feed".into(),
})?;
let feed = Feed::from_model(ctx, feed_model).await?; let feed = Feed::from_model(ctx, feed_model).await?;

View File

@ -44,9 +44,7 @@ impl Feed {
.await?; .await?;
(subscription, episodes) (subscription, episodes)
} else { } else {
return Err(RecorderError::ModelEntityNotFound { return Err(RecorderError::from_model_not_found("Subscription"));
entity: "Subscription".into(),
});
}; };
Ok(Feed::SubscritpionEpisodes( Ok(Feed::SubscritpionEpisodes(

View File

@ -11,3 +11,4 @@ pub mod subscribers;
pub mod subscription_bangumi; pub mod subscription_bangumi;
pub mod subscription_episode; pub mod subscription_episode;
pub mod subscriptions; pub mod subscriptions;
pub mod cron;

View File

@ -130,10 +130,9 @@ impl Model {
pub async fn find_by_id(ctx: &dyn AppContextTrait, id: i32) -> RecorderResult<Self> { pub async fn find_by_id(ctx: &dyn AppContextTrait, id: i32) -> RecorderResult<Self> {
let db = ctx.db(); let db = ctx.db();
let subscriber = Entity::find_by_id(id) let subscriber = Entity::find_by_id(id).one(db).await?.ok_or_else(|| {
.one(db) RecorderError::from_model_not_found_detail("subscribers", format!("id {id} not found"))
.await? })?;
.ok_or_else(|| RecorderError::from_db_record_not_found("subscribers::find_by_id"))?;
Ok(subscriber) Ok(subscriber)
} }

View File

@ -190,16 +190,16 @@ impl Model {
let subscription_model = Entity::find_by_id(subscription_id) let subscription_model = Entity::find_by_id(subscription_id)
.one(db) .one(db)
.await? .await?
.ok_or_else(|| RecorderError::ModelEntityNotFound { .ok_or_else(|| RecorderError::from_model_not_found("Subscription"))?;
entity: "Subscription".into(),
})?;
if subscription_model.subscriber_id != subscriber_id { if subscription_model.subscriber_id != subscriber_id {
Err(RecorderError::ModelEntityNotFound { Err(RecorderError::from_model_not_found("Subscription"))?;
entity: "Subscription".into(),
})?;
} }
Ok(subscription_model) Ok(subscription_model)
} }
pub async fn exec_cron(&self, _ctx: &dyn AppContextTrait) -> RecorderResult<()> {
todo!()
}
} }

View File

@ -1,134 +1,18 @@
mod media; mod media;
mod subscriber;
mod subscription; mod subscription;
use std::sync::Arc; mod system;
pub use media::OptimizeImageTask; pub use media::OptimizeImageTask;
use sea_orm::{DeriveActiveEnum, DeriveDisplay, EnumIter, FromJsonQueryResult}; pub use subscriber::{
use serde::{Deserialize, Serialize}; SubscriberTask, SubscriberTaskType, SubscriberTaskTypeEnum, SubscriberTaskTypeVariant,
SubscriberTaskTypeVariantIter,
};
pub use subscription::{ pub use subscription::{
SyncOneSubscriptionFeedsFullTask, SyncOneSubscriptionFeedsIncrementalTask, SyncOneSubscriptionFeedsFullTask, SyncOneSubscriptionFeedsIncrementalTask,
SyncOneSubscriptionSourcesTask, SyncOneSubscriptionSourcesTask,
}; };
pub use system::{
use crate::{ SystemTask, SystemTaskType, SystemTaskTypeEnum, SystemTaskTypeVariant,
app::AppContextTrait, SystemTaskTypeVariantIter,
errors::{RecorderError, RecorderResult},
models::subscriptions::SubscriptionTrait,
task::AsyncTaskTrait,
}; };
#[derive(
Clone,
Debug,
Serialize,
Deserialize,
PartialEq,
Eq,
Copy,
DeriveActiveEnum,
DeriveDisplay,
EnumIter,
)]
#[sea_orm(rs_type = "String", db_type = "Text")]
pub enum SubscriberTaskType {
#[serde(rename = "sync_one_subscription_feeds_incremental")]
#[sea_orm(string_value = "sync_one_subscription_feeds_incremental")]
SyncOneSubscriptionFeedsIncremental,
#[serde(rename = "sync_one_subscription_feeds_full")]
#[sea_orm(string_value = "sync_one_subscription_feeds_full")]
SyncOneSubscriptionFeedsFull,
#[serde(rename = "sync_one_subscription_sources")]
#[sea_orm(string_value = "sync_one_subscription_sources")]
SyncOneSubscriptionSources,
}
impl TryFrom<&SubscriberTask> for serde_json::Value {
type Error = RecorderError;
fn try_from(value: &SubscriberTask) -> Result<Self, Self::Error> {
let json_value = serde_json::to_value(value)?;
Ok(match json_value {
serde_json::Value::Object(mut map) => {
map.remove("task_type");
serde_json::Value::Object(map)
}
_ => {
unreachable!("subscriber task must be an json object");
}
})
}
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, FromJsonQueryResult)]
#[serde(tag = "task_type")]
pub enum SubscriberTask {
#[serde(rename = "sync_one_subscription_feeds_incremental")]
SyncOneSubscriptionFeedsIncremental(SyncOneSubscriptionFeedsIncrementalTask),
#[serde(rename = "sync_one_subscription_feeds_full")]
SyncOneSubscriptionFeedsFull(SyncOneSubscriptionFeedsFullTask),
#[serde(rename = "sync_one_subscription_sources")]
SyncOneSubscriptionSources(SyncOneSubscriptionSourcesTask),
}
impl SubscriberTask {
pub fn get_subscriber_id(&self) -> i32 {
match self {
Self::SyncOneSubscriptionFeedsIncremental(task) => task.0.get_subscriber_id(),
Self::SyncOneSubscriptionFeedsFull(task) => task.0.get_subscriber_id(),
Self::SyncOneSubscriptionSources(task) => task.0.get_subscriber_id(),
}
}
pub async fn run(self, ctx: Arc<dyn AppContextTrait>) -> RecorderResult<()> {
match self {
Self::SyncOneSubscriptionFeedsIncremental(task) => task.run(ctx).await,
Self::SyncOneSubscriptionFeedsFull(task) => task.run(ctx).await,
Self::SyncOneSubscriptionSources(task) => task.run(ctx).await,
}
}
pub fn task_type(&self) -> SubscriberTaskType {
match self {
Self::SyncOneSubscriptionFeedsIncremental(_) => {
SubscriberTaskType::SyncOneSubscriptionFeedsIncremental
}
Self::SyncOneSubscriptionFeedsFull(_) => {
SubscriberTaskType::SyncOneSubscriptionFeedsFull
}
Self::SyncOneSubscriptionSources(_) => SubscriberTaskType::SyncOneSubscriptionSources,
}
}
}
#[derive(
Clone,
Debug,
Serialize,
Deserialize,
PartialEq,
Eq,
Copy,
DeriveActiveEnum,
DeriveDisplay,
EnumIter,
)]
#[sea_orm(rs_type = "String", db_type = "Text")]
pub enum SystemTaskType {
#[serde(rename = "optimize_image")]
#[sea_orm(string_value = "optimize_image")]
OptimizeImage,
}
#[derive(Clone, Debug, Serialize, Deserialize, FromJsonQueryResult)]
pub enum SystemTask {
#[serde(rename = "optimize_image")]
OptimizeImage(OptimizeImageTask),
}
impl SystemTask {
pub async fn run(self, ctx: Arc<dyn AppContextTrait>) -> RecorderResult<()> {
match self {
Self::OptimizeImage(task) => task.run(ctx).await,
}
}
}

View File

@ -0,0 +1,100 @@
use std::sync::Arc;
use sea_orm::{DeriveActiveEnum, DeriveDisplay, EnumIter, FromJsonQueryResult};
use serde::{Deserialize, Serialize};
use crate::{
app::AppContextTrait,
errors::{RecorderError, RecorderResult},
models::subscriptions::SubscriptionTrait,
task::{
AsyncTaskTrait,
registry::{
SyncOneSubscriptionFeedsFullTask, SyncOneSubscriptionFeedsIncrementalTask,
SyncOneSubscriptionSourcesTask,
},
},
};
#[derive(
Clone,
Debug,
Serialize,
Deserialize,
PartialEq,
Eq,
Copy,
DeriveActiveEnum,
DeriveDisplay,
EnumIter,
)]
#[sea_orm(rs_type = "String", db_type = "Text")]
pub enum SubscriberTaskType {
#[serde(rename = "sync_one_subscription_feeds_incremental")]
#[sea_orm(string_value = "sync_one_subscription_feeds_incremental")]
SyncOneSubscriptionFeedsIncremental,
#[serde(rename = "sync_one_subscription_feeds_full")]
#[sea_orm(string_value = "sync_one_subscription_feeds_full")]
SyncOneSubscriptionFeedsFull,
#[serde(rename = "sync_one_subscription_sources")]
#[sea_orm(string_value = "sync_one_subscription_sources")]
SyncOneSubscriptionSources,
}
impl TryFrom<&SubscriberTask> for serde_json::Value {
type Error = RecorderError;
fn try_from(value: &SubscriberTask) -> Result<Self, Self::Error> {
let json_value = serde_json::to_value(value)?;
Ok(match json_value {
serde_json::Value::Object(mut map) => {
map.remove("task_type");
serde_json::Value::Object(map)
}
_ => {
unreachable!("subscriber task must be an json object");
}
})
}
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, FromJsonQueryResult)]
#[serde(tag = "task_type")]
pub enum SubscriberTask {
#[serde(rename = "sync_one_subscription_feeds_incremental")]
SyncOneSubscriptionFeedsIncremental(SyncOneSubscriptionFeedsIncrementalTask),
#[serde(rename = "sync_one_subscription_feeds_full")]
SyncOneSubscriptionFeedsFull(SyncOneSubscriptionFeedsFullTask),
#[serde(rename = "sync_one_subscription_sources")]
SyncOneSubscriptionSources(SyncOneSubscriptionSourcesTask),
}
impl SubscriberTask {
pub fn get_subscriber_id(&self) -> i32 {
match self {
Self::SyncOneSubscriptionFeedsIncremental(task) => task.0.get_subscriber_id(),
Self::SyncOneSubscriptionFeedsFull(task) => task.0.get_subscriber_id(),
Self::SyncOneSubscriptionSources(task) => task.0.get_subscriber_id(),
}
}
pub async fn run(self, ctx: Arc<dyn AppContextTrait>) -> RecorderResult<()> {
match self {
Self::SyncOneSubscriptionFeedsIncremental(task) => task.run(ctx).await,
Self::SyncOneSubscriptionFeedsFull(task) => task.run(ctx).await,
Self::SyncOneSubscriptionSources(task) => task.run(ctx).await,
}
}
pub fn task_type(&self) -> SubscriberTaskType {
match self {
Self::SyncOneSubscriptionFeedsIncremental(_) => {
SubscriberTaskType::SyncOneSubscriptionFeedsIncremental
}
Self::SyncOneSubscriptionFeedsFull(_) => {
SubscriberTaskType::SyncOneSubscriptionFeedsFull
}
Self::SyncOneSubscriptionSources(_) => SubscriberTaskType::SyncOneSubscriptionSources,
}
}
}

View File

@ -0,0 +1,43 @@
use std::sync::Arc;
use sea_orm::{DeriveActiveEnum, DeriveDisplay, EnumIter, FromJsonQueryResult};
use serde::{Deserialize, Serialize};
use crate::{
app::AppContextTrait,
errors::RecorderResult,
task::{AsyncTaskTrait, registry::media::OptimizeImageTask},
};
#[derive(
Clone,
Debug,
Serialize,
Deserialize,
PartialEq,
Eq,
Copy,
DeriveActiveEnum,
DeriveDisplay,
EnumIter,
)]
#[sea_orm(rs_type = "String", db_type = "Text")]
pub enum SystemTaskType {
#[serde(rename = "optimize_image")]
#[sea_orm(string_value = "optimize_image")]
OptimizeImage,
}
#[derive(Clone, Debug, Serialize, Deserialize, FromJsonQueryResult)]
pub enum SystemTask {
#[serde(rename = "optimize_image")]
OptimizeImage(OptimizeImageTask),
}
impl SystemTask {
pub async fn run(self, ctx: Arc<dyn AppContextTrait>) -> RecorderResult<()> {
match self {
Self::OptimizeImage(task) => task.run(ctx).await,
}
}
}

View File

@ -1,16 +1,18 @@
use std::{ops::Deref, str::FromStr, sync::Arc}; use std::{future::Future, ops::Deref, str::FromStr, sync::Arc};
use apalis::prelude::*; use apalis::prelude::*;
use apalis_sql::{ use apalis_sql::{
Config, Config,
context::SqlContext, context::SqlContext,
postgres::{PgListen, PostgresStorage}, postgres::{PgListen as ApalisPgListen, PostgresStorage as ApalisPostgresStorage},
}; };
use sea_orm::sqlx::postgres::PgListener;
use tokio::sync::RwLock; use tokio::sync::RwLock;
use crate::{ use crate::{
app::AppContextTrait, app::AppContextTrait,
errors::{RecorderError, RecorderResult}, errors::{RecorderError, RecorderResult},
models::cron::{self, CRON_DUE_EVENT},
task::{ task::{
SUBSCRIBER_TASK_APALIS_NAME, SYSTEM_TASK_APALIS_NAME, SubscriberTask, TaskConfig, SUBSCRIBER_TASK_APALIS_NAME, SYSTEM_TASK_APALIS_NAME, SubscriberTask, TaskConfig,
config::{default_subscriber_task_workers, default_system_task_workers}, config::{default_subscriber_task_workers, default_system_task_workers},
@ -21,8 +23,9 @@ use crate::{
pub struct TaskService { pub struct TaskService {
pub config: TaskConfig, pub config: TaskConfig,
ctx: Arc<dyn AppContextTrait>, ctx: Arc<dyn AppContextTrait>,
subscriber_task_storage: Arc<RwLock<PostgresStorage<SubscriberTask>>>, subscriber_task_storage: Arc<RwLock<ApalisPostgresStorage<SubscriberTask>>>,
system_task_storage: Arc<RwLock<PostgresStorage<SystemTask>>>, system_task_storage: Arc<RwLock<ApalisPostgresStorage<SystemTask>>>,
cron_worker_id: String,
} }
impl TaskService { impl TaskService {
@ -43,12 +46,13 @@ impl TaskService {
let system_task_storage_config = let system_task_storage_config =
Config::new(SYSTEM_TASK_APALIS_NAME).set_keep_alive(config.system_task_timeout); Config::new(SYSTEM_TASK_APALIS_NAME).set_keep_alive(config.system_task_timeout);
let subscriber_task_storage = let subscriber_task_storage =
PostgresStorage::new_with_config(pool.clone(), subscriber_task_storage_config); ApalisPostgresStorage::new_with_config(pool.clone(), subscriber_task_storage_config);
let system_task_storage = let system_task_storage =
PostgresStorage::new_with_config(pool, system_task_storage_config); ApalisPostgresStorage::new_with_config(pool, system_task_storage_config);
Ok(Self { Ok(Self {
config, config,
cron_worker_id: nanoid::nanoid!(),
ctx, ctx,
subscriber_task_storage: Arc::new(RwLock::new(subscriber_task_storage)), subscriber_task_storage: Arc::new(RwLock::new(subscriber_task_storage)),
system_task_storage: Arc::new(RwLock::new(system_task_storage)), system_task_storage: Arc::new(RwLock::new(system_task_storage)),
@ -132,8 +136,73 @@ impl TaskService {
Ok(task_id) Ok(task_id)
} }
pub async fn setup_monitor(&self) -> RecorderResult<Monitor> { pub async fn run<F, Fut>(&self, shutdown_signal: Option<F>) -> RecorderResult<()>
let mut monitor = Monitor::new(); where
F: Fn() -> Fut + Send + 'static,
Fut: Future<Output = ()> + Send,
{
tokio::try_join!(
async {
let monitor = self.setup_apalis_monitor().await?;
if let Some(shutdown_signal) = shutdown_signal {
monitor
.run_with_signal(async move {
shutdown_signal().await;
tracing::info!("apalis shutting down...");
Ok(())
})
.await?;
} else {
monitor.run().await?;
}
Ok::<_, RecorderError>(())
},
async {
let listener = self.setup_apalis_listener().await?;
tokio::task::spawn(async move {
if let Err(e) = listener.listen().await {
tracing::error!("Error listening to apalis: {e}");
}
});
Ok::<_, RecorderError>(())
},
async {
let listener = self.setup_cron_due_listening().await?;
let ctx = self.ctx.clone();
let cron_worker_id = self.cron_worker_id.clone();
tokio::task::spawn(async move {
if let Err(e) = Self::listen_cron_due(listener, ctx, &cron_worker_id).await {
tracing::error!("Error listening to cron due: {e}");
}
});
Ok::<_, RecorderError>(())
},
async {
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(60));
let ctx = self.ctx.clone();
tokio::task::spawn(async move {
loop {
interval.tick().await;
if let Err(e) = cron::Model::cleanup_expired_locks(ctx.as_ref()).await {
tracing::error!("Error cleaning up expired locks: {e}");
}
if let Err(e) = cron::Model::check_and_trigger_due_crons(ctx.as_ref()).await
{
tracing::error!("Error checking and triggering due crons: {e}");
}
}
});
Ok::<_, RecorderError>(())
}
)?;
Ok(())
}
async fn setup_apalis_monitor(&self) -> RecorderResult<Monitor> {
let mut apalis_monitor = Monitor::new();
{ {
let subscriber_task_worker = WorkerBuilder::new(SUBSCRIBER_TASK_APALIS_NAME) let subscriber_task_worker = WorkerBuilder::new(SUBSCRIBER_TASK_APALIS_NAME)
@ -155,28 +224,51 @@ impl TaskService {
.backend(self.system_task_storage.read().await.clone()) .backend(self.system_task_storage.read().await.clone())
.build_fn(Self::run_system_task); .build_fn(Self::run_system_task);
monitor = monitor apalis_monitor = apalis_monitor
.register(subscriber_task_worker) .register(subscriber_task_worker)
.register(system_task_worker); .register(system_task_worker);
} }
Ok(monitor) Ok(apalis_monitor)
} }
pub async fn setup_listener(&self) -> RecorderResult<PgListen> { async fn setup_apalis_listener(&self) -> RecorderResult<ApalisPgListen> {
let pool = self.ctx.db().get_postgres_connection_pool().clone(); let pool = self.ctx.db().get_postgres_connection_pool().clone();
let mut task_listener = PgListen::new(pool).await?; let mut apalis_pg_listener = ApalisPgListen::new(pool).await?;
{ {
let mut subscriber_task_storage = self.subscriber_task_storage.write().await; let mut subscriber_task_storage = self.subscriber_task_storage.write().await;
task_listener.subscribe_with(&mut subscriber_task_storage); apalis_pg_listener.subscribe_with(&mut subscriber_task_storage);
} }
{ {
let mut system_task_storage = self.system_task_storage.write().await; let mut system_task_storage = self.system_task_storage.write().await;
task_listener.subscribe_with(&mut system_task_storage); apalis_pg_listener.subscribe_with(&mut system_task_storage);
} }
Ok(task_listener) Ok(apalis_pg_listener)
}
async fn setup_cron_due_listening(&self) -> RecorderResult<PgListener> {
let pool = self.ctx.db().get_postgres_connection_pool().clone();
let listener = PgListener::connect_with(&pool).await?;
Ok(listener)
}
async fn listen_cron_due(
mut listener: PgListener,
ctx: Arc<dyn AppContextTrait>,
worker_id: &str,
) -> RecorderResult<()> {
listener.listen(CRON_DUE_EVENT).await?;
loop {
let notification = listener.recv().await?;
if let Err(e) =
cron::Model::handle_cron_notification(ctx.as_ref(), notification, worker_id).await
{
tracing::error!("Error handling cron notification: {e}");
}
}
} }
} }