where unit is one of: s, m, h, d, mo, y. "
+ "Examples: '30s', '5m', '2h', '1d', '1mo', '1y'."
+ )
+ value = int(match.group(1))
+ unit = match.group(2)
+ if value <= 0:
+ raise ValueError("Interval value must be greater than 0.")
+ return value * INTERVAL_MULTIPLIERS[unit]
+
+
+def format_interval_seconds(seconds: int) -> str:
+ """
+ Convert interval_seconds back to a human-readable string.
+ """
+ if seconds < 60:
+ return f"{seconds}s"
+ if seconds < 3600:
+ return f"{seconds // 60}m"
+ if seconds < 86400:
+ return f"{seconds // 3600}h"
+ if seconds < 2592000:
+ return f"{seconds // 86400}d"
+ if seconds < 31536000:
+ return f"{seconds // 2592000}mo"
+ return f"{seconds // 31536000}y"
+
+
+class JobCreate(BaseModel):
+ type: JobType
+ payload: dict[str, Any] = Field(default_factory=dict)
+ priority: JobPriority = JobPriority.MEDIUM
+ scheduled_at: datetime | None = Field(
+ default=None,
+ description="ISO 8601 datetime. Defaults to now if not provided.",
+ )
+ interval: str | None = Field(
+ default=None,
+ description="Recurring interval e.g. '5m', '1h'. Null means non-recurring.",
+ )
+ dependency_ids: list[UUID] | None = Field(
+ default=None,
+ description="List of job IDs that must complete before this job runs.",
+ )
+ max_retries: int = Field(
+ default=3,
+ ge=1,
+ le=10,
+ description="Maximum retry attempts before the job moves to DLQ.",
+ )
+
+ @field_validator("interval")
+ @classmethod
+ def validate_interval(cls, v: str | None) -> str | None:
+ if v is not None:
+ parse_interval(v)
+ return v
+
+ @field_validator("dependency_ids")
+ @classmethod
+ def validate_dependency_ids(cls, v: list[UUID] | None) -> list[UUID] | None:
+ if v is not None and len(v) != len(set(v)):
+ raise ValueError("dependency_ids must not contain duplicates.")
+ return v
+
+ model_config = {
+ "json_schema_extra": {
+ "examples": [
+ {
+ "type": "webhook_delivery",
+ "payload": {
+ "url": "https://webhook.site/abc",
+ "body": {"event": "user.created"},
+ },
+ "priority": 1,
+ "scheduled_at": "2026-06-10T10:00:00Z",
+ "interval": "1h",
+ "dependency_ids": [],
+ "max_retries": 3,
+ }
+ ]
+ }
+ }
+
+
+class JobLogResponse(BaseModel):
+ id: UUID
+ job_id: UUID
+ event: str
+ message: str
+ metadata_: dict[str, Any] | None = Field(None, alias="metadata")
+ created_at: datetime
+
+ model_config = {
+ "from_attributes": True,
+ "populate_by_name": True,
+ }
+
+
+class JobResponse(BaseModel):
+ id: UUID
+ type: str
+ payload: dict[str, Any]
+ priority: int
+ status: str
+ scheduled_at: datetime
+ interval_seconds: int | None
+ retry_count: int
+ max_retries: int
+ last_error: str | None
+ effective_priority: float
+ cancellation_requested: bool
+ worker_id: str | None
+ started_at: datetime | None
+ completed_at: datetime | None
+ is_dlq: bool
+ created_at: datetime
+ updated_at: datetime
+
+ # Populated on detail endpoint only
+ dependencies: list[UUID] | None = None
+ logs: list[JobLogResponse] | None = None
+
+ model_config = {"from_attributes": True}
+
+
+class JobListResponse(BaseModel):
+ jobs: list[JobResponse]
+
+
+class JobFilterParams(BaseModel):
+ page: int = Field(default=1, ge=1, description="Page number (1-indexed).")
+ limit: int = Field(default=20, ge=1, le=100, description="Items per page.")
+ status: JobStatus | None = Field(default=None, description="Filter by status.")
+ type: JobType | None = Field(default=None, description="Filter by job type.")
+ priority: JobPriority | None = Field(
+ default=None, description="Filter by priority."
+ )
+ search: str | None = Field(
+ default=None,
+ description="Search across job ID (partial) and type.",
+ )
diff --git a/app/services/alert.py b/app/services/alert.py
new file mode 100644
index 0000000..9a4759c
--- /dev/null
+++ b/app/services/alert.py
@@ -0,0 +1,147 @@
+import os
+from email.mime.multipart import MIMEMultipart
+from email.mime.text import MIMEText
+
+import aiosmtplib
+from jinja2 import Environment, FileSystemLoader, select_autoescape
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.core.logger import get_logger
+
+logger = get_logger(__name__)
+
+TEMPLATE_DIR = os.path.join(os.path.dirname(__file__), "..", "templates", "emails")
+
+jinja_env = Environment(
+ loader=FileSystemLoader(TEMPLATE_DIR),
+ autoescape=select_autoescape(["html"]),
+)
+
+
+async def send_dlq_alert(
+ dlq_count: int,
+ session: AsyncSession,
+) -> None:
+ """
+ Send a DLQ threshold alert email to all configured recipients.
+ """
+ from app.core.config import settings as app_settings
+ from app.services.dlq import get_recent_dlq_jobs
+ from app.services.settings import get_alert_emails, get_dlq_threshold
+
+ recipients = await get_alert_emails(session)
+ if not recipients:
+ logger.info(
+ "dlq_alert_skipped",
+ reason="no_recipients_configured",
+ dlq_count=dlq_count,
+ )
+ return
+
+ threshold = await get_dlq_threshold(session)
+ recent_jobs = await get_recent_dlq_jobs(session, limit=10)
+
+ try:
+ template = jinja_env.get_template("dlq_alert.html")
+ html_body = template.render(
+ dlq_count=dlq_count,
+ threshold=threshold,
+ jobs=recent_jobs,
+ dashboard_url="https://app.flint.local/dlq",
+ app_name="Flint",
+ tagline="Quietly igniting your payload, every job has a spark.",
+ )
+ except Exception as exc:
+ logger.error(
+ "dlq_alert_template_error",
+ error=str(exc),
+ )
+ html_body = _plain_text_fallback(dlq_count, threshold, recent_jobs)
+
+ subject = (
+ f"[Flint Alert] DLQ threshold reached — "
+ f"{dlq_count} failed job{'s' if dlq_count != 1 else ''}"
+ )
+
+ for recipient in recipients:
+ await _send_email(
+ to=recipient,
+ subject=subject,
+ html_body=html_body,
+ smtp_host=app_settings.SMTP_HOST,
+ smtp_port=app_settings.SMTP_PORT,
+ smtp_from=app_settings.SMTP_FROM,
+ )
+
+ logger.warning(
+ "dlq_alert_sent",
+ dlq_count=dlq_count,
+ threshold=threshold,
+ recipient_count=len(recipients),
+ )
+
+
+async def _send_email(
+ to: str,
+ subject: str,
+ html_body: str,
+ smtp_host: str,
+ smtp_port: int,
+ smtp_from: str,
+) -> None:
+ """Send a single HTML email via aiosmtplib."""
+ msg = MIMEMultipart("alternative")
+ msg["Subject"] = subject
+ msg["From"] = smtp_from
+ msg["To"] = to
+
+ # Attach plain text fallback first, then HTML
+ plain = "Flint DLQ Alert: threshold reached. Visit your dashboard for details."
+ msg.attach(MIMEText(plain, "plain", "utf-8"))
+ msg.attach(MIMEText(html_body, "html", "utf-8"))
+
+ try:
+ await aiosmtplib.send(
+ msg,
+ hostname=smtp_host,
+ port=smtp_port,
+ use_tls=False,
+ start_tls=False,
+ )
+ logger.info("dlq_alert_email_delivered", to=to)
+ except Exception as exc:
+ logger.error(
+ "dlq_alert_email_failed",
+ to=to,
+ error=str(exc),
+ )
+
+
+def _plain_text_fallback(
+ dlq_count: int,
+ threshold: int,
+ jobs: list[dict],
+) -> str:
+ """
+ Plain HTML fallback when the Jinja template fails to render.
+ """
+ rows = "".join(
+ f""
+ f"| {j['short_id']} | "
+ f"{j['type']} | "
+ f"{j['last_error'][:80]} | "
+ f"{j['retry_count']}/{j['max_retries']} | "
+ f"{j['updated_at']} | "
+ f"
"
+ for j in jobs
+ )
+ return f"""
+
+ Flint — DLQ Threshold Reached
+ {dlq_count} failed jobs in the DLQ (threshold: {threshold}).
+
+ | ID | Type | Error | Retries | Failed At |
+ {rows}
+
+
+ """
diff --git a/app/services/dag.py b/app/services/dag.py
new file mode 100644
index 0000000..3a1e1cc
--- /dev/null
+++ b/app/services/dag.py
@@ -0,0 +1,379 @@
+import uuid
+from typing import TYPE_CHECKING
+
+from sqlalchemy import func, select, update
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.core.exceptions import DependencyCycleException, DependencyNotFoundException
+from app.core.logger import get_logger
+from app.models.job import Job, JobStatus
+from app.models.job_depedencies import JobDependency
+from app.models.job_log import JobLog, LogEvent
+
+if TYPE_CHECKING:
+ from app.queues.base import BaseQueue
+
+logger = get_logger(__name__)
+
+
+async def check_cycle(
+ new_job_id: uuid.UUID,
+ dependency_ids: list[uuid.UUID],
+ db: AsyncSession,
+) -> None:
+ """
+ Verify that adding dependency_ids to new_job_id does not create a cycle.
+
+ Performs a depth-first search starting from each dependency_id,
+ traversing its own dependencies recursively. If new_job_id is
+ encountered during the traversal, a cycle would be created.
+
+ Raises:
+ DependencyNotFoundException: If any dependency_id does not exist.
+ DependencyCycleException: If a cycle would be introduced.
+ """
+ for dep_id in dependency_ids:
+ result = await db.execute(
+ select(Job.id).where(
+ Job.id == dep_id,
+ Job.deleted_at.is_(None),
+ )
+ )
+ if result.scalar_one_or_none() is None:
+ raise DependencyNotFoundException(str(dep_id))
+
+ for dep_id in dependency_ids:
+ visited: set[uuid.UUID] = set()
+ await _dfs_cycle_check(
+ start=dep_id,
+ target=new_job_id,
+ visited=visited,
+ db=db,
+ )
+
+
+async def _dfs_cycle_check(
+ start: uuid.UUID,
+ target: uuid.UUID,
+ visited: set[uuid.UUID],
+ db: AsyncSession,
+) -> None:
+ """
+ Recursive DFS. Raises DependencyCycleException if target is found.
+ """
+ if start == target:
+ raise DependencyCycleException(str(target), str(start))
+
+ if start in visited:
+ return
+ visited.add(start)
+
+ result = await db.execute(
+ select(JobDependency.depends_on_id).where(JobDependency.job_id == start)
+ )
+ upstream_ids = [row[0] for row in result.fetchall()]
+
+ for upstream_id in upstream_ids:
+ await _dfs_cycle_check(
+ start=upstream_id,
+ target=target,
+ visited=visited,
+ db=db,
+ )
+
+
+async def create_dependencies(
+ job_id: uuid.UUID,
+ dependency_ids: list[uuid.UUID],
+ db: AsyncSession,
+) -> None:
+ """
+ Insert rows into job_dependencies for all dependency_ids.
+ Assumes cycle check has already been performed.
+ """
+ for dep_id in dependency_ids:
+ dependency = JobDependency(
+ job_id=job_id,
+ depends_on_id=dep_id,
+ )
+ db.add(dependency)
+
+ await db.flush()
+ logger.info(
+ "job_dependencies_created",
+ job_id=str(job_id),
+ dependency_count=len(dependency_ids),
+ dependency_ids=[str(d) for d in dependency_ids],
+ )
+
+
+async def on_job_completed(
+ job_id: uuid.UUID,
+ db: AsyncSession,
+ queue: "BaseQueue",
+) -> None:
+ """
+ Called after every successful job completion.
+ """
+ result = await db.execute(
+ select(JobDependency.job_id).where(JobDependency.depends_on_id == job_id)
+ )
+ dependent_job_ids = [row[0] for row in result.fetchall()]
+
+ for dependent_id in dependent_job_ids:
+ await _maybe_unblock_job(dependent_id, db, queue)
+
+
+async def _maybe_unblock_job(
+ job_id: uuid.UUID,
+ db: AsyncSession,
+ queue: "BaseQueue",
+) -> None:
+ """
+ Check if all dependencies of job_id are completed.
+ If yes, push the job onto the queue.
+ """
+ result = await db.execute(
+ select(Job).where(
+ Job.id == job_id,
+ Job.deleted_at.is_(None),
+ )
+ )
+ job = result.scalar_one_or_none()
+ if not job or job.status != JobStatus.PENDING:
+ return
+
+ total_result = await db.execute(
+ select(func.count(JobDependency.id)).where(JobDependency.job_id == job_id)
+ )
+ total_deps = total_result.scalar() or 0
+
+ if total_deps == 0:
+ return
+
+ # Count completed dependencies
+ completed_result = await db.execute(
+ select(func.count(Job.id))
+ .join(JobDependency, Job.id == JobDependency.depends_on_id)
+ .where(
+ JobDependency.job_id == job_id,
+ Job.status == JobStatus.COMPLETED,
+ )
+ )
+ completed_count = completed_result.scalar() or 0
+
+ if completed_count == total_deps:
+ logger.info(
+ "job_unblocked",
+ job_id=str(job_id),
+ completed_deps=completed_count,
+ )
+ await queue.push(
+ job_id=str(job_id),
+ effective_priority=job.effective_priority,
+ scheduled_at=job.scheduled_at.timestamp(),
+ created_at=job.created_at.timestamp(),
+ )
+
+
+async def on_job_failed(
+ job_id: uuid.UUID,
+ db: AsyncSession,
+) -> None:
+ """
+ Called when a job exhausts its retries and moves to the DLQ.
+ """
+ await _cascade_cancel(job_id, db)
+
+
+async def _cascade_cancel(
+ failed_job_id: uuid.UUID,
+ db: AsyncSession,
+) -> None:
+ """
+ BFS through the dependency graph cancelling all downstream jobs.
+ Only cancels jobs that are still 'pending'.
+ """
+ queue: list[uuid.UUID] = [failed_job_id]
+ visited: set[uuid.UUID] = {failed_job_id}
+
+ while queue:
+ current_id = queue.pop(0)
+
+ # Find all jobs that directly depend on current_id
+ result = await db.execute(
+ select(JobDependency.job_id).where(
+ JobDependency.depends_on_id == current_id
+ )
+ )
+ dependent_ids = [row[0] for row in result.fetchall()]
+
+ for dep_id in dependent_ids:
+ if dep_id in visited:
+ continue
+ visited.add(dep_id)
+
+ update_result = await db.execute(
+ update(Job)
+ .where(
+ Job.id == dep_id,
+ Job.status == JobStatus.PENDING,
+ Job.deleted_at.is_(None),
+ )
+ .values(
+ status=JobStatus.CANCELLED,
+ last_error=(
+ f"Cancelled: dependency job {failed_job_id} "
+ f"failed permanently and was moved to DLQ."
+ ),
+ updated_at=func.now(),
+ )
+ .returning(Job.id)
+ )
+ cancelled_id = update_result.scalar_one_or_none()
+
+ if cancelled_id:
+ log_entry = JobLog(
+ job_id=dep_id,
+ event=LogEvent.JOB_CANCELLED,
+ message=(
+ f"Job automatically cancelled because upstream "
+ f"dependency {failed_job_id} failed permanently."
+ ),
+ metadata_={
+ "failed_dependency_id": str(failed_job_id),
+ "reason": "upstream_dependency_failed",
+ },
+ )
+ db.add(log_entry)
+
+ logger.info(
+ "job_cascade_cancelled",
+ job_id=str(dep_id),
+ failed_dependency=str(failed_job_id),
+ )
+
+ queue.append(dep_id)
+
+ await db.commit()
+
+
+async def on_dag_root_retried(
+ job_id: uuid.UUID,
+ db: AsyncSession,
+) -> None:
+ """
+ Called when manually retries a DLQ job that has downstream dependents.
+ """
+ await _cascade_reset(job_id, db)
+
+
+async def _cascade_reset(
+ retried_job_id: uuid.UUID,
+ session: AsyncSession,
+) -> None:
+ """
+ BFS through the dependency graph resetting auto-cancelled downstream jobs.
+ """
+ queue: list[uuid.UUID] = [retried_job_id]
+ visited: set[uuid.UUID] = {retried_job_id}
+
+ while queue:
+ current_id = queue.pop(0)
+
+ result = await session.execute(
+ select(JobDependency.job_id).where(
+ JobDependency.depends_on_id == current_id
+ )
+ )
+ dependent_ids = [row[0] for row in result.fetchall()]
+
+ for dep_id in dependent_ids:
+ if dep_id in visited:
+ continue
+ visited.add(dep_id)
+
+ # Only reset jobs that were auto-cancelled due to dependency failure
+ dep_result = await session.execute(
+ select(Job).where(
+ Job.id == dep_id,
+ Job.deleted_at.is_(None),
+ )
+ )
+ dep_job = dep_result.scalar_one_or_none()
+
+ if not dep_job:
+ continue
+
+ is_auto_cancelled = (
+ dep_job.status == JobStatus.CANCELLED
+ and dep_job.last_error is not None
+ and "dependency" in dep_job.last_error.lower()
+ )
+
+ if is_auto_cancelled:
+ await session.execute(
+ update(Job)
+ .where(Job.id == dep_id)
+ .values(
+ status=JobStatus.PENDING,
+ retry_count=0,
+ last_error=None,
+ worker_id=None,
+ updated_at=func.now(),
+ )
+ )
+
+ log_entry = JobLog(
+ job_id=dep_id,
+ event=LogEvent.JOB_CREATED,
+ message=(
+ f"Job reset to pending: upstream dependency "
+ f"{retried_job_id} was manually retried."
+ ),
+ metadata_={
+ "retried_dependency_id": str(retried_job_id),
+ "reason": "upstream_dependency_retried",
+ },
+ )
+ session.add(log_entry)
+
+ logger.info(
+ "job_cascade_reset",
+ job_id=str(dep_id),
+ retried_dependency=str(retried_job_id),
+ )
+
+ queue.append(dep_id)
+
+ await session.commit()
+
+
+async def get_dependency_ids(
+ job_id: uuid.UUID,
+ db: AsyncSession,
+) -> list[uuid.UUID]:
+ """Return the list of job IDs that job_id depends on."""
+ result = await db.execute(
+ select(JobDependency.depends_on_id).where(JobDependency.job_id == job_id)
+ )
+ return [row[0] for row in result.fetchall()]
+
+
+async def has_unmet_dependencies(
+ job_id: uuid.UUID,
+ session: AsyncSession,
+) -> bool:
+ """
+ Return True if job_id has at least one dependency that is not yet completed.
+ """
+ result = await session.execute(
+ select(func.count(JobDependency.id))
+ .join(Job, Job.id == JobDependency.depends_on_id)
+ .where(
+ JobDependency.job_id == job_id,
+ Job.status != JobStatus.COMPLETED,
+ )
+ )
+ unmet_count = result.scalar() or 0
+ return unmet_count > 0
diff --git a/app/services/dlq.py b/app/services/dlq.py
new file mode 100644
index 0000000..b8baa20
--- /dev/null
+++ b/app/services/dlq.py
@@ -0,0 +1,246 @@
+import uuid
+
+from sqlalchemy import func, select, update
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.core.exceptions import JobNotInDLQException
+from app.core.logger import get_logger
+from app.models.job import Job, JobStatus
+from app.models.job_log import JobLog, LogEvent
+from app.queues.base import BaseQueue
+from app.services.settings import get_dlq_threshold
+
+logger = get_logger(__name__)
+
+
+async def send_to_dlq(
+ job_id: uuid.UUID,
+ error: str,
+ db: AsyncSession,
+) -> None:
+ """
+ Move a job to the dead letter queue after exhausting all retries.
+ """
+ await db.execute(
+ update(Job)
+ .where(Job.id == job_id)
+ .values(
+ status=JobStatus.FAILED,
+ is_dlq=True,
+ last_error=error,
+ worker_id=None,
+ updated_at=func.now(),
+ )
+ )
+
+ log_entry = JobLog(
+ job_id=job_id,
+ event=LogEvent.JOB_FAILED,
+ message=f"Job moved to DLQ after exhausting all retries. Error: {error}",
+ metadata_={"error": error, "reason": "max_retries_exhausted"},
+ )
+ db.add(log_entry)
+ await db.flush()
+
+ logger.error(
+ "job_failed",
+ job_id=str(job_id),
+ error=error[:200],
+ reason="max_retries_exhausted",
+ )
+
+ from app.services import dag
+
+ await dag.on_job_failed(job_id, db)
+
+ await _check_and_alert(db)
+
+
+async def _check_and_alert(session: AsyncSession) -> None:
+ """
+ Count current DLQ jobs. If count >= threshold, fire alert email.
+ """
+ count = await get_dlq_count(session)
+ threshold = await get_dlq_threshold(session)
+
+ logger.info("dlq_count_check", count=count, threshold=threshold)
+
+ if count >= threshold:
+ logger.warning(
+ "dlq_threshold_reached",
+ count=count,
+ threshold=threshold,
+ )
+ from app.services import alert
+
+ await alert.send_dlq_alert(count, session)
+
+
+async def get_dlq_jobs(
+ page: int,
+ limit: int,
+ db: AsyncSession,
+) -> tuple[list[Job], int]:
+ """
+ Return paginated DLQ jobs with total count.
+ """
+ base_filter = [
+ Job.is_dlq.is_(True),
+ Job.deleted_at.is_(None),
+ ]
+
+ total_result = await db.execute(select(func.count(Job.id)).where(*base_filter))
+ total = total_result.scalar() or 0
+
+ offset = (page - 1) * limit
+ jobs_result = await db.execute(
+ select(Job)
+ .where(*base_filter)
+ .order_by(Job.updated_at.desc())
+ .offset(offset)
+ .limit(limit)
+ )
+ jobs = list(jobs_result.scalars().all())
+
+ return jobs, total
+
+
+async def get_dlq_count(db: AsyncSession) -> int:
+ """Return the current number of jobs in the DLQ."""
+ result = await db.execute(
+ select(func.count(Job.id)).where(
+ Job.is_dlq.is_(True),
+ Job.deleted_at.is_(None),
+ )
+ )
+ return result.scalar() or 0
+
+
+async def get_recent_dlq_jobs(
+ db: AsyncSession,
+ limit: int = 10,
+) -> list[dict]:
+ """
+ Return the most recent DLQ jobs as plain dicts.
+ """
+ result = await db.execute(
+ select(Job)
+ .where(
+ Job.is_dlq.is_(True),
+ Job.deleted_at.is_(None),
+ )
+ .order_by(Job.updated_at.desc())
+ .limit(limit)
+ )
+ jobs = result.scalars().all()
+ return [
+ {
+ "id": str(job.id),
+ "short_id": str(job.id)[:8],
+ "type": job.type,
+ "last_error": job.last_error or "Unknown error",
+ "retry_count": job.retry_count,
+ "max_retries": job.max_retries,
+ "updated_at": job.updated_at.strftime("%Y-%m-%d %H:%M:%S UTC"),
+ }
+ for job in jobs
+ ]
+
+
+async def retry_dlq_job(
+ job_id: uuid.UUID,
+ db: AsyncSession,
+ queue: BaseQueue,
+) -> Job:
+ """
+ Manually retry a job from the DLQ.
+ """
+ result = await db.execute(
+ select(Job).where(
+ Job.id == job_id,
+ Job.deleted_at.is_(None),
+ )
+ )
+ job = result.scalar_one_or_none()
+
+ if not job or not job.is_dlq:
+ raise JobNotInDLQException(str(job_id))
+
+ # Reset the job
+ await db.execute(
+ update(Job)
+ .where(Job.id == job_id)
+ .values(
+ status=JobStatus.PENDING,
+ is_dlq=False,
+ retry_count=0,
+ last_error=None,
+ worker_id=None,
+ effective_priority=float(job.priority),
+ updated_at=func.now(),
+ )
+ )
+
+ log_entry = JobLog(
+ job_id=job_id,
+ event=LogEvent.JOB_CREATED,
+ message="Job manually retried from DLQ by engineer.",
+ metadata_={"reason": "manual_dlq_retry"},
+ )
+ db.add(log_entry)
+ await db.flush()
+
+ logger.info("dlq_job_retried", job_id=str(job_id))
+
+ # Cascade reset downstream auto-cancelled dependents
+ from app.services import dag
+
+ await dag.on_dag_root_retried(job_id, db)
+
+ # Re-evaluate DAG: push to queue only if all deps are met
+ has_unmet = await dag.has_unmet_dependencies(job_id, db)
+ if not has_unmet:
+ await queue.push(
+ job_id=str(job_id),
+ effective_priority=float(job.priority),
+ scheduled_at=job.scheduled_at.timestamp(),
+ created_at=job.created_at.timestamp(),
+ )
+ # Also sync to Redis sorted set
+ from app.services.job_service import _sync_job_to_redis
+
+ await _sync_job_to_redis(str(job_id), float(job.priority))
+
+ await db.commit()
+
+ refreshed = await db.get(Job, job_id)
+ return refreshed
+
+
+async def remove_from_dlq(
+ job_id: uuid.UUID,
+ session: AsyncSession,
+) -> None:
+ """
+ Soft-delete a job from the DLQ.
+ The job moves to the bin and can be hard-deleted from there.
+ """
+ result = await session.execute(
+ select(Job).where(
+ Job.id == job_id,
+ Job.deleted_at.is_(None),
+ )
+ )
+ job = result.scalar_one_or_none()
+
+ if not job or not job.is_dlq:
+ raise JobNotInDLQException(str(job_id))
+
+ await session.execute(
+ update(Job)
+ .where(Job.id == job_id)
+ .values(deleted_at=func.now(), updated_at=func.now())
+ )
+ await session.commit()
+
+ logger.info("dlq_job_removed", job_id=str(job_id))
diff --git a/app/services/job.py b/app/services/job.py
new file mode 100644
index 0000000..67bbf6e
--- /dev/null
+++ b/app/services/job.py
@@ -0,0 +1,456 @@
+import json
+import uuid
+from datetime import UTC, datetime
+
+import redis.asyncio as aioredis
+from sqlalchemy import func, or_, select, update
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.core.config import settings as app_settings
+from app.core.exceptions import (
+ JobNotCancellableException,
+ JobNotDeletableException,
+ JobNotFoundException,
+ JobNotInBinException,
+)
+from app.core.logger import get_logger
+from app.models.job import Job, JobStatus
+from app.models.job_log import JobLog, LogEvent
+from app.schemas.job import JobCreate, JobFilterParams, parse_interval
+from app.services import dag
+
+logger = get_logger(__name__)
+
+QUEUE_KEY = "flint:queue"
+EVENTS_CHANNEL = "flint:events"
+
+
+async def _get_redis() -> aioredis.Redis:
+ """Get a Redis client. Used internally by this service."""
+ return aioredis.from_url(
+ app_settings.REDIS_URL,
+ decode_responses=True,
+ encoding="utf-8",
+ )
+
+
+async def _sync_job_to_redis(job_id: str, effective_priority: float) -> None:
+ """
+ Add a job to the Redis sorted set (flint:queue).
+ Score = effective_priority. Lower score = higher urgency.
+ Workers read from this set to know which job to process next.
+ """
+ redis = await _get_redis()
+ try:
+ await redis.zadd(QUEUE_KEY, {job_id: effective_priority})
+ finally:
+ await redis.aclose()
+
+
+async def _remove_job_from_redis(job_id: str) -> None:
+ """Remove a job from the Redis sorted set."""
+ redis = await _get_redis()
+ try:
+ await redis.zrem(QUEUE_KEY, job_id)
+ finally:
+ await redis.aclose()
+
+
+async def _publish_sse_event(event: dict) -> None:
+ """
+ Publish a job status change event to the Redis pub/sub channel.
+ The SSE endpoint subscribes to this channel and forwards events
+ to connected browser clients.
+ """
+ redis = await _get_redis()
+ try:
+ await redis.publish(EVENTS_CHANNEL, json.dumps(event))
+ finally:
+ await redis.aclose()
+
+
+async def create_job(
+ data: JobCreate,
+ db: AsyncSession,
+) -> Job:
+ """
+ Create a new job and optionally push it to the queue.
+ """
+ # Parse interval
+ interval_seconds = None
+ if data.interval:
+ interval_seconds = parse_interval(data.interval)
+
+ dependency_ids = data.dependency_ids or []
+ if dependency_ids:
+ await dag.check_cycle(
+ new_job_id=uuid.uuid4(), # placeholder — job not yet in DB
+ dependency_ids=dependency_ids,
+ db=db,
+ )
+
+ scheduled_at = data.scheduled_at or datetime.now(UTC)
+
+ job = Job(
+ type=data.type,
+ payload=data.payload,
+ priority=int(data.priority),
+ effective_priority=float(data.priority),
+ status=JobStatus.PENDING,
+ scheduled_at=scheduled_at,
+ interval_seconds=interval_seconds,
+ max_retries=data.max_retries,
+ retry_count=0,
+ )
+ db.add(job)
+ await db.flush()
+
+ # Now run cycle check with the real job ID
+ if dependency_ids:
+ await dag.check_cycle(
+ new_job_id=job.id,
+ dependency_ids=dependency_ids,
+ db=db,
+ )
+ await dag.create_dependencies(job.id, dependency_ids, db)
+
+ log_entry = JobLog(
+ job_id=job.id,
+ event=LogEvent.JOB_CREATED,
+ message=f"Job created with type '{job.type}' and priority {job.priority}.",
+ metadata_={
+ "type": job.type,
+ "priority": job.priority,
+ "scheduled_at": scheduled_at.isoformat(),
+ "interval_seconds": interval_seconds,
+ "dependency_count": len(dependency_ids),
+ },
+ )
+ db.add(log_entry)
+ await db.commit()
+
+ logger.info(
+ "job_created",
+ job_id=str(job.id),
+ type=job.type,
+ priority=job.priority,
+ scheduled_at=scheduled_at.isoformat(),
+ has_dependencies=bool(dependency_ids),
+ )
+
+ has_unmet = bool(dependency_ids)
+ is_due = scheduled_at <= datetime.now(UTC)
+
+ if not has_unmet and is_due:
+ await _sync_job_to_redis(str(job.id), job.effective_priority)
+
+ await _publish_sse_event(
+ {
+ "job_id": str(job.id),
+ "status": JobStatus.PENDING,
+ "type": job.type,
+ }
+ )
+
+ return job
+
+
+async def get_jobs(
+ filters: JobFilterParams,
+ db: AsyncSession,
+) -> tuple[list[Job], int]:
+ """
+ Return paginated jobs with total count.
+ """
+ conditions: list = [
+ Job.deleted_at.is_(None),
+ Job.is_dlq.is_(False),
+ ]
+
+ if filters.status:
+ conditions.append(Job.status == filters.status)
+ if filters.type:
+ conditions.append(Job.type == filters.type)
+ if filters.priority:
+ conditions.append(Job.priority == int(filters.priority))
+ if filters.search:
+ search_term = f"%{filters.search}%"
+ conditions.append(
+ or_(
+ Job.type.ilike(search_term),
+ Job.id.cast(db_string_type()).ilike(search_term),
+ )
+ )
+
+ total_result = await db.execute(select(func.count(Job.id)).where(*conditions))
+ total = total_result.scalar() or 0
+
+ offset = (filters.page - 1) * filters.limit
+ jobs_result = await db.execute(
+ select(Job)
+ .where(*conditions)
+ .order_by(Job.created_at.desc())
+ .offset(offset)
+ .limit(filters.limit)
+ )
+ jobs = list(jobs_result.scalars().all())
+
+ return jobs, total
+
+
+def db_string_type():
+ """SQLAlchemy string type for UUID casting in search."""
+ from sqlalchemy import String
+
+ return String
+
+
+async def get_job_by_id(
+ job_id: uuid.UUID,
+ db: AsyncSession,
+ include_logs: bool = False,
+ include_dependencies: bool = False,
+) -> Job:
+ """
+ Fetch a single job by ID.
+ """
+ result = await db.execute(
+ select(Job).where(
+ Job.id == job_id,
+ Job.deleted_at.is_(None),
+ )
+ )
+ job = result.scalar_one_or_none()
+ if not job:
+ raise JobNotFoundException(str(job_id))
+ return job
+
+
+async def get_job_with_details(
+ job_id: uuid.UUID,
+ db: AsyncSession,
+) -> tuple[Job, list[uuid.UUID], list]:
+ """
+ Fetch a job with its dependency IDs and log entries.
+ """
+ job = await get_job_by_id(job_id, db)
+
+ dependency_ids = await dag.get_dependency_ids(job_id, db)
+
+ from sqlalchemy import asc
+
+ from app.models.job_log import JobLog
+
+ logs_result = await db.execute(
+ select(JobLog).where(JobLog.job_id == job_id).order_by(asc(JobLog.created_at))
+ )
+ logs = list(logs_result.scalars().all())
+
+ return job, dependency_ids, logs
+
+
+async def cancel_job(
+ job_id: uuid.UUID,
+ db: AsyncSession,
+) -> Job:
+ """
+ Request cancellation of a job.
+ """
+ job = await get_job_by_id(job_id, db)
+
+ if not job.is_cancellable:
+ raise JobNotCancellableException(str(job_id), job.status)
+
+ if job.status == JobStatus.PENDING:
+ # Immediate cancellation
+ await db.execute(
+ update(Job)
+ .where(Job.id == job_id)
+ .values(
+ status=JobStatus.CANCELLED,
+ cancellation_requested=True,
+ updated_at=func.now(),
+ )
+ )
+
+ await _remove_job_from_redis(str(job_id))
+
+ # Cascade cancel downstream dependents
+ await dag.on_job_failed(job_id, db)
+
+ log_entry = JobLog(
+ job_id=job_id,
+ event=LogEvent.JOB_CANCELLED,
+ message="Job cancelled by user request while pending.",
+ metadata_={"reason": "user_requested", "was_status": "pending"},
+ )
+ db.add(log_entry)
+
+ logger.info("job_cancelled", job_id=str(job_id), was_status="pending")
+
+ elif job.status == JobStatus.PROCESSING:
+ await db.execute(
+ update(Job)
+ .where(Job.id == job_id)
+ .values(
+ cancellation_requested=True,
+ updated_at=func.now(),
+ )
+ )
+
+ log_entry = JobLog(
+ job_id=job_id,
+ event=LogEvent.JOB_CANCELLED,
+ message=(
+ "Cancellation requested while job is processing. "
+ "Worker will honour at next checkpoint."
+ ),
+ metadata_={"reason": "user_requested", "was_status": "processing"},
+ )
+ db.add(log_entry)
+
+ logger.info(
+ "job_cancellation_requested",
+ job_id=str(job_id),
+ was_status="processing",
+ )
+
+ await db.commit()
+
+ await _publish_sse_event(
+ {
+ "job_id": str(job_id),
+ "status": JobStatus.CANCELLED,
+ }
+ )
+
+ job = await db.get(Job, job_id)
+ assert job is not None, f"Job {job_id} vanished after cancellation"
+ return job
+
+
+async def soft_delete_job(
+ job_id: uuid.UUID,
+ db: AsyncSession,
+) -> None:
+ """
+ Move a job to the bin (sets deleted_at).
+ """
+ job = await get_job_by_id(job_id, db)
+
+ if not job.is_terminal:
+ raise JobNotDeletableException(str(job_id), job.status)
+
+ await db.execute(
+ update(Job)
+ .where(Job.id == job_id)
+ .values(deleted_at=func.now(), updated_at=func.now())
+ )
+ await db.commit()
+
+ logger.info("job_soft_deleted", job_id=str(job_id))
+
+
+async def get_bin_jobs(
+ page: int,
+ limit: int,
+ db: AsyncSession,
+) -> tuple[list[Job], int]:
+ """
+ Return soft-deleted jobs (the bin). Paginated, most recently deleted first.
+ """
+ conditions = [Job.deleted_at.isnot(None)]
+
+ total_result = await db.execute(select(func.count(Job.id)).where(*conditions))
+ total = total_result.scalar() or 0
+
+ offset = (page - 1) * limit
+ jobs_result = await db.execute(
+ select(Job)
+ .where(*conditions)
+ .order_by(Job.deleted_at.desc())
+ .offset(offset)
+ .limit(limit)
+ )
+ jobs = list(jobs_result.scalars().all())
+
+ return jobs, total
+
+
+async def restore_job(
+ job_id: uuid.UUID,
+ db: AsyncSession,
+) -> Job:
+ """
+ Restore a soft-deleted job from the bin.
+ Clears deleted_at. Does not re-queue the job.
+ Raises JobNotInBinException if the job is not soft-deleted.
+ """
+ result = await db.execute(select(Job).where(Job.id == job_id))
+ job = result.scalar_one_or_none()
+
+ if not job or job.deleted_at is None:
+ raise JobNotInBinException(str(job_id))
+
+ await db.execute(
+ update(Job)
+ .where(Job.id == job_id)
+ .values(deleted_at=None, updated_at=func.now())
+ )
+ await db.commit()
+
+ logger.info("job_restored", job_id=str(job_id))
+ job = await db.get(Job, job_id)
+ assert job is not None, f"Job {job_id} vanished after cancellation"
+ return job
+
+
+async def hard_delete_job(
+ job_id: uuid.UUID,
+ db: AsyncSession,
+) -> None:
+ """
+ Permanently delete a job from the database.
+ """
+ result = await db.execute(select(Job).where(Job.id == job_id))
+ job = result.scalar_one_or_none()
+
+ if not job or job.deleted_at is None:
+ raise JobNotInBinException(str(job_id))
+
+ await db.delete(job)
+ await db.commit()
+
+ logger.info("job_hard_deleted", job_id=str(job_id))
+
+
+async def get_job_counts_by_status(
+ db: AsyncSession,
+) -> dict[str, int]:
+ """
+ Return a dict of job counts per status for the dashboard.
+ Excludes soft-deleted jobs. Includes DLQ count separately.
+ """
+
+ from app.services.dlq import get_dlq_count
+
+ results = await db.execute(
+ select(Job.status, func.count(Job.id))
+ .where(
+ Job.deleted_at.is_(None),
+ Job.is_dlq.is_(False),
+ )
+ .group_by(Job.status)
+ )
+ counts = {status: count for status, count in results.fetchall()}
+
+ dlq_count = await get_dlq_count(db)
+
+ return {
+ "pending": counts.get(JobStatus.PENDING, 0),
+ "processing": counts.get(JobStatus.PROCESSING, 0),
+ "completed": counts.get(JobStatus.COMPLETED, 0),
+ "failed": counts.get(JobStatus.FAILED, 0),
+ "cancelled": counts.get(JobStatus.CANCELLED, 0),
+ "dlq": dlq_count,
+ }
diff --git a/app/services/settings.py b/app/services/settings.py
new file mode 100644
index 0000000..6efb8f9
--- /dev/null
+++ b/app/services/settings.py
@@ -0,0 +1,111 @@
+import json
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.core.exceptions import SettingNotFoundException
+from app.core.logger import get_logger
+from app.models.settings import Setting, SettingKey
+
+logger = get_logger(__name__)
+
+
+async def get_setting(key: str, session: AsyncSession) -> str:
+ """
+ Return the value string for the given settings key.
+ """
+ result = await session.execute(select(Setting.value).where(Setting.key == key))
+ value = result.scalar_one_or_none()
+ if value is None:
+ raise SettingNotFoundException(key)
+ return value
+
+
+async def get_setting_with_default(
+ key: str,
+ default: str,
+ db: AsyncSession,
+) -> str:
+ """
+ Return the value for a settings key, falling back to default if not found.
+ Useful in worker/scheduler code where a missing key should not crash.
+ """
+ result = await db.execute(select(Setting.value).where(Setting.key == key))
+ value = result.scalar_one_or_none()
+ return value if value is not None else default
+
+
+async def get_all_settings(db: AsyncSession) -> dict[str, str]:
+ """
+ Return all settings as a flat {key: value} dict.
+ """
+ result = await db.execute(select(Setting))
+ settings = result.scalars().all()
+ return {s.key: s.value for s in settings}
+
+
+async def update_settings(
+ updates: dict[str, str],
+ db: AsyncSession,
+) -> dict[str, str]:
+ """
+ Upsert one or more settings by key.
+ """
+ for key, value in updates.items():
+ result = await db.execute(select(Setting).where(Setting.key == key))
+ existing = result.scalar_one_or_none()
+
+ if existing:
+ existing.value = value
+ logger.info(
+ "setting_updated",
+ key=key,
+ new_value=value if key != "alert_emails" else "***",
+ )
+ else:
+ new_setting = Setting(key=key, value=value)
+ db.add(new_setting)
+ logger.info("setting_created", key=key)
+
+ await db.commit()
+ return await get_all_settings(db)
+
+
+async def get_dlq_threshold(db: AsyncSession) -> int:
+ """
+ Return the DLQ threshold as an integer.
+ Falls back to 5 if the setting is missing or unparseable.
+ """
+ raw = await get_setting_with_default(SettingKey.DLQ_THRESHOLD, "5", db)
+ try:
+ return int(raw)
+ except (ValueError, TypeError):
+ logger.warning("dlq_threshold_parse_error", raw_value=raw)
+ return 5
+
+
+async def get_alert_emails(db: AsyncSession) -> list[str]:
+ """
+ Return the alert email list.
+ """
+ raw = await get_setting_with_default(SettingKey.ALERT_EMAILS, "[]", db)
+ try:
+ parsed = json.loads(raw)
+ if isinstance(parsed, list):
+ return [str(e) for e in parsed if e]
+ return []
+ except (ValueError, TypeError, json.JSONDecodeError):
+ logger.warning("alert_emails_parse_error", raw_value=raw)
+ return []
+
+
+async def get_scheduler_strategy(db: AsyncSession) -> str:
+ """
+ Return the active scheduler strategy: 'heap' or 'timing_wheel'.
+ Falls back to 'heap' if the setting is missing or invalid.
+ """
+ raw = await get_setting_with_default(SettingKey.SCHEDULER_STRATEGY, "heap", db)
+ if raw not in ("heap", "timing_wheel"):
+ logger.warning("scheduler_strategy_invalid", raw_value=raw)
+ return "heap"
+ return raw
diff --git a/app/templates/email/base.html b/app/templates/email/base.html
new file mode 100644
index 0000000..b2022b5
--- /dev/null
+++ b/app/templates/email/base.html
@@ -0,0 +1,163 @@
+
+
+
+
+
+ {% block title %}Flint Notification{% endblock %}
+
+
+
+
+
+
{% block content %}{% endblock %}
+
+
+
+
diff --git a/app/templates/email/dlq_alert.html b/app/templates/email/dlq_alert.html
new file mode 100644
index 0000000..1c4c658
--- /dev/null
+++ b/app/templates/email/dlq_alert.html
@@ -0,0 +1,61 @@
+{% extends "base.html" %} {% block title %}Flint DLQ Alert — {{ dlq_count }}
+Failed Jobs{% endblock %} {% block content %}
+⚠️ Dead Letter Queue Threshold Reached
+
+
+ {{ dlq_count }}
+ failed job{{ 's' if dlq_count != 1 else '' }} have accumulated in the Dead
+ Letter Queue. Your configured threshold is
+ {{ threshold }}.
+
+
+
+ These jobs exhausted all retry attempts and require manual inspection. Review
+ the error details below and retry each job once the underlying issue has been
+ resolved.
+
+
+{% if jobs %}
+
+
+
+ | Job ID |
+ Type |
+ Error |
+ Retries |
+ Failed At |
+
+
+
+ {% for job in jobs %}
+
+ | {{ job.short_id }} |
+
+ {{ job.type }}
+ |
+
+
+ {{ job.last_error | truncate(80, true, '...') }}
+
+ |
+
+ {{ job.retry_count }}/{{ job.max_retries }}
+ |
+ {{ job.updated_at }} |
+
+ {% endfor %}
+
+
+{% else %}
+No job details available.
+{% endif %}
+
+
+ Use the Flint dashboard to inspect error details, fix the underlying issue,
+ and manually retry each job.
+
+
+
+ View Dead Letter Queue →
+
+{% endblock %}