# Abstra Jobs Guide

A **job** is a stage that runs on a **schedule** (cron) — no external request, no incoming task. The job's Python file runs top-to-bottom each time it fires. Use jobs for unattended, recurring automation: syncs, polling, reports, cleanup, reminders, and kicking off batch work.

## Scheduling

The schedule is **configuration, not code** — set it in the editor's stage settings (every hour / daily / weekly / monthly, or a custom **cron expression** for minute-level control). It's stored as the stage's `schedule` field (a cron string like `0 7 * * *` = 07:00 every day) in `abstra.json`.

The cron expression is evaluated in **UTC** once deployed — `0 7 * * *` fires at 07:00 UTC, not the user's local time. When the user gives a local time, convert it to UTC before writing the schedule and tell them the conversion.

> **Jobs do not run automatically in the editor** — they only fire on schedule once deployed. In the editor you trigger them manually to test.

## Programming model

A job is an ordinary script — there's no required entrypoint, decorator, or `main()`. Write the body; it runs on each fire. You have the full stage SDK:

```python
import os
from abstra.logging import logger
import abstra.tables as at
from abstra.connectors import run_connection_action
from abstra.tasks import get_tasks, send_task     # jobs CAN emit tasks and drain pending ones
from abstra.messages import send_email
from abstra.common import get_persistent_dir, get_execution_id
```

Note: `get_trigger_task()` does **not** work in a job (jobs aren't task-triggered). But a job *can* `send_task(...)` to fan work out, and `get_tasks()` / `iter_tasks()` to **drain** tasks that piled up at the stage — the classic "process the queue every N minutes" pattern.

## Patterns

- **Scheduled ETL / sync.** Pull from a connector or API, upsert into Abstra Tables (`at.insert` / `at.update` / `at.run_sql`). Make it **idempotent** — the same tick may run again, and yesterday's rows may already exist. Upsert on a natural key; don't blind-insert.
- **Polling + fan-out.** Check a source (SFTP, mailbox, API) for new items; for each new one, `send_task("item", {...})` so a tasklet processes it. Track "last seen" (a table row or `get_persistent_dir()` file) so you don't reprocess.
- **Drain a queue on a cadence.** `for task in get_tasks(): with task: ...` — batch up pending tasks and process them on schedule.
- **Reports / digests.** Query tables, render, `send_email(..., is_html=True, attachments=[...])`.
- **Cleanup / reconciliation.** Delete/expire stale rows; compare external state vs internal tables and emit discrepancy tasks.

## Reliability

- There is **no built-in retry for a job run** — if it raises, it simply fails and waits for the next scheduled tick. (Retry/idempotency in Abstra lives in the **task** lifecycle, not the job runner.) So: wrap work in `try/except`, `logger.exception(...)` on failure, and alert (email/Slack) if a failure matters. For anything that must be retried promptly, `send_task` it to a tasklet, which auto-retries.
- **Assume overlap is possible** (a slow run may still be going when the next fires). Guard shared work with a lock row or idempotent upserts.
- **Long work**: prefer fanning out to tasklets over doing thousands of items in one job run — tasklets parallelize and retry per item.

## Inside a job you also have

`os.environ.get(...)` (secrets ≥10 chars masked in prod logs) · `abstra.logging.logger` · `abstra.tables` · `abstra.connectors.run_connection_action` · `abstra.messages.send_email` · `abstra.common.get_persistent_dir`/`get_execution_id` · `abstra.ai.run_agent` (e.g. nightly autonomous tasks).

## Footguns

- **Expecting a job to run in the editor.** It won't fire on schedule there — trigger manually to test.
- **Non-idempotent writes.** Re-runs and overlaps duplicate rows unless you upsert on a key.
- **Reprocessing the same source data.** Track a watermark (last id/timestamp) in a table or persistent file.
- **Swallowing failures silently.** A failed job just waits for the next tick — log and alert, or hand off to a tasklet that retries.
- **Doing everything in one giant run.** Fan out to tasklets for per-item retry and parallelism.

## Use-case map

| Use case | Shape |
|---|---|
| Nightly/hourly ETL sync | `run_connection_action` → `at.run_sql`/`at.insert` (upsert) |
| Poll source for new items | check source → per new item `send_task(...)` + advance watermark |
| Drain pending tasks on a clock | `for task in get_tasks(): with task: ...` |
| Daily report / digest email | `at.select`/`run_sql` → render → `send_email(is_html=True)` |
| Cleanup / archival | `at.run_sql("DELETE …")` / `delete_by_id` |
| Reconciliation | compare external vs table → `send_task` discrepancies |
| Scheduled reminders/alerts | `run_connection_action` (Slack/WhatsApp) or `send_email` |

See `jobs_examples.md` for complete, commented job files.
