Automations
Automations are ordered workflows for ingest, transforms, alerts, and agent tasks.
For a scheduled monitor that runs a SQL probe, asks an LLM whether to act, and then acts — see Autonomous Agents. An automation can make a one-shot LLM judgment too (the
llm_decisionstep plus awhenguard), but an agent additionally remembers across runs, has a cooldown, and feeds its run history back to the model.
V1 shape:
pipeline definition -> queued run -> ordered run steps -> logs + outputs
The API stores definitions and run history in Postgres. The job-runner
Deployment polls Postgres, claims queued runs, and executes each run's steps
in order. Runs execute concurrently — up to jobRunner.maxConcurrentRuns
(default 3) at a time per runner pod — but never two runs of the same
pipeline at once, so watermarks and sync state stay serial per pipeline.
Pipeline Definition
{
"name": "Flight count alert",
"description": "Query the lakehouse and notify Slack.",
"definition": {
"steps": [
{
"id": "summarize",
"type": "sql",
"config": {
"sql": "SELECT COUNT(*) AS flights FROM flights"
}
},
{
"id": "notify",
"type": "slack_webhook",
"config": {
"integration": "slack-mjr-onprem",
"text": "Automation finished: {{ run.id }}. Rows: {{ steps.summarize.rows_json }}"
}
}
]
}
}
Besides steps, a definition may set "lane": "backfill" to mark the
pipeline's runs as long full-history syncs — see
Concurrency, lanes, and cancelling runs.
The default lane is default.
At the pipeline level, run_after_pipeline_id may name one upstream pipeline.
Each successful upstream run then enqueues this pipeline; see
Run-after dependencies.
Step Types
Full set: sql, python, slack_webhook,
send_email, email_report,
pg_sync, adbc_sync, mssql_sync,
assert_unique, agent,
llm_decision, maintenance, and
data_app_export.
Three of these deliver something to a person, and picking the wrong one is the usual mistake:
| Want | Step |
|---|---|
| A data app emailed as a PDF or PNG attachment | email_report |
| An email whose body you write (digest, alert, threshold breach) | send_email |
| A message in a Slack channel | slack_webhook |
| A query's rows as a CSV in object storage | data_app_export |
All of them authenticate themselves from the pipeline. None of them takes an API token, and you never need to create one to schedule a report.
sql
Runs SQL against the lakehouse.
{
"id": "transform_orders",
"type": "sql",
"config": {
"sql": "CREATE OR REPLACE TABLE marts.orders AS SELECT * FROM raw.orders",
"max_output_rows": 100
}
}
python
Runs a Python script inside the job-runner container, or in a burst sandbox
when config.compute_profile names a non-default profile. The script receives
AUTOMATION_RUN_ID, AUTOMATION_STEP_ID, and AUTOMATION_CONTEXT_JSON plus
anything declared on config.env.
Set config.attach_lake: true when the script needs
definite_lakehouse.query(), write_table(), or direct DuckDB/DuckLake
access. For compatibility with definitions created before this flag existed,
scripts that import or mention definite_lakehouse are also treated as
attached when the field is omitted. Set attach_lake: false to disable that
inference.
The script body is specified in exactly one of three ways:
script_id— reference a stored script by its hex id.script_name— reference a stored script by its unique name.script— inline source, useful for one-liners or ad-hoc steps.
Prefer script_id / script_name: scripts are managed via the
/api/v1/automation-scripts endpoint and edited in the frontend with a
Monaco editor, so non-trivial Python doesn't have to be JSON-escaped into the
pipeline definition.
Every python step runs via uv run --python 3.12 --no-project in an
ephemeral venv. When the step (or stored script) declares requirements,
the executor writes them to a requirements.txt and adds
--with-requirements <path>, so the listed packages are installed for that
run only. Resolved wheels are cached on the job-runner's uv cache volume
(see helm/values.yaml: jobRunner.uvCache), so the second run with the
same deps starts in seconds.
Baked-in libs: pyarrow, duckdb, polars, httpx, requests, psycopg2.
For pandas, scikit-learn, or any other lib, add it to your step's or stored
script's requirements and uv will install it per-run.
Stored script (recommended)
{
"id": "load_api",
"type": "python",
"config": { "script_name": "load_hubspot" }
}
The job-runner resolves the reference, runs the latest content, and
installs the row's requirements list before execution. The step output
includes script_id, script_name, script_version, script_requirements,
resolved_requirements, and uv_command for traceability.
Inline script (escape hatch)
{
"id": "load_api",
"type": "python",
"config": {
"timeout_seconds": 900,
"env": {
"SOURCE_NAME": "hubspot"
},
"requirements": ["httpx>=0.27", "polars>=0.20"],
"script": "import os, httpx\nprint('load', os.environ['SOURCE_NAME'])"
}
}
config.requirements is optional. Each entry must be a non-empty string;
the format is whatever uv pip install accepts (PEP 508 specifiers, VCS
URLs, etc.). It's only valid on inline scripts — stored scripts carry their
own requirements column.
Automation scripts API
GET /api/v1/automation-scripts/ # list (no body content)
POST /api/v1/automation-scripts/ # create
GET /api/v1/automation-scripts/{id_or_name} # full record, including content
PATCH /api/v1/automation-scripts/{id_or_name} # update content/description/requirements; bumps version
DELETE /api/v1/automation-scripts/{id_or_name} # 409 if any pipeline references it
POST /api/v1/automation-scripts/{id_or_name}/fork # copy to a custom script, optionally repointing pipelines
GET /api/v1/automation-scripts/{id_or_name}/versions # placeholder for v1.5
GET /api/v1/connector-catalog/{connector_id}/loader # current shipped core connector loader
Create payload:
{
"name": "hubspot_loader",
"description": "Pull contacts from HubSpot into raw.hubspot_contacts.",
"content": "import os\nprint('loading', os.environ['SOURCE_NAME'])\n",
"requirements": ["requests==2.32.0"]
}
version bumps on every PATCH. The integer is included in each step's
output payload so a run's logs can be traced back to the exact script body
that executed. Names are unique — pick something stable, since pipeline
definitions reference scripts by name (or id), and renaming a referenced
script will break pipelines that use the old name.
Core connector scripts
Connector-catalog pipelines reference a deployment-managed script named
connector_<id>_loader by default. On API startup, the shipped connector
catalog is reconciled into automation_scripts rows with
origin: "core_connector", managed: true, connector_id, and an
upstream_hash. These rows are the blessed connector loaders for the
deployment and receive updates when a new API image ships a changed catalog.
Managed core connector scripts are read-only: PATCH and DELETE return
409 with guidance to fork first. Use
POST /api/v1/automation-scripts/{id_or_name}/fork to create a custom copy.
The fork keeps the connector metadata, records forked_from_hash, clears
managed, and can take pipeline_ids to repoint those pipeline steps from
the core script to the forked script name in the same transaction.
After a fork, upstream updates no longer overwrite the custom script. Use
GET /api/v1/connector-catalog/{connector_id}/loader to fetch the current
blessed loader when comparing or manually merging upstream changes.
Runtime requirements
The job-runner pod needs outbound network access to pypi.org (and
files.pythonhosted.org) to install declared dependencies on the first
run with a given dep set. The uvCache PVC keeps subsequent runs offline
for the same versions. For air-gapped deployments, either point uv at a
private index via UV_INDEX_URL (set in jobRunner.extraEnv) or omit
requirements and bake the packages into a custom job-runner image.
Environment contract
The python subprocess receives an explicitly-constructed environment. Nothing else from the runner pod's environment leaks in. Scripts can rely on:
| Variable | Source | Purpose |
|---|---|---|
AUTOMATION_RUN_ID | runner | The current run's id. |
AUTOMATION_STEP_ID | runner | The current step's id. |
AUTOMATION_CONTEXT_JSON | runner | JSON-encoded prior step outputs ({"run": {...}, "steps": {...}}). Bounded to 64 KiB: env strings are kernel-capped at 128 KiB and a spawn past that fails with E2BIG. The run's definition snapshot is excluded (a multi-object connector definition alone can exceed the cap), each prior step's stdout/stderr keeps only its last 2,000 chars, and if the payload is still over, that free text is dropped entirely oldest-step-first. A step whose output was shortened carries "context_truncated": true; the full output stays on the run's step records and in template rendering. |
LAKEHOUSE_CATALOG_DSN | get_settings() when attach_lake is true | libpq DSN for the ducklake_catalog Postgres database; definite_lakehouse attaches ducklake:postgres:<DSN> with it. |
LAKEHOUSE_LAKE_ALIAS | get_settings() when attach_lake is true | Defaults to LAKE. |
LAKEHOUSE_QUERY_TIMEOUT_SECONDS | get_settings() when attach_lake is true | Query timeout used by the SDK. |
LAKEHOUSE_DATA_PATH | get_settings() when attach_lake is true | Object-store data path the DuckLake attach reads parquet from over httpfs. |
LAKEHOUSE_STORE_TYPE | get_settings() when attach_lake is true | Active store backend (gcs, s3, minio, azure). |
DEFINITE_INTEGRATIONS_JSON | runner (optional) | JSON object of the integrations the step declared in config.integrations, decrypted. Read via get_integration(); only set when the step declares integrations. |
DEFINITE_STEP_KV_API_URL, DEFINITE_STEP_KV_TOKEN | runner | Internal, short-lived capability used by definite_lakehouse KV helpers. The token is scoped to KV calls and revoked when the step exits; scripts should use the SDK rather than read these directly. |
LAKEHOUSE_STAGING_URI | runner when attach_lake is true | Bucket prefix write_parquet_to_lake stages to. Defaults to a _staging/ subpath of the lake's data path; override with jobRunner.staging.uri. |
| Active store credentials | runner when attach_lake is true | GCS_HMAC_*, S3_*, or AZURE_STORAGE_*, depending on lakehouse.storeType. |
PATH, HOME, LANG, LC_ALL | runner | Minimal shell bootstrap so uv works. |
UV_* | runner | Forwarded so UV_CACHE_DIR / UV_INDEX_URL flow through. |
config.env.* | step config | Anything declared on the step. |
Anything else (e.g. AWS_SECRET_ACCESS_KEY or arbitrary operator-set
vars on the runner pod) does NOT propagate. Put values you want
available in either config.env for the step or jobRunner.extraEnv
with a recognized prefix (today: UV_*). For sandboxed compute-profile
steps, lake and store keys from config.env are still dropped unless
attach_lake resolves to true.
Writing to the lakehouse
Every python step has definite_lakehouse preinstalled (the wheel is
baked into the job-runner and fi-sandbox images and passed via
uv run --with). The same wheel also provides connector_streaming — the
connector-building layer the catalog loaders use. Custom sync scripts can
import it instead of hand-rolling batching, checkpoint cursors, or OAuth
token refresh:
from connector_streaming import write_stream, read_checkpoint
from connector_streaming import OAuthTokenProvider, request_with_retries
from definite_lakehouse import retry_commit_conflicts
write_stream writes a row iterable in bounded batches with commit-conflict
retry; read_checkpoint/commit_checkpoint persist sync cursors in the
queryable __definite_internal.connector_checkpoints table. Use the
checkpoint helpers for sync cursors you want to inspect with SQL; use
kv_get/kv_set (below) for small opaque state such as rotated tokens.
The common shape is to opt into lake access and then import the SDK:
{
"id": "load_lake",
"type": "python",
"config": {
"attach_lake": true,
"script_name": "load_lake"
}
}
import polars as pl
from definite_lakehouse import write_table, query
df = pl.DataFrame({"id": [1, 2, 3], "v": ["a", "b", "c"]})
write_table(df, "main.hello", mode="replace") # or mode="append"
count = query("SELECT count(*) FROM main.hello").to_pylist()
print(count)
write_table accepts polars / pyarrow / pandas inputs (duck-typed —
pandas is supported as input but isn't shipped in the image; declare it
on config.requirements if your script also imports it directly). It
inlines rows as a VALUES literal up to a 50k-row cap.
Using a stored integration — config.integrations
A python step can use a stored integration's credentials without
fetching them over the API. Declare the integrations the step needs by
name in config.integrations:
{
"id": "ingest",
"type": "python",
"config": {
"script_name": "hubspot-contacts-ingest",
"attach_lake": true,
"integrations": ["hubspot"]
}
}
config.integrations is a list of integration names; it is only valid
on python steps. At run time the job-runner resolves each declared
integration in-process (decrypted, audited as an automation_run
secret access — the same path pg_sync uses) and injects them into the
subprocess as DEFINITE_INTEGRATIONS_JSON. The script reads them with
get_integration():
from definite_lakehouse import get_integration, write_table
hs = get_integration("hubspot")
api_key = hs["secrets"]["api_key"] # decrypted secret half
portal = hs["public"]["portal_id"] # non-secret config half
# ... call the HubSpot API, build an arrow table ...
write_table(tbl, "main.hubspot_contacts", mode="replace")
get_integration(name) returns {"name", "type", "public", "secrets"}.
It raises a clear error if the step did not declare that integration in
config.integrations. This replaces the old anti-pattern of passing an
admin token through config.env and calling
GET /api/v1/integrations/{id}/reveal from the script.
Durable step state — KV
Every Python step can persist small JSON values in the application Postgres without putting security-sensitive state in a queryable lakehouse table. Values are encrypted at rest with the deployment's rotatable application key. The runner gives each executing step a short-lived capability that authorizes only KV calls and revokes it when the step exits; no user API token or Postgres credential is exposed to the script.
For new code, read and write individual keys directly:
from definite_lakehouse import kv_get, kv_set
refresh_token = kv_get("xero-sync", "refresh_token")
# ... refresh OAuth credentials ...
kv_set("xero-sync", "refresh_token", rotated_refresh_token)
Values may be strings, numbers, booleans, null, lists, or objects, up to 64 KiB serialized. Namespaces are deployment-wide, so choose a stable, pipeline-specific name. Writes are last-write-wins; normal automation runs are already serialized per pipeline, so one namespace should have one pipeline as its writer.
Cloud SDK ports can keep the familiar buffered store pattern with a one-line construction change:
from definite_lakehouse import get_kv_store
state = get_kv_store("xero-sync")
token = state.get("refresh_token")
state["refresh_token"] = rotated_refresh_token
state.commit()
The compatibility store buffers assignments until commit(). It does not
provide cloud store-wide optimistic versioning; each key is persisted with
last-write-wins semantics.
Large writes — write_parquet_to_lake
For writes above the inline cap the SDK stages parquet to the object
store and drives one CREATE … AS SELECT * FROM read_parquet('<uri>')
on the lakehouse. This happens transparently — write_table auto-routes
any call larger than 50k rows. Staging works out of the box: when
jobRunner.staging.uri is left unset the chart derives a default
(<lakehouse.dataPath>/_staging/), so it lives in the same bucket the
lake already uses and the existing object-store credentials cover it.
Set jobRunner.staging.uri explicitly to stage somewhere else.
You can also call the helper directly:
from definite_lakehouse import write_parquet_to_lake
write_parquet_to_lake(
df,
"main.big_table",
mode="replace", # or "append"
# staging_prefix="gs://acme-lake/staging/", # overrides LAKEHOUSE_STAGING_URI
# cleanup=True,
)
Required env (Helm wires these from the lake's object-store config):
LAKEHOUSE_STAGING_URI: a URI on the lake's own object store, matchinglakehouse.storeType.gs://bucket/prefix/for GCS,s3://bucket/prefix/for S3/MinIO, oraz://account.blob.core.windows.net/container/prefix/(or legacyazure://…) for Azure Blob. Defaults to<lakehouse.dataPath>/_staging/; override withjobRunner.staging.uri.- The lake's object-store credentials, the same ones the embedded DuckLake
connection uses:
GCS_HMAC_*for GCS,S3_*or IRSA for S3/MinIO,AZURE_STORAGE_*for Azure. The chart mounts them perlakehouse.storeType.
The parquet upload goes through DuckDB's native object-store client, using the
same store_creds secret and signing path the lake reads parquet with, so it
works on every store the lake supports, not just GCS. Staging files are not
cleaned up inline (DuckDB's httpfs/azure extensions don't expose an object-store
delete), so configure a lifecycle rule on the staging prefix (e.g. 1-day
expiration) to reap files after the lake has loaded them.
slack_webhook
Posts a message to Slack.
{
"id": "notify",
"type": "slack_webhook",
"config": {
"integration": "slack-mjr-onprem",
"text": "Finished run {{ run.id }}. Rows: {{ steps.summarize.rows_json }}"
}
}
integration, integration_name, and integration_id all resolve a stored
slack_webhook integration. For local/debug use, webhook_url_env and
webhook_url are still supported, but stored integrations are preferred so
secrets stay encrypted in Postgres.
Template variables in text
config.text is rendered as a Jinja2 template against the run context, so any
expression you can write in Jinja works:
| Reference | Resolves to |
|---|---|
{{ run.id }} | the run's UUID |
{{ steps.<id>.rows }} | the step's rows (a list of dicts) |
{{ steps.<id>.rows[0].my_col }} | a single value from the first row |
{{ steps.<id>.row_count }} | how many rows the step returned |
{{ steps.<id>.rows_json }} | the rows array, JSON-encoded as a single string |
{{ steps.<id>.output_json }} | the full step output, JSON-encoded |
{{ steps.<id>.stdout }} | stdout from a python step |
Standard Jinja filters (| tojson, | format, | upper, …) and control
structures ({% if %}, {% for %}) are available. Unknown variables fail the
step at render time rather than silently leak {{ … }} into the message —
fix the typo and re-run.
The renderer uses Jinja2's SandboxedEnvironment (attribute access on
private (_…) and dunder (__…) names is blocked), so step text is safe to
evaluate even when authored by less-trusted users.
When to reach for a python step instead
Jinja in config.text is for pulling one or two values out of a prior step's
first row. Anything beyond that belongs in a python step that
queries the lakehouse and posts to Slack itself.
Signals that you're past what text should do:
- The message body branches (
{% if %}deciding whether to alert, or which channel to hit). - You're formatting a multi-row table, looping over rows, or accumulating state across them.
- You need values from more than one integration in the same message.
- You want to call an LLM, retry on failure, or rate-limit yourself.
- The SQL needs a follow-up call (enrichment, lookup, second query) before the message is built.
- You catch yourself reaching for
| tojsonand then parsing it back in your head to figure out what the message will look like.
In a python step you have the lakehouse, the integration's secrets, and the
full standard library; there's no template language to fight.
The same alert, both ways
A "Stripe revenue dropped below $100k" alert as a SQL probe plus a templated
slack_webhook. The probe's HAVING clause returns a row only when the
total is below the threshold, so the notify step can guard on
rows_nonempty:
{
"steps": [
{
"id": "stripe_total",
"type": "sql",
"config": {
"sql": "SELECT SUM(amount) / 100.0 AS total FROM stripe.charges WHERE status = 'succeeded' HAVING SUM(amount) / 100.0 < 100000"
}
},
{
"id": "notify",
"type": "slack_webhook",
"when": { "step": "stripe_total", "expr": "rows_nonempty" },
"config": {
"integration": "slack-mjr-onprem",
"text": "Stripe revenue is ${{ '%.2f' | format(steps.stripe_total.rows[0].total) }}, below the $100k threshold."
}
}
]
}
That works, and for a single-value threshold it's the right tool. The moment
the alert needs to also list the top failing customers, hit a second
integration, or skip if you already pinged today, collapse it into one
python step:
{
"steps": [
{
"id": "stripe_alert",
"type": "python",
"config": {
"attach_lake": true,
"integrations": ["slack-mjr-onprem"],
"script": "import requests\nfrom definite_lakehouse import query, get_integration\n\nTHRESHOLD = 100_000\nrows = query(\"SELECT SUM(amount) / 100.0 AS total FROM stripe.charges WHERE status = 'succeeded'\").to_pylist()\ntotal = rows[0]['total'] or 0\nif total >= THRESHOLD:\n print(f'ok: ${total:,.2f}')\nelse:\n slack = get_integration('slack-mjr-onprem')\n url = slack['secrets']['webhook_url']\n requests.post(url, json={'text': f'Stripe revenue is ${total:,.2f}, below the ${THRESHOLD:,} threshold.'}, timeout=10).raise_for_status()\n print(f'alerted: ${total:,.2f}')\n"
}
}
]
}
definite_lakehouse is preinstalled, requests is baked into the image, and
config.integrations decrypts the Slack webhook in-process — no template, no
when clause, no second step to keep in sync. See python for the
full environment contract and the stored-script workflow.
For a worked recipe that applies these pieces to data-freshness monitoring (alert when a table stops updating), see Staleness Checks.
send_email
Sends a plain templated email through the deployment's configured email
service (EMAIL_MODE=smtp or definite_cloud; see the email configuration). Unlike the email_report step — which
renders a data app to a PDF/PNG attachment — send_email just sends a message
body, so it's the step to use for digests, alerts, and any pipeline that used to
call the cloud SDK's send_email_message().
{
"id": "notify",
"type": "send_email",
"config": {
"to": ["alerts@example.com"],
"subject": "Daily revenue: ${{ steps.total.rows[0].revenue }}",
"html_body": "<p>Revenue today was <b>${{ steps.total.rows[0].revenue }}</b>.</p>",
"text_body": "Revenue today was ${{ steps.total.rows[0].revenue }}."
}
}
| Field | Required | Notes |
|---|---|---|
to | yes | List of recipient addresses, 1 to 10. |
subject | yes | Rendered as a Jinja2 template (see below). |
html_body | one of the two | HTML body, rendered as a Jinja2 template. |
text_body | one of the two | Plain-text body, rendered as a Jinja2 template. |
At least one of html_body / text_body is required; providing both sends a
multipart message so clients that can't render HTML fall back to the text part.
subject, html_body, and text_body are rendered with the same Jinja2
context and SandboxedEnvironment as slack_webhook,
so {{ steps.<id>.rows[0].my_col }}, {{ run.id }}, standard filters, and
{% if %} / {% for %} all work, and an unknown variable fails the step at
render time. If the deployment's email service is disabled or unconfigured, the
step fails with a clear "requires configured email delivery" error rather than
silently dropping the message.
To send an email from outside a pipeline (a script, an ad-hoc job, a migrated
cloud service), use the POST /api/v1/email/send HTTP route
instead — it delivers through the same email service.
email_report
Renders an existing data app in a headless browser and emails it as a PDF or PNG attachment. This is the step for "email me this dashboard every month": one step, no SQL, no scripting, and no API token.
{
"id": "mail",
"type": "email_report",
"config": {
"to": ["exec-team@example.com"],
"app_slug": "second-look-trend",
"subject": "Second Look dealer mix — {{ run.started_at }}",
"format": "pdf",
"pdf_orientation": "landscape",
"viewport": { "width": 1440, "height": 1000 }
}
}
| Field | Required | Notes |
|---|---|---|
to | yes | List of recipient addresses, 1 to 10. |
app_slug | yes | Slug of the data app to render, as it appears in /apps/<slug>. |
subject | yes | Rendered as a Jinja2 template, same context as send_email. |
format | no | pdf (default) or png. |
body | no | HTML message body. Templated. Defaults to a one-line "Attached is your Definite report." |
pdf_orientation | no | portrait or landscape. Only swaps the page dimensions; it never scales the app. |
viewport | no | {"width": ..., "height": ...}, each 320 to 4000. Defaults to 1440x1000. |
Authentication is automatic, and this is the point of the step. The job-runner mints a short-lived render token for the pipeline's owner (the user who created the pipeline) and loads the app as them. Recipients get a flat attachment, not a link, so they need no Definite account. There is nothing to paste, store, or rotate — if a workflow is asking anyone for an API token in order to email a report, it has taken a wrong turn.
Because the render runs as the pipeline owner, the report shows exactly what that person can see. A pipeline owned by someone without access to the app's underlying tables produces an empty or partial report, so create the pipeline as a user who can open the app.
Prerequisites:
- The deployment has email delivery configured (
EMAIL_MODE=smtpordefinite_cloud). Otherwise the step fails with "email_report step requires configured email delivery". This is an admin setting; see email configuration. - The data app already exists and renders. Build it first, open it once, then schedule it.
- The job-runner can reach the frontend. It uses
EMAIL_REPORT_BASE_URL,FRONTEND_INTERNAL_BASE_URL, orPUBLIC_BASE_URL, in that order.
The renderer waits for the app's charts to finish loading before it captures
the page, up to EMAIL_REPORT_READY_TIMEOUT_MS (default 120000). An app whose
queries never resolve fails the step with "email_report render timed out"
rather than mailing a picture of a loading spinner. If reports start timing
out, the app itself is slow — profile its queries rather than raising the
timeout.
Widening viewport.width fits more of a dense dashboard on the page; it does
not re-flow the app to a narrower layout. For a tall dashboard prefer
format: "png", which captures the full scroll height, over a PDF that has to
pick one page size.
Recipe: email a data app every month
The whole pipeline, ready to POST /api/v1/automations/pipelines. 0 13 1 * *
is 13:00 on the 1st of each month, in cron_timezone.
{
"name": "Monthly Second Look report",
"description": "PDF of the Second Look dealer-mix app, 1st of the month.",
"enabled": true,
"cron_schedule": "0 13 1 * *",
"cron_timezone": "America/New_York",
"definition": {
"steps": [
{
"id": "mail",
"type": "email_report",
"config": {
"to": ["exec-team@example.com"],
"app_slug": "second-look-trend",
"subject": "Second Look dealer mix — monthly",
"format": "pdf",
"pdf_orientation": "landscape"
}
}
]
}
}
Weekly instead is 0 13 * * 1 (Mondays at 13:00). Use a real timezone rather
than UTC so the send does not drift an hour across daylight saving.
Before trusting the schedule, trigger it once by hand with
POST /api/v1/automations/pipelines/{id}/runs and open the attachment. That
one run confirms the three things a schedule cannot: email delivery is
configured, the pipeline owner can see the app, and the app renders.
If the report should reflect data that a sync or transform produces, do not
race them on two crons. Put the email_report step last in the same pipeline,
or point the report pipeline's run_after_pipeline_id at the pipeline that
refreshes the data — see Run-after dependencies.
pg_sync
Pulls a single table from a stored postgres integration into the lakehouse.
Runs in the job-runner's embedded DuckDB connection: it ATTACHes the source
Postgres via DuckDB's postgres extension and streams rows directly into
DuckLake (the Postgres-catalog lake).
{
"id": "pull_orders",
"type": "pg_sync",
"config": {
"integration": "pg-prod",
"source_table": "public.orders",
"destination_table": "main.orders",
"mode": "incremental",
"watermark_column": "updated_at",
"primary_key": ["id"]
}
}
Modes:
full_refresh— drops and recreates the destination on every run. Simple, always correct, but rescans the entire source table each time.incremental— pulls rows wherewatermark_column > last_seen_value. Ifprimary_keyis provided, rows are upserted (DELETE-then-INSERT by key); otherwise they're appended. Watermarks are persisted inautomation_pg_sync_statekeyed by(integration_id, source_table, destination_table), so the next run resumes from the last successful sync. On the first run the watermark is null and the full table is loaded.
There is deliberately no separate "backfill" mode: a backfill is just the
first run of an incremental sync (empty watermark state → full history,
chunked and checkpoint-resumable), or a full_refresh. For a large initial
load, configure incremental from day one, give the step a non-default
compute_profile (it runs as a dedicated sync-executor Job — see
Sync-executor Jobs), and put the pipeline in
"lane": "backfill"; when the first run finishes, the watermark is already
seeded and the same pipeline is your steady-state sync.
Full-refresh resume (#710): the chunked, key-windowed full_refresh path
checkpoints each staged window in automation_pg_full_refresh_state. A run
killed mid-backfill (pod OOM, node loss, helm roll) resumes from the last
committed window on its next attempt instead of restarting — a crash costs at
most one window. The checkpoint is fingerprinted to the extraction
(source_sql, chunk key, chunk size); changing any of them starts clean. The
staging table still swaps in atomically at the end, so the destination is
never observed half-populated. The keyless (ctid-paged) fallback stays
non-resumable — ctid order isn't stable across restarts.
Postgres type/customization controls:
column_casts— optional object mapping source column names to Postgres cast types.pg_syncalready casts Postgres enums and other source-defined types totext; use this for explicit choices like"column_casts": {"status": "text", "payload": "jsonb"}.source_sql— optional PostgresSELECT/WITHquery to read instead ofSELECT * FROM source_table. Use it for casts, joins, computed columns, or source-side filters. It must be a single semicolon-free read query. For incremental syncs,watermark_columnandprimary_keymust refer to columns returned by this query.
Schema drift (applies to pg_sync, adbc_sync, and mssql_sync alike):
- A column added on the source is added to the destination automatically
before the next incremental load (
ALTER TABLE ... ADD COLUMN, logged, and reported in the step output ascolumns_added). Rows synced before the column existed hold NULL — run a one-offfull_refreshto backfill them. - A column dropped on the source stays on the destination; new rows hold NULL there. Synced data is never dropped automatically.
- A type change on an existing column is logged as a warning and left to
the insert's implicit cast; an incompatible change fails the run with the
column named. Switch the step to
full_refreshonce to rebuild.
Limitations:
- Deletes on the source are not detected. Use
full_refreshfor tables where rows can be hard-deleted. - The customer's Postgres must be reachable from the job-runner pod. Lock down with NetworkPolicy + a least-privilege read-only role.
- Credentials live in the stored
postgresintegration (encrypted at rest); the connection string is interpolated into the SQL the job-runner runs, so the job-runner pod sees them in plaintext at run time.
adbc_sync
Pulls a single table from a stored snowflake integration into the lakehouse.
The ADBC counterpart of pg_sync. There is no Snowflake DuckDB extension, so
unlike pg_sync (which streams directly through DuckDB's postgres extension)
this cannot stream in-engine: the job-runner opens an ADBC connection, fetches
the result as Apache Arrow, stages it as a parquet file to the lake's object
store, and reads it back into DuckLake with read_parquet() on its embedded
DuckDB connection.
{
"id": "pull_orders",
"type": "adbc_sync",
"config": {
"integration": "snowflake-prod",
"source_table": "ANALYTICS.ORDERS",
"destination_table": "main.orders",
"mode": "incremental",
"watermark_column": "UPDATED_AT",
"primary_key": ["ID"]
}
}
Modes:
full_refresh— replaces the destination on every run.incremental— pulls rows wherewatermark_column > last_seen_value. Ifprimary_keyis provided rows are upserted (DELETE-then-INSERT by key); otherwise they are appended. Watermarks are persisted inautomation_adbc_sync_statekeyed by(integration_id, source_table, destination_table). On the first run the watermark is null and the full table is loaded. An incremental run that finds no new rows is a no-op and leaves the watermark unchanged.
Customization:
source_sql— optional SnowflakeSELECTto read instead ofSELECT * FROM source_table. Use it for casts, joins, computed columns, or quoted/lower-case identifiers. For incremental syncswatermark_columnandprimary_keymust refer to columns it returns.
Limitations:
source_tableidentifiers are emitted verbatim and unquoted — Snowflake folds them to upper case. Usesource_sqlfor quoted or lower-case names.- The whole result set is buffered as one Arrow table in the job-runner.
Incremental deltas are small; a
full_refreshof a very large table is the one heavy case (range-chunking it is a planned follow-on). - Deletes on the source are not detected — use
full_refreshfor tables where rows can be hard-deleted. - Requires object-store staging:
LAKEHOUSE_STAGING_URImust be set (Helm sets it whenjobRunner.staging.uri/ the lake data path is configured). Staged parquet files are reaped by the object store's lifecycle rule. - Credentials live in the stored
snowflakeintegration (encrypted at rest). Password and key-pair (JWT) auth are both supported; key-pair is preferred for service accounts.
mssql_sync
Pulls a single table from a stored sqlserver integration into the lakehouse.
SQL Server has no ADBC driver and no built-in DuckDB extension, so the
job-runner opens an in-process DuckDB connection, attaches the source through
the DuckDB mssql community extension, stages the result as parquet, and has
the lakehouse read it back.
{
"id": "pull_loans",
"type": "mssql_sync",
"config": {
"integration": "sqlserver-prod",
"source_table": "dbo.Loans",
"destination_table": "main.loans",
"mode": "incremental",
"watermark_column": "updated_at",
"primary_key": ["id"]
}
}
Modes:
full_refresh— replaces the destination on every run.incremental— pulls rows wherewatermark_column > last_seen_value. Ifprimary_keyis provided rows are upserted (DELETE-then-INSERT by key); otherwise they are appended. Watermarks are persisted inautomation_mssql_sync_statekeyed by(integration_id, source_table, destination_table).
Connection defaults:
- The
sqlserverintegration usesuse_encrypt=trueby default. - Set
trust_server_certificate=trueonly when the SQL Server presents a self-signed certificate or a certificate from an internal CA that the deployment does not trust. The test-connection path names this toggle when certificate validation fails.
Customization:
source_sql— optionalSELECTto read instead ofSELECT * FROM source_table. It should reference the attached source asmssql_src.<schema>.<table>. For incremental syncswatermark_columnandprimary_keymust refer to columns it returns.
Limitations:
- The whole result set is buffered as one Arrow table in the job-runner.
Incremental deltas are small; a
full_refreshof a very large table is the heavy case. Range-chunked full refresh is a planned follow-on. - Deletes on the source are not detected — use
full_refreshfor tables where rows can be hard-deleted. - Requires object-store staging:
LAKEHOUSE_STAGING_URImust be set (Helm sets it whenjobRunner.staging.uri/ the lake data path is configured). - Credentials live in the stored
sqlserverintegration (encrypted at rest).
assert_unique
Asserts that a lakehouse table holds at most one row per key. On a pass the step records the row/key counts and the run continues; on a violation the step (and the run) fails with the duplicate count and a sample of up to 10 offending keys, so a poisoned load is caught at write time instead of silently fanning out every downstream join.
{
"id": "verify_keys",
"type": "assert_unique",
"config": {
"table": "redshift_cleanid.transactiondata",
"key": "transactionid"
}
}
Config:
table(required) — the table to check, asschema.tableor a bare table name. Parts must be plain identifiers (letters, digits, underscores).key(required) — a single column name, or a list of column names for a composite key.
The check is one aggregate scan — count(*) vs
count(DISTINCT (<key columns>)) — so it stays cheap even on 100M+-row
tables. Only when the counts disagree does the step run a second
GROUP BY <keys> HAVING count(*) > 1 probe to capture the worst offenders
(key values plus their row counts) into the step error and logs. NULL key
values count toward count(*) but not count(DISTINCT ...), so NULL keys
also fail the assertion (a merge key must be non-null); when no key value
occurs twice the error says the violation is NULLs.
Step output on a pass:
{
"table": "redshift_cleanid.transactiondata",
"key": ["transactionid"],
"total_rows": 134300000,
"distinct_keys": 134300000,
"duplicate_rows": 0,
"passed": true
}
The backfill invariant
The incremental syncs write merge-by-pk (DELETE incoming keys, then INSERT incoming rows), so a synced table holds one row per key going forward by construction — downstream models may rely on that instead of re-deduplicating. But bulk-import paths (replace+append staging loads, parquet manifest copies, chunked full-refresh backfills) bypass merge, and sources do not necessarily enforce primary-key uniqueness — Redshift, for one, does not enforce declared PKs at all. The merge maintains uniqueness for every key it pulls but cannot create it: a backfill that copies source-side duplicates poisons the table indefinitely, and no scheduled incremental run will ever repair it.
Every bulk-import or backfill must therefore end with an assert_unique
step (or a dedup-repair) on the merge key. Chaining the step after the
load is one cheap scan; scheduling it weekly on its own also works as
insurance for tables whose downstream models depend on the invariant.
Repairing a table that already has duplicates
When assert_unique fails on an already-loaded table, repair just the
duplicated keys — delete those keys, then reinsert exactly one surviving row
per key — instead of re-running the whole backfill:
-- 1. Capture the offending keys (the step's failure log holds a sample;
-- this captures all of them).
CREATE OR REPLACE TABLE main.dupe_keys AS
SELECT transactionid
FROM redshift_cleanid.transactiondata
GROUP BY transactionid
HAVING count(*) > 1;
-- 2. Stash one surviving row per duplicated key. Pick a deterministic
-- tiebreak (a watermark column, or re-select from the source when it
-- holds the authoritative row).
CREATE OR REPLACE TABLE main.dupe_repair AS
SELECT *
FROM redshift_cleanid.transactiondata
WHERE transactionid IN (SELECT transactionid FROM main.dupe_keys)
QUALIFY row_number() OVER (
PARTITION BY transactionid ORDER BY updated_at DESC
) = 1;
-- 3. Delete every row for those keys, then reinsert the deduped survivors.
DELETE FROM redshift_cleanid.transactiondata
WHERE transactionid IN (SELECT transactionid FROM main.dupe_keys);
INSERT INTO redshift_cleanid.transactiondata
SELECT * FROM main.dupe_repair;
DROP TABLE main.dupe_repair;
DROP TABLE main.dupe_keys;
Scoped to just the duplicated keys, the repair touches thousands of rows
instead of rewriting a 100M+-row table. Chain an assert_unique step after
the repair to prove it worked.
agent
Runs a Fi/Pi agent step when Fi is enabled.
{
"id": "agent_check",
"type": "agent",
"config": {
"prompt": "Inspect the mart tables and summarize anything unusual.",
"timeout_seconds": 1800
}
}
llm_decision
Makes one structured LLM call and returns the decision as the step output, so
later steps can guard on it with a when clause. It is
the building block for "run this SQL, ask an LLM whether it's worth alerting
on, and only then post to Slack."
{
"id": "judge",
"type": "llm_decision",
"config": {
"instructions": "Decide if the flight count in `summarize` is anomalous.",
"inputs": ["summarize"],
"decision_integration": "anthropic-prod",
"decision_model": "claude-sonnet-4-6"
}
}
Config:
instructions(required) — the system prompt; what the model should judge.inputs(optional) — a list of earlier step ids whose outputs are fed to the model. Omit it to pass every prior step's output.decision_integration(optional) — a storedanthropic/openaiintegration to call. Omitted, the step uses the deployment LLM (the samellm:block inconfig.yamlthat Fi uses) — no second credential needed.decision_model(required only whendecision_integrationis set) — an integration carries just an API key, so the model must be named alongside it. With no integration, the model falls back to the deploymentLLM_MODEL.
The step output is the decision dict:
{ "should_act": true, "reasoning": "...", "action_args": { "text": "..." }, "model": "..." }
A downstream step guards on it with
"when": { "step": "judge", "expr": "output_truthy:should_act" }.
Unlike an agent, an llm_decision step is single-shot and
memoryless — it sees no prior runs and keeps no durable state. Reach for an
agent when you need cross-run memory, a cooldown, or run history fed back to
the model.
maintenance
Runs one DuckLake maintenance operation against the lake.
{
"id": "compact_orders",
"type": "maintenance",
"config": { "operation": "compact", "schema": "analytics", "table": "orders" }
}
config.operation is one of compact, rewrite, flush_inlined_data,
checkpoint, expire_snapshots, cleanup_old_files,
delete_orphaned_files, vacuum_catalog, or full. Table-scoped operations
require schema and table together; compact accepts both or neither
(neither compacts the whole lake).
Most deployments should not hand-author these. The product keeps a reserved
ducklake-maintenance pipeline covering the whole schedule in one place — see
Managed maintenance schedule. Write an
explicit maintenance step only to attach one operation to a pipeline of your
own, such as compacting a table right after the sync that rewrote it.
data_app_export
Internal. The product enqueues this step itself when someone exports a data
app's data to CSV; it carries a pre-signed object_uri under
data-app-exports/<slug>/ plus row and byte caps, and the URI format is
validated on save.
Do not hand-author it, and do not reach for it to deliver a report — it writes
a CSV into object storage and emails nobody. To email a dashboard use
email_report; to email numbers in a message body run a
sql step and pass its output to send_email.
Step guards — when
Any step may carry an optional when clause. A step whose when evaluates
false is skipped — it is not run, not failed, and the run continues to
the next step. Steps still execute in their declared order; when only
short-circuits an individual step, it is not a jump target or a branch.
{
"id": "notify",
"type": "slack_webhook",
"when": { "step": "summarize", "expr": "rows_nonempty" },
"config": { "integration": "slack-ops", "text": "Found rows!" }
}
when.step must reference an earlier step's id. when.expr is a small,
closed vocabulary evaluated against that step's output — not an expression
language:
expr | true when |
|---|---|
rows_nonempty | the referenced step's output.row_count > 0 |
rows_empty | the referenced step's output.row_count == 0 |
succeeded | the referenced step ran and recorded an output |
output_truthy:<key> | the referenced step's output[<key>] is truthy |
A failed expr is reserved but unreachable today: the executor aborts the
whole run on a step failure, so no later step could ever observe a failed one.
It is accepted by the definition validator (so a definition written against a
future continue-on-error mode still validates) but a failed guard always
evaluates false in the current linear executor.
A guard that references a step which itself was skipped (and so produced no output) evaluates false — the guarded step is skipped in turn.
Step status
Each run step ends in one terminal status:
succeeded— the step ran and finished cleanly.failed— the step raised; the whole run is failed and stops here.skipped— the step'swhenguard evaluated false; the run continued.
Step timing
GET /api/v1/automations/runs/{run_id} returns each step row with its raw
timing columns plus a computed duration_ms. The contract:
started_atis the start of the step's last attempt. It is reset on every attempt, not just the first. A run whose job-runner died mid-step is automatically requeued up to the configured stale-recovery budget (see Runner death recovery below) and re-executes every step, so a re-run step's timestamps always bracket the attempt that actually produced its result — never the span across the requeue gap.attemptscounts the starts.attempts > 1means the step was re-executed by a requeued run.duration_ms=finished_at − started_at, i.e. the wall time of the last attempt. It isnullwhile the step is unfinished: stillrunning(compute live elapsed fromstarted_at), stillqueued, orskipped(awhen-skipped step never starts).- An absent
finished_aton a terminal run means the runner died mid-step and the run had exhausted its stale-recovery budget. The reaper stamps such stepsfailedwithfinished_atset at reap time, so their duration includes the ~3-minute detection window; until the reaper acts, arunningstep row under a dead runner is indistinguishable from live work except via the run's staleheartbeat_at.
Transformation runs additionally return model_durations — one record per
transformation_model (each compiled step is one model), sorted by duration
descending — so "which model is eating the run?" is one API call:
{
"model_durations": [
{ "model": "fct_transactions_all", "step_id": "model_fct_transactions_all",
"status": "succeeded", "duration_ms": 6222000, "attempts": 1 },
{ "model": "stg_payments", "step_id": "model_stg_payments",
"status": "succeeded", "duration_ms": 41000, "attempts": 1 }
]
}
CLI status JSON
definite run automation status <run_id> --format json returns the API run
payload inside the standard CLI envelope:
{
"ok": true,
"data": {
"run": { "id": "run-1", "status": "succeeded" },
"steps": [],
"logs": []
},
"meta": { "elapsed_ms": 12 }
}
The canonical run status path is data.run.status (jq:
.data.run.status). Polling code should read that nested path.
API
GET /api/v1/automations/pipelines
POST /api/v1/automations/pipelines
GET /api/v1/automations/pipelines/{pipeline_id}
PATCH /api/v1/automations/pipelines/{pipeline_id}
POST /api/v1/automations/pipelines/{pipeline_id}/runs
POST /api/v1/automations/pipelines/{pipeline_id}/trigger-schedule
POST /api/v1/automations/pipelines/{pipeline_id}/runs/{run_id}/cancel
GET /api/v1/automations/runs
GET /api/v1/automations/runs/{run_id}
POST /api/v1/automations/runs/{run_id}/cancel
All routes require the same bearer token auth as the rest of the API.
trigger-schedule requires admin.
Concurrency, lanes, and cancelling runs
The job-runner executes up to jobRunner.maxConcurrentRuns (Helm value,
default 3) runs at once on a thread pool; set it to 1 for the old strictly
serial behavior. Two guarantees hold regardless of the setting:
- One active run per pipeline. A queued run whose pipeline already has
a running or
awaiting_externalrun is skipped (later runs of other pipelines still claim), so per-pipeline sync state never races. This also holds across multiple job-runner replicas. - Backfills can't monopolize the pool. A pipeline definition (or a
single
POST .../runsbody) may set"lane": "backfill"to mark long full-history syncs. With two or more slots, at mostslots − 1backfill in-runner runs execute at once — one slot is always reserved for normal runs. Profile-bound durable Jobs hold zero runner slots, so their number does not reduce business-as-usual runner capacity at all.
Cancel one run — instead of every non-terminal run of a pipeline — with
either POST /api/v1/automations/runs/{run_id}/cancel (by run id) or the
pipeline-scoped POST /api/v1/automations/pipelines/{pipeline_id}/runs/{run_id}/cancel,
which 404s if the run doesn't belong to that pipeline. From the CLI:
definite run automation cancel <run_id>. A queued
run is never claimed; a running run stops between steps, and an in-flight
python step is killed mid-flight — the runner polls the run status every
few seconds and terminates the step subprocess (or tears down its burst
sandbox) on cancel. A sync step running as a sync-executor Job (below) is
also killed mid-flight: the runner deletes the Job, and the persisted chunk
checkpoint survives for the next run. The run finishes as cancelled, the
interrupted step is marked cancelled, and a log line records the
termination. Other in-flight step types (sql, in-pod pg_sync, …) finish
their current statement first and stop at the next between-steps checkpoint.
External compute-profile Jobs
A pg_sync / adbc_sync / mssql_sync step that requests a non-default
compute_profile runs as a dedicated Kubernetes batch/v1 Job sized by
that profile, instead of inside the job-runner pod or on a burst sandbox
(#710). This is the execution surface for full-history backfills and any
other long sync:
- Sized by the profile. The Job's pod carries the profile's
resources,node_selector, andtolerations— the same scheduling triple burst sandboxes use, typically landing on a scale-to-zero burst node pool. The pod's embedded DuckDB is bounded by the profile's memory limit (the chart's downward-API sizing follows the executor container). - No idle reaper, held RPC, or runner slot. The executor clones the job-runner's
container (image, env, secrets, scratch volume) and re-enters it as
python -m job_runner.sync_executor; progress flows through theautomation_sync_jobshandshake row and the run log, not a long-lived HTTP connection. After launch the automation entersawaiting_externaland releases its thread-pool slot. Sandbox idle reaping cannot kill it, and a 12-hour run consumes no job-runner execution capacity. - Restart-safe.
restartPolicy: OnFailurewith a boundedbackoffLimit(default 3) restarts a dead executor pod; the restarted attempt resumes from the persisted full-refresh checkpoint or incremental watermark.activeDeadlineSeconds(default 48 h) is the overall wall-clock cap. If the job-runner dies instead, the reaper requeues the run and the new the parked run and its deterministic Job remain intact; the new runner reconciles them without replaying the step. - Visible. The executor writes chunk progress into the normal run log
(
automation status <run_id>), heartbeats the handshake row, and pod failures surface with their real reason (e.g.OOMKilled→ raise the profile's memory). - Works without Fi. The surface needs only core Kubernetes RBAC (shipped with the chart) — unlike burst sandboxes it does not require the agent-sandbox controller. It also lifts the burst path's portability restriction: SSH-bastion / pasted-cert sources work, because the tunnel opens inside the executor pod itself.
Operator knobs live under jobRunner.syncJobs in values.yaml
(enabled, activeDeadlineSeconds, backoffLimit). With
enabled: false — or in environments without cluster credentials — sync
steps fall back to their previous behavior (in-pod, or pg_sync's burst
sandbox path).
Python steps with a non-default profile can use the same durable lifecycle by
setting jobRunner.pythonJobs.enabled: true. This is off by default for a
backwards-compatible rollout. The Job uses the profile's CPU, memory,
workspaceSize, node selector, and tolerations; arbitrary Python defaults to
backoffLimit: 0 so Kubernetes never silently replays a non-idempotent script.
Stored-script requirements are merged with step-level config.requirements,
with the step's distribution pin winning. Stdout/stderr lines stream into the
normal run log, and the final captured tails remain in step output. The user
subprocess receives the existing allowlisted step environment rather than the
executor pod's platform environment, so values such as POSTGRES_URL and
APP_ENCRYPTION_KEY are not inherited. Cancellation deletes the Job.
Runner death recovery. Claimed runs carry a liveness lease: the runner
heartbeats its in-flight runs every poll cycle, and a reaper (also ticked
every cycle, replica-safe) recovers running runs whose heartbeat has gone
stale for ~3 minutes — the signature of an OOM-killed pod, a lost node, or
a helm roll mid-run. Recovery requeues the run up to
jobRunner.staleRunMaxRequeues times (default 1; catalog connectors
checkpoint-resume, so an interrupted backfill picks up where it left off);
a run that keeps killing its runner fails terminally instead of
crash-looping. jobRunner.runLeaseSeconds controls the stale detection
window. The run log records each recovery.
Concurrent writes to the lake. DuckLake snapshot commits are global, so
parallel runs writing the lake can conflict even on disjoint tables. The
SDK raises DuckLake's internal retry budget (ducklake_max_retry_count = 100), the catalog connectors' shared checkpoint/write helpers retry commit
conflicts with exponential backoff, and the SDK's own write_table /
write_parquet_to_lake retry them too — including the race two concurrent
first-writes have on auto-creating the same new schema. Concurrent
connector runs and plain SDK writes are safe out of the box; only scripts
issuing hand-written DDL through query() need their own
TransactionException retry.
Scheduling
Pipelines can run on a cron schedule by setting cron_schedule (a standard
5-field expression) and optionally cron_timezone (IANA name, defaults to
UTC) at create or update time. The job-runner runs a scheduler tick every
~5s on a dedicated thread, isolated from run claiming and execution —
a multi-hour step occupying every run slot cannot delay a cron fire.
Overdue scheduled pipelines are enqueued with trigger_type = "scheduled"
(note: "scheduled", not "cron" — filter run history on that) and
next_fire_at is advanced to the next future tick. Every tick logs one
scheduler_tick: … last_ticked_at=… line with fired/skipped counts; that
line going quiet in the job-runner's logs means the scheduler itself is
wedged.
{
"name": "Refresh hourly",
"cron_schedule": "*/15 * * * *",
"cron_timezone": "UTC",
"definition": { "steps": [ /* ... */ ] }
}
Rules and limits:
- 5 fields only —
minute hour day month weekday. 6-field (seconds-precision) expressions are rejected; the runner polls every ~5s, so the minimum useful interval is once per minute. - Schedules that would fire more often than once per minute are rejected with a 400 at create/update.
- Invalid cron syntax or unknown IANA timezones return 400.
- Manual triggering still works via
POST /pipelines/{id}/runs. It does not interfere with the next scheduled fire. - No missed-run replay. If the job-runner is down and
next_fire_atfalls more than an hour behind, the next tick skips the backlog, logs a warning, and jumpsnext_fire_atto the next future tick. - Re-enabling a scheduled pipeline recomputes
next_fire_atfrom "now", even when the update carries no cron fields (clients that PATCH{"enabled": true}alone included) — a pipeline whosenext_fire_atwent stale while disabled fires again within one tick of being enabled, it never silently stays dead. - Multiple job-runner replicas are safe — the tick uses
FOR UPDATE SKIP LOCKED, so each scheduled fire enqueues exactly one run. POST /pipelines/{id}/trigger-schedule(admin-only) manually advancesnext_fire_atby one tick without enqueueing — useful for testing the schedule on a long-period cron.
Run-after dependencies
Set run_after_pipeline_id to run one pipeline after another succeeds. The
relationship is success-only, single-parent, and fan-out: pipeline B can name
pipeline A, and any number of other pipelines can also name A. Cron and manual
triggers remain available alongside it.
{
"name": "Build attributed events",
"run_after_pipeline_id": "<upstream-pipeline-id>",
"definition": { "steps": [ /* ... */ ] }
}
The API rejects missing or archived upstream pipelines and rejects direct or
transitive cycles at save time. On success, the upstream run and downstream
enqueue commit atomically. If the downstream pipeline already has a queued or
running run, the event is coalesced instead of adding another queued run. Runs
created this way have trigger_type = "upstream".
This chains whole linear pipelines; it does not add branching steps, loops, or multi-parent AND dependencies within a pipeline.
Managed maintenance schedule
A convenience endpoint for the single most common automation a lake needs:
a periodic DuckLake maintenance pass. It owns a single reserved pipeline
named ducklake-maintenance (one per deployment) so the entire schedule
can be inspected and replaced with one request, no per-step pipeline
authoring required.
GET /api/v1/lakehouse/maintenance/schedule # any user
PUT /api/v1/lakehouse/maintenance/schedule # admin only
GET returns { "schedule": null } if no schedule has been configured.
Otherwise it returns the current cron, timezone, enabled flag,
resolved next_fire_at, and the flattened steps list (one entry per
maintenance op).
Auto-provisioning. On API startup the lifespan calls
bootstrap_managed_maintenance_schedule: if no pipeline exists with the
reserved name (any state — enabled, archived, even an operator-created
collision) it inserts the default schedule (cron 0 2 * * 0 UTC, the
default step list below, enabled). After first boot the operator owns the
row: edit / disable / archive / delete are all respected — with one
exception: an untouched schedule from a recognized prior default generation is
upgraded in place on the next boot. This removes the former redundant leading
CHECKPOINT while preserving customized schedules. A default schedule whose
burst profiles were stripped (a pre-v0.1.121 PUT that omitted
compute_profile cleared them) is also re-stamped on the next boot; to keep
a deliberately cleared profile, change any step field so the schedule no
longer matches the default shape.
Any edited schedule no longer matches and is never touched. Wiping the
row and restarting is the only other way back to the default.
PUT body:
{
"cron": "0 3 * * *",
"timezone": "UTC",
"enabled": true,
"steps": [
{ "operation": "checkpoint" },
{ "operation": "compact", "schema": "main", "table": "orders" },
{ "operation": "expire_snapshots", "older_than_days": 30 },
{ "operation": "cleanup_old_files", "older_than_days": 30 }
],
"confirm_destructive": true,
"compute_profile": "lakehouse-large"
}
Behaviour:
- Upsert by reserved name. Replaces the existing
ducklake-maintenancepipeline if it exists, or creates it if not. An archived row is revived. - Collision guard. A pipeline that already exists with the reserved
name but was not written by this endpoint (i.e. its
descriptionlacks the[managed:maintenance-schedule]tag) returns409; rename or delete it before re-PUTting the schedule. - Cadence floor. Schedules that fire more often than once per day are
rejected — maintenance ops can scan the whole lake, so a sub-daily cadence
is almost always a misconfiguration. Run ad-hoc maintenance via
POST /lakehouse/maintenance/runsif a one-off shorter cycle is needed. The general per-pipeline 1-minute floor still applies to other pipelines. - Defaults. Omit
stepsentirely and the endpoint installs the canonical list:flush_inlined_data,expire_snapshots(older_than_days=30, dry_run=false), whole-lakecompact(merge_adjacent_files), whole-lakerewrite(delete_threshold=0.95),cleanup_old_files(older_than_days=30, dry_run=false),delete_orphaned_files(older_than_days=30, dry_run=false), andvacuum_catalog(a plainVACUUM (ANALYZE)of the catalog Postgres). These are the operations DuckLake bundles intoCHECKPOINT, expressed as individual steps so each has its own retry boundary, output, and settings. Destructive steps run live on purpose — a dry-run schedule previews-but-never-reclaims, which silently leaks storage. Theconfirm_destructiveacknowledgment applies only to an explicitstepslist that setsdry_run=falseon a destructive op; requesting the defaults doesn't re-prompt. - Burst profile.
compute_profileapplies to the schedule as a whole. A profile name routes each step to that burst tier, exceptvacuum_catalog— that step VACUUMs the catalog Postgres in-pod, and a burst sandbox has no Postgres credentials. If the field is absent,null, or empty, the stored profile stays: a cron-only PUT does not change burst routing. Only the explicit value"default"clears the profile, and all steps then run in the job-runner pod. The default schedule runs its heavy steps onlakehouse-large. - Step shape. Each
steps[i]is amaintenancestep config (operation+ the same per-op fields the ad-hoc/runsendpoint accepts:schema,table,older_than_days,delete_threshold,dry_run). The step is validated through the samevalidate_maintenance_configrule used by theAutomationStepmodel. enabled: falsekeeps the row + definition around but clearsnext_fire_atso the scheduler skips it. Re-enable by PUTting again withenabled: true.
The resulting pipeline is a regular automation_pipelines row — runs,
logs, and history all show up under the same /api/v1/automations/
endpoints. The convenience is in the auto-naming, upsert semantics, and
the maintenance-specific cron + confirmation rules.
UI. Settings → Maintenance ships a "Scheduled maintenance" card that
calls the endpoints above: cron + timezone fields, three preset chips
(daily 03:00 UTC, weekly Sun 02:00 UTC, monthly 1st 02:00 UTC), an
enabled checkbox, a read-only summary of the current step list, and the
human-readable cadence next to the cron input. Editing the step list (or
toggling dry_run=false on a destructive op) still requires the PUT
endpoint.
Current limits
- Steps execute in declared order; no jump targets, DAG, or fanout. A step
may be skipped by a
whenguard, but the order is fixed. Concurrency is per-run (see Concurrency, lanes, and cancelling runs); steps within one run are always sequential. - Python steps run in the shared job-runner pod unless they set a non-default
compute_profile; memory-heavy workloads should use a burst profile. Concurrent in-pod python steps share the pod's CPU/memory limits — sizeresources.jobRunner.resourceswithjobRunner.maxConcurrentRuns. - Mid-step cancellation covers
pythonsteps (in-pod and burst-sandbox). Other step types cancel at the next between-steps checkpoint.
Slack setup
Create a Kubernetes Secret in the deployment namespace and expose it to
job-runner via Helm values:
kubectl create secret generic definite-automation-secrets \
-n definite \
--from-literal=slack-webhook-url="$SLACK_WEBHOOK_URL"
jobRunner:
extraEnv:
- name: SLACK_WEBHOOK_URL
valueFrom:
secretKeyRef:
name: definite-automation-secrets
key: slack-webhook-url
Do not commit actual Slack webhook URLs.