use crate::error::{JobError, JobResult};
use crate::job::Job;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::{PgPool, Row};
pub struct PgCronManager {
pool: PgPool,
}
impl PgCronManager {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
pub async fn initialize(&self) -> JobResult<()> {
sqlx::query("CREATE EXTENSION IF NOT EXISTS pg_cron")
.execute(&self.pool)
.await
.map_err(|e| JobError::pg_cron(&format!("Failed to create pg_cron extension: {}", e)))?;
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS backbone_cron_jobs (
id VARCHAR(255) PRIMARY KEY,
name VARCHAR(255) NOT NULL,
cron_expression VARCHAR(100) NOT NULL,
command TEXT NOT NULL,
database VARCHAR(100) NOT NULL DEFAULT CURRENT_DATABASE(),
username VARCHAR(100) NOT NULL DEFAULT CURRENT_USER,
active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
job_metadata JSONB
);
CREATE INDEX IF NOT EXISTS idx_backbone_cron_jobs_active ON backbone_cron_jobs(active);
CREATE INDEX IF NOT EXISTS idx_backbone_cron_jobs_name ON backbone_cron_jobs(name);
"#
)
.execute(&self.pool)
.await
.map_err(|e| JobError::pg_cron(&format!("Failed to create backbone_cron_jobs table: {}", e)))?;
Ok(())
}
pub async fn create_cron_job(&self, job: &Job) -> JobResult<i32> {
let command = self.generate_cron_command(job)?;
let _result = sqlx::query(
r#"
INSERT INTO backbone_cron_jobs (id, name, cron_expression, command, job_metadata)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (id) DO UPDATE SET
name = EXCLUDED.name,
cron_expression = EXCLUDED.cron_expression,
command = EXCLUDED.command,
job_metadata = EXCLUDED.job_metadata,
updated_at = NOW()
RETURNING id
"#
)
.bind(job.id.as_str())
.bind(&job.name)
.bind(&job.cron_expression)
.bind(&command)
.bind(serde_json::to_value(job)?)
.fetch_one(&self.pool)
.await
.map_err(|e| JobError::pg_cron(&format!("Failed to insert backbone cron job: {}", e)))?;
let cron_job_id = self.create_pg_cron_job(&job.cron_expression, &command).await?;
Ok(cron_job_id)
}
pub async fn update_cron_job(&self, job: &Job) -> JobResult<()> {
self.remove_cron_job(&job.id).await?;
self.create_cron_job(job).await?;
Ok(())
}
pub async fn remove_cron_job(&self, job_id: &crate::types::JobId) -> JobResult<()> {
let cron_job_id: Option<i32> = sqlx::query_scalar(
"SELECT cron.jobid FROM cron.job JOIN backbone_cron_jobs ON cron.job.command = backbone_cron_jobs.command WHERE backbone_cron_jobs.id = $1"
)
.bind(job_id.as_str())
.fetch_optional(&self.pool)
.await
.map_err(|e| JobError::pg_cron(&format!("Failed to query cron job: {}", e)))?;
if let Some(jobid) = cron_job_id {
sqlx::query("SELECT cron.unschedule($1)")
.bind(jobid)
.execute(&self.pool)
.await
.map_err(|e| JobError::pg_cron(&format!("Failed to unschedule cron job: {}", e)))?;
}
sqlx::query("DELETE FROM backbone_cron_jobs WHERE id = $1")
.bind(job_id.as_str())
.execute(&self.pool)
.await
.map_err(|e| JobError::pg_cron(&format!("Failed to delete backbone cron job: {}", e)))?;
Ok(())
}
pub async fn enable_cron_job(&self, job_id: &crate::types::JobId) -> JobResult<()> {
sqlx::query("UPDATE backbone_cron_jobs SET active = true, updated_at = NOW() WHERE id = $1")
.bind(job_id.as_str())
.execute(&self.pool)
.await
.map_err(|e| JobError::pg_cron(&format!("Failed to enable cron job: {}", e)))?;
Ok(())
}
pub async fn disable_cron_job(&self, job_id: &crate::types::JobId) -> JobResult<()> {
sqlx::query("UPDATE backbone_cron_jobs SET active = false, updated_at = NOW() WHERE id = $1")
.bind(job_id.as_str())
.execute(&self.pool)
.await
.map_err(|e| JobError::pg_cron(&format!("Failed to disable cron job: {}", e)))?;
Ok(())
}
pub async fn list_cron_jobs(&self) -> JobResult<Vec<PgCronJobInfo>> {
let rows = sqlx::query(
r#"
SELECT
bcj.id,
bcj.name,
bcj.cron_expression,
bcj.command,
bcj.active,
bcj.created_at,
bcj.updated_at,
bcj.job_metadata,
cj.jobid as pg_job_id
FROM backbone_cron_jobs bcj
LEFT JOIN cron.job cj ON cj.command = bcj.command
ORDER BY bcj.created_at
"#
)
.fetch_all(&self.pool)
.await
.map_err(|e| JobError::pg_cron(&format!("Failed to list cron jobs: {}", e)))?;
let mut jobs = Vec::new();
for row in rows {
jobs.push(PgCronJobInfo {
id: row.get("id"),
name: row.get("name"),
cron_expression: row.get("cron_expression"),
command: row.get("command"),
active: row.get("active"),
created_at: row.get("created_at"),
updated_at: row.get("updated_at"),
job_metadata: row.get("job_metadata"),
pg_job_id: row.get("pg_job_id"),
});
}
Ok(jobs)
}
pub async fn get_statistics(&self) -> JobResult<PgCronStatistics> {
let total_jobs: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM backbone_cron_jobs")
.fetch_one(&self.pool)
.await
.map_err(|e| JobError::pg_cron(&format!("Failed to get total jobs: {}", e)))?;
let active_jobs: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM backbone_cron_jobs WHERE active = true")
.fetch_one(&self.pool)
.await
.map_err(|e| JobError::pg_cron(&format!("Failed to get active jobs: {}", e)))?;
let pg_cron_jobs: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM cron.job")
.fetch_one(&self.pool)
.await
.map_err(|e| JobError::pg_cron(&format!("Failed to get pg_cron jobs: {}", e)))?;
Ok(PgCronStatistics {
total_tracked_jobs: total_jobs as u64,
active_tracked_jobs: active_jobs as u64,
pg_cron_jobs: pg_cron_jobs as u64,
})
}
pub async fn sync_jobs(&self, backbone_jobs: &[Job]) -> JobResult<SyncResult> {
let mut created = 0;
let mut updated = 0;
let mut removed = 0;
let pg_cron_jobs = self.list_cron_jobs().await?;
let pg_job_ids: std::collections::HashSet<String> = pg_cron_jobs.iter().map(|j| j.id.clone()).collect();
let backbone_job_ids: std::collections::HashSet<String> = backbone_jobs.iter().map(|j| j.id.as_str().to_string()).collect();
for job in backbone_jobs {
if !pg_job_ids.contains(job.id.as_str()) {
self.create_cron_job(job).await?;
created += 1;
} else {
if let Some(pg_job) = pg_cron_jobs.iter().find(|j| j.id == job.id.as_str()) {
if pg_job.cron_expression != job.cron_expression || !pg_job.active {
self.update_cron_job(job).await?;
updated += 1;
}
}
}
}
for job_id in pg_job_ids.difference(&backbone_job_ids) {
self.remove_cron_job(&crate::types::JobId::parse(job_id)).await?;
removed += 1;
}
Ok(SyncResult {
created,
updated,
removed,
})
}
fn generate_cron_command(&self, job: &Job) -> JobResult<String> {
let queue_payload = serde_json::json!({
"job_id": job.id.as_str(),
"job_name": job.name,
"queue": job.queue,
"payload": job.payload,
"metadata": job.metadata,
"timestamp": Utc::now().to_rfc3339()
});
let queue_payload_str = serde_json::to_string(&queue_payload)?;
let escaped_payload = queue_payload_str.replace('\'', "''");
let command = format!(
r#"
INSERT INTO job_queue (queue_name, payload, priority, created_at)
VALUES ('{}', '{}', {}, NOW())
"#,
job.queue, escaped_payload, job.priority as i32
);
Ok(command.trim().to_string())
}
async fn create_pg_cron_job(&self, cron_expression: &str, command: &str) -> JobResult<i32> {
let result = sqlx::query_scalar("SELECT cron.schedule($1, $2, $3)")
.bind(format!("backbone_job_{}", chrono::Utc::now().timestamp()))
.bind(cron_expression)
.bind(command)
.fetch_one(&self.pool)
.await
.map_err(|e| JobError::pg_cron(&format!("Failed to schedule pg_cron job: {}", e)))?;
Ok(result)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PgCronJobInfo {
pub id: String,
pub name: String,
pub cron_expression: String,
pub command: String,
pub active: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub job_metadata: Option<serde_json::Value>,
pub pg_job_id: Option<i32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PgCronStatistics {
pub total_tracked_jobs: u64,
pub active_tracked_jobs: u64,
pub pg_cron_jobs: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SyncResult {
pub created: u32,
pub updated: u32,
pub removed: u32,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::JobId;
#[test]
fn test_generate_cron_command() {
let job = Job::new(
JobId::new(),
"Test Job".to_string(),
"0 12 * * *".to_string(),
"test_queue".to_string(),
serde_json::json!({"test": true}),
);
let command = format!(
r#"
INSERT INTO job_queue (queue_name, payload, priority, created_at)
VALUES ('{}', '{{"test":true}}', {}, NOW())
"#,
job.queue, job.priority as i32
);
assert!(command.contains("INSERT INTO job_queue"));
assert!(command.contains("test_queue"));
assert!(command.contains("priority"));
}
#[test]
fn test_pg_cron_manager_creation() {
let _ = std::marker::PhantomData::<PgCronManager>;
}
}