Keep scheduled compositions cheap
Every time a composition runs, the platform records its progress: a state row for the run, for each layer and for each step that executes. That record is what gives you history, retries and Lumen visibility. It is also the cost, and it is paid per run, whether or not the run had anything to do.
A queue polled every minute is 1,440 runs a day. If the queue is empty most of the time, almost all of that is spent confirming that there is nothing to do.
| The same 6-row composition | Runs per day | State rows per day |
|---|---|---|
| Polled every minute | 1,440 | at least 8,640 |
| Every 15 minutes | 96 | at least 576 |
| Cron, weekdays at 08:00 | ~0.7 | ~4 |
| Change trigger, 20 new rows a day in 5 bursts | 5 | ~30 |
The rest of this page is how to get from the first row of that table to the last two.
1. Pick what starts the run
| The work starts when… | Use | Not |
|---|---|---|
| A row is added or changed in an App Data table (a queue, an inbox, a pending item) | A change trigger on that table: trigger.create | A 1-minute schedule that queries the table |
| A fixed time: "weekdays at 08:00", "the 1st of the month" | schedule.create with schedule_expression and timezone | A short interval plus a "is it 08:00 yet?" check |
| A periodic sweep you genuinely need | schedule.create at the slowest interval the product tolerates (hourly is usually enough) | Every 1 to 10 minutes |
| A provider calls you | A Hook redirect pointed at the composition, see Receive webhooks | Polling the provider |
| A user does something | Expose the composition and let the app start it | A schedule |
Schedules your end users create from your app (schedule.app.create) are created paused and fire at most every 15 minutes.
2. Fixed times: cron with a timezone
schedule.create takes either interval_minutes or schedule_expression, never both. The expression is five cron fields (minute hour day-of-month month day-of-week) read in timezone, an IANA name. Without timezone it is read in UTC.
Weekdays at 08:00 in São Paulo:
{
"target_workflow_type": "orn:orkestia:composition:<org>:lineage/<lineage_uuid>",
"name": "Morning briefing (weekdays 08:00)",
"schedule_expression": "0 8 * * 1-5",
"timezone": "America/Sao_Paulo"
}
| Expression | Fires |
|---|---|
0 8 * * 1-5 | 08:00, Monday to Friday |
30 18 * * * | 18:30 every day |
0 9 1 * * | 09:00 on the 1st of each month |
0 8-18 * * 1-5 | On the hour from 08:00 to 18:00, weekdays (11 runs instead of 1,440) |
0 */6 * * * | Every six hours |
- The answer includes
next_occurrences, the next instants (UTC) the schedule will really fire. Read it before you walk away. - Daylight saving time is followed. A local time the clock skips does not fire that day; a local time it repeats fires once.
- Point the schedule at the composition's lineage ORN (returned by
composition.saveasorn). Every fire then runs the active version, so the schedule follows your saves. A concretevirtual.<uuid>@<version>pins that one version until you re-point it. - To change an existing schedule,
schedule.updatetakes the sameschedule_expressionandtimezone.timezoneon its own re-reads the stored expression in the new zone.
control.time.now → compare the hour → stop if it is not time yet) and run it every few minutes. Every one of those runs is paid for, including all the ones that decide it is not time.3. Queues: a change trigger, not a poll
A change trigger starts your composition when rows of one App Data table are inserted or updated. An idle queue then costs no runs at all, and a burst of inserts becomes one run.
Step 1. Declare events: true on the table in your data structure and apply it (see Declare structures):
databases:
- slug: crm
tables:
- slug: signups
ownership: { kind: app }
events: true # or { ops: [insert] } to capture inserts only
fields:
- { slug: email, type: text, required: true }
- { slug: status, type: text }
- { slug: welcomed_at, type: timestamp }
Step 2. Create the trigger with trigger.create:
{
"identity_app_uuid": "<app uuid>",
"table_slug": "signups",
"target_workflow_type": "orn:orkestia:composition:<org>:lineage/<lineage_uuid>",
"run_as": { "kind": "staff_actor", "staff_actor_uuid": "<actor uuid>" },
"ops": ["insert"],
"where": { "status": "pending" },
"debounce_s": 5,
"max_batch": 500
}
| Field | What it does |
|---|---|
ops | insert and/or update (default both). Deletes are not supported. |
changed_fields | For updates: fire only when one of these fields changed. |
where | Filter on the row as it is now: {field: value}, {field: [a, b]}, {field: {"ne": v}}. All fields must match. |
run_as | Who the run acts as: a Staff actor in rbac_scoped mode. Never an admin, the organization or the system. |
debounce_s | Quiet seconds after the newest change before firing (default 5). |
max_batch | Most records per run (default 500). A backlog is drained in batches. |
sweep_interval_s | Optional safety net: also start the target with mode: "sweep" every N seconds (minimum 300). |
Step 3. Let the target composition receive what the trigger sends. Declare these inputs, all optional; inputs the composition does not declare are dropped:
{
"name": "welcome_new_signups",
"inputs": [
{ "name": "records", "type": "list" },
{ "name": "trigger_uuid", "type": "string" },
{ "name": "gap", "type": "boolean" },
{ "name": "mode", "type": "string" }
],
"layers": [
{
"name": "load",
"steps": [
{
"name": "todo",
"workflow_type": "appdata.query.run",
"condition": { "field": "records", "op": "truthy" },
"input_mapping": {
"identity_app_uuid": { "source": "static", "value": "<app uuid>" },
"statement": { "source": "static", "value": "SELECT record_uuid, email FROM signups WHERE record_uuid::text = ANY($1) AND welcomed_at IS NULL" },
"params": { "source": "list", "items": [{ "source": "input", "field": "records" }] }
}
}
]
}
]
}
records holds the ids of the matching rows, not their values. Your first layer re-reads exactly those rows, so it always sees current data.
What to know about delivery:
- At least once. A record can arrive twice. Make the work idempotent (the
welcomed_at IS NULLcheck above, auniquefield, or an idempotency key). - One run at a time per trigger. Changes that arrive while a run is in flight are delivered together in the next run.
- No self-loops. Writes your own run makes through workflow steps do not fire the same trigger again.
- It protects itself. A trigger that fires in a storm, or whose starts keep failing, pauses itself.
trigger.resumestarts it again; events that arrived meanwhile are kept and delivered. - Creating, updating and deleting a trigger needs an organization admin. Manage them with
trigger.list,trigger.get,trigger.update,trigger.pause,trigger.resumeandtrigger.delete.
4. Skip empty work
When you do need a schedule, make the empty run as cheap as possible:
- Layer 1 is one cheap check: a count, or a query with a
LIMIT. - Every later step carries a
conditionon that check. A skipped step writes no rows, and a skipped sub-composition skips everything inside it.
{
"name": "mark_overdue_invoices",
"description": "Daily: mark open invoices past their due date as overdue",
"layers": [
{
"name": "check",
"steps": [
{
"name": "pending",
"workflow_type": "appdata.query.run",
"input_mapping": {
"identity_app_uuid": { "source": "static", "value": "<app uuid>" },
"statement": { "source": "static", "value": "SELECT json_build_object('record_uuid', record_uuid, 'patch', json_build_object('status', 'overdue')) FROM invoices WHERE status = 'open' AND due_date < current_date ORDER BY record_uuid LIMIT 500" }
}
}
]
},
{
"name": "shape",
"steps": [
{
"name": "patches",
"workflow_type": "control.collection.flatten",
"condition": { "step": "pending", "field": "row_count", "op": "gt", "value": 0 },
"input_mapping": {
"items": { "source": "step", "step": "pending", "field": "rows" },
"depth": { "source": "static", "value": 1 }
}
}
]
},
{
"name": "write",
"steps": [
{
"name": "mark",
"workflow_type": "appdata.update-as-app",
"condition": { "step": "pending", "field": "row_count", "op": "gt", "value": 0 },
"input_mapping": {
"identity_app_uuid": { "source": "static", "value": "<app uuid>" },
"database_slug": { "source": "static", "value": "billing" },
"table_slug": { "source": "static", "value": "invoices" },
"patches": { "source": "step", "step": "patches", "field": "result" },
"source": { "source": "static", "value": "overdue-sweep" }
}
}
]
}
]
}
Conditions take {"step": S, "field": F, "op": OP, "value": V}. field may be a dotted path into the step's output. Operators: eq, ne, in, not_in, truthy, falsy, gt, gte, lt, lte, contains, not_contains, regex. Combine them with {"all_of": [...]}, {"any_of": [...]} and {"not": ...}.
control.flow.guard to stop early. A guard that does not hold fails the run: you get a failed run on every empty pass, and the failures hide the real ones. Use conditions.An interval schedule whose runs keep finding nothing to do (ten in a row) may be slowed down automatically, up to 15 minutes between fires. The next run that does real work restores the normal pace. If the work needs a prompt reaction, that is the sign it should be a change trigger.
5. Keep pure logic inline
Pure control.* steps (value, collection, predicate, math.compute, switch.select, text and time.now) are evaluated inside the run instead of starting a step of their own, so they add no state rows. Consecutive layers of pure steps are fused into one. In the example above, patches is inline.
You do not need to mark them. Check the result instead: composition.validate and composition.save return inline_steps, the steps that will run inline.
"inline": falseon a step opts it out."inline": trueon a step that is not eligible is a validation error.- A step with
fan_outortransientis never inline. control.flow.guard,control.flow.wait,control.poll.until,control.http.requestandcontrol.math.randomalways run as their own step.- Don't split pure logic into sub-compositions just for structure. A sub-composition is a run of its own.
A version saved before inline evaluation was available keeps its old compiled form. To upgrade it, save again with recompile_inline: true. That writes a new version even if the definition is unchanged. Schedules and triggers that point at the lineage ORN pick it up; anything pinned to the old virtual.<uuid>@<version> is listed in stale_dependents and must be re-pointed.
6. Write only what changed, and carry little data
- Write the difference. Don't upsert every record on every pass. Select only the rows whose stored value differs from the value you would write, as the
WHERE status = 'open' AND due_date < current_dateclause does above, and write those. - Make writes idempotent. Use
uniquefields, upserts keyed on an external id (appdata.upsert-as-app) or anexpected_updated_atprecondition, so a retry or a repeated delivery is a no-op. - Pass ids, not documents. Each layer's state carries the outputs of the steps before it. Select only the columns you need,
LIMITand page, and pass ids to later steps that can read what they need. - Read results, not echoes. Pure
control.*steps return their result fields (result,count,value, …), not a copy of their inputs. Map from those fields. - Let poison items leave the queue. An item that fails the same way every time (for example "this name is already taken") must be resolved, by adopting the existing resource or marking the item failed with a reason. Otherwise every run retries it forever.
7. Read the cost before you activate
Before saving, composition.validate (and composition.save) return cost_estimate. For the overdue-invoices example:
{
"passed": true,
"inline_steps": ["patches"],
"cost_estimate": {
"floor": { "steps": 1, "rows": 6 },
"ceiling": { "steps": 3, "rows": 9 },
"layers": 3,
"unbounded_fanouts": [],
"unresolved": []
}
}
| Field | Meaning |
|---|---|
floor | Rows of a run where every conditional step is skipped: the price of an empty run. |
ceiling | Rows of a run where every step runs. A ceiling over 100 rows adds a warning. |
unbounded_fanouts | Steps that fan out over a list. Their cost grows with the list; bound the list. |
unresolved | Steps whose cost could not be estimated. |
When scheduling, schedule.create and schedule.update return estimated_rows_per_day (floor, ceiling, runs_per_day, rows_per_run) and warnings. A schedule that fires more often than every 5 minutes and writes more than 20,000 rows a day gets a cost warning. cost_ack: true accepts it; only pass it when you can say why in the schedule's name.
After a day, run data.composition.cost with the composition's lineage_uuid or the schedule_uuid (window_hours defaults to 24, up to 168). It reports runs, rows, rows_per_run, bytes and empty_runs, the runs that did nothing after layer 1. If empty_runs is more than about half of runs_with_stats, go back to sections 1 and 4.
Checklist
Before composition.activate, schedule.create or trigger.create:
- What starts the run matches the table in section 1: a trigger for a queue, cron with a timezone for fixed times.
- Schedules and triggers point at the lineage ORN, or at the version you just saved.
- Layer 1 is one cheap check, and every later step and sub-composition has a
condition. - No
control.flow.guardused as an early stop. -
inline_stepsis not empty when the composition has pure logic. - Writes are diff-only and idempotent; failing items leave the queue.
- Step outputs are small: ids and the columns you need.
- You have read
cost_estimateandestimated_rows_per_day, and there is no unexplainedcost_ack. - A day later,
data.composition.costshows a low share of empty runs.
Related
Receive webhooks with a re-query
Point a provider's webhook at a Hook URL, run a composition on each delivery, and act only on what the provider's API confirms. Worked example — Banco Inter Pix and boletos
Building with DGI
Express a goal to DGI, review the workflow plan it assembles, and promote a good plan into a reusable deterministic virtual workflow
