workflow runs - BaryoDev/barakoCMS GitHub Wiki
Workflow runs and how long they are kept
Every time a workflow fires it leaves a WorkflowRun behind: what it decided to do, every attempt at
each action, and how each went. They accumulate at the rate content is published times the number of
actions per workflow, so something has to remove them.
The two windows
Failures are kept longer than successes, because they are interesting for longer. A run that succeeded answers "did that go out" for a while. A run that failed is interesting until somebody deals with it.
{
"Workflows": {
"Retention": {
"Succeeded": 7,
"Failed": 90
}
}
}
Both are in days and both are the defaults, so a deployment that sets neither gets seven and ninety.
Workflows:Retention:Enabled turns the sweep off entirely, and then runs accumulate.
PartiallyFailed is kept on the failure window, not the success one. A run where the post went out
and the email did not holds a thing nobody has dealt with, which is the case the longer window is
for. Cancelled is kept on the failure window too: it holds an action that was stopped, and
whoever asks why wants it for as long as a failure.
Zero or less keeps that class forever. That reading was chosen deliberately, because "0 days" reads as "delete immediately" just as naturally, and a setting whose two plain readings are opposite should not be settled by a default. Keeping is the direction a mistake can be recovered from.
What is never removed
A run that is Pending or Running is never removed, whatever its age. That is a rule rather than a
consequence of the windows: a run whose provider has been unreachable for a fortnight is still an
email somebody is waiting for, and a window would otherwise delete the work rather than the record of
it.
If runs are piling up in Pending, the runner is not keeping up or is switched off
(Workflows:RunnerEnabled). Retention is not the thing to reach for.
Webhook deliveries keep their own log with its own window, Webhooks:DeliveryLogRetentionDays.
See docs/webhooks.md.
How many actions run at once
A node runs one action at a time unless it is told otherwise. Every action is a call to a third party, so at two seconds per webhook that is half an action a second per node, and a bigger machine does not change it.
{
"Workflows": {
"RunnerConcurrency": 4
}
}
Workflows:RunnerConcurrency is how many actions one node may have in flight at once. The default
is 1. It takes 1 to 20, and a value outside that range stops the API from starting, with an error
that names the setting. Twenty is the number of due runs a pass reads per tenant.
What to know before raising it:
- The bound is per node. The worst case against one provider is the setting times the number of nodes: three nodes at 4 is twelve calls to the same provider in flight at once. Set it from the provider's rate limit, not from the node's cores. A provider that starts answering 429 turns the extra calls into retries.
- The actions of one run still run in order, one at a time. "Post, then email, then tweet" stays a sequence. Only actions of different runs overlap.
- Runs no longer finish in the order they were queued. At 1, a node works through a tenant's runs oldest first, so two runs for the same entry reach a receiver in the order they fired. Above 1 they can be in flight together and arrive either way round. Several nodes already had this.
- Two runs that write the same entry can collide. Two workflows on one event, each with an
UpdateFieldon the entry that triggered them, can be claimed in the same pass. Both load the entry, one write is refused, and that action spends one of its five attempts and waits out its backoff. It succeeds on the retry. Several nodes at 1 already behave this way. - Tenants share the slots. A pass hands them out one per tenant in turn, so a tenant with a long queue gets a second slot only after every other tenant with due work has had one.
- A pass waits for all of its actions before it claims again. One slow action holds the other slots of its pass empty until it ends, so throughput is the bound divided by the slowest action of each pass, not by the average. The slowest action of any tenant sets the length of the pass for every tenant.
- Custom actions run side by side. Above 1, actions of different runs execute at the same time in one process, so a custom action that keeps state outside its own instance has to be safe for that.
- Each action in flight uses a database connection while it loads the entry and records the outcome, so the setting also has to fit the connection pool.
When a node stops, the actions it has in flight are cancelled and the node waits for them to end,
the same as with one. An attempt whose outcome was not recorded stays Running under its lease and
is taken again, by any node, when the lease ends five minutes after it was claimed.
Retries inside one attempt
A reset socket or one 503 during a deploy should not cost a whole durable attempt and its backoff.
Outbound calls from the Webhook and Request actions, and the other calls made through the
outbound HTTP client (connector token grants, the OIDC backchannel), get a small retry inside the
attempt. The runner still owns retry across attempts; this sits inside one. It uses
Carom.
What is tried again:
| Failure | Tried again |
|---|---|
| Connection not opened, name not resolved | yes, any method |
| 408, 429, 503 | yes, any method |
| Timeout, 502, 504, connection lost mid-exchange | only GET, HEAD, OPTIONS, PUT, DELETE, a request with an Idempotency-Key header, or a webhook |
| Any other status, a blocked address, a TLS failure | no |
A webhook counts as safe to resend because every try carries the same X-Barako-Delivery id, and
the Idempotency-Key when the runner supplied one, so a receiver that took the first try can tell
the second is the same delivery.
A Retry-After up to MaxRetryAfterSeconds is waited for. A longer one ends the tries, and the
answer goes back to the action, which fails the attempt as retryable.
Each tenant has a breaker per destination host. After BreakerFailures failed calls among the last
BreakerWindow within BreakerSamplingSeconds, that tenant's calls to that host are refused without
being sent for BreakerOpenSeconds, then one probe is let through. A 429, and an answer asking to
wait longer than MaxRetryAfterSeconds, never count: on a shared host they are usually one
account's quota, and another tenant on the same host is not affected. A refused call fails the
attempt as retryable, with an error that names the host and nothing else from the URL, so the
durable queue comes back later rather than adding to the load on a provider that is down. Breakers
are kept for at most 1,000 tenant and host pairs; past that the least recently used tenth is
dropped, and a dropped pair starts again with a closed breaker.
Email is not retried inside the attempt and has no breaker. The SMTP and Resend modules report
every failure the same way, with the cause left out because it can carry the relay password, so a
send that never left cannot be told from one the relay may have taken, and there is no idempotency
key to make a second send safe. A failed send is left to the durable queue, as before.
EmailSendTimeoutSeconds is off by default, so a large attachment over a slow relay still sends.
When it is set and fires, the attempt is recorded as unknown and not retried, because the message
may already have gone.
| Setting | Default | |
|---|---|---|
Workflows:Outbound:Retries |
2 | Tries after the first. 0 sends once. |
Workflows:Outbound:AttemptTimeoutSeconds |
10 | One HTTP try, up to the response headers |
Workflows:Outbound:BaseDelayMilliseconds |
200 | Floor of the jitter between tries |
Workflows:Outbound:MaxDelaySeconds |
2 | Ceiling of the jitter between tries |
Workflows:Outbound:MaxRetryAfterSeconds |
5 | Longest Retry-After waited for |
Workflows:Outbound:BreakerFailures |
5 | 0 turns the breakers off |
Workflows:Outbound:BreakerWindow |
10 | |
Workflows:Outbound:BreakerSamplingSeconds |
60 | |
Workflows:Outbound:BreakerOpenSeconds |
30 | |
Workflows:Outbound:EmailSendTimeoutSeconds |
0 | One email send. 0 is no limit. |
The longest one HTTP call can spend here is every try running to its timeout plus every wait at
its ceiling: 44 seconds with the defaults. The outbound client's own timeout is that plus 30
seconds for a buffered response body, 74 seconds. The slowest action is a connector request, which
can make four calls (a token grant, the send, a second grant after a 401, and the resend): 208
seconds with the defaults, since each grant also has its own 30 second deadline. The host refuses
to start when the slowest action, or the email send timeout, is more than 80% of the shorter of the
runner's 5 minute lease and Jobs:LeaseSeconds, because an action that runs past its lease is run
again by another node.
Before 4.7.0 the outbound client used the standard .NET resilience handler, which made up to four tries and also retried a 500, and a POST after a timeout. The defaults here make one try fewer and retry neither.
Stopping a chain at a failure
By default a failed action does not stop the ones after it. "Post, then email, then tweet" is three
independent things, the tweet still goes out when the mail server is down, and the run reads
PartiallyFailed.
A chain is different: if the journal entry fails, filing the document and adjusting stock should not
go ahead as though it had worked. Each action of a workflow takes onFailure, Continue or Halt.
Left out or sent as null it is Continue, and so is every action saved before the setting
existed. Any other value is refused with a 400, where the API used to ignore the field.
{
"actions": [
{ "type": "Webhook", "parameters": { "Url": "https://ledger.example.com/entries" }, "onFailure": "Halt" },
{ "type": "Webhook", "parameters": { "Url": "https://stock.example.com/adjust" } }
]
}
What Halt does:
- Nothing after the action runs until it has succeeded. While it waits on a retry, the actions
after it wait too. A
Continueaction in the same position lets them run in the meantime. - Once it fails for good, the actions after it that have not run become
Skipped. That is a failure that is not retried, or the fifth failed attempt. Each skipped action carrieshaltedBy, the ordinal of the action that stopped it, and the run is finished:Failedif nothing of it succeeded,PartiallyFailedotherwise. Unknownhalts as well. A timeout does not say whether the step happened, and the steps after it would run as though it had.- An action skipped because the content was deleted does not halt. It did not fail.
- Actions before the halting one are not touched. One of them still waiting on its own retry keeps it.
POST /api/workflow-runs/{id}/actions/{ordinal}/retry on the action that halted the run is the
resume: fix the cause, retry it, and the actions it skipped are queued again behind it. They run
once it succeeds, and are skipped again if it fails again. The audit entry of that retry carries
resumedActions, the number it queued again. Retrying one of the skipped actions on its own answers
409, because that would run it past the failure.
The policy is copied onto a run when the workflow fires, like the action's parameters, so a run
already queued keeps the policy it was queued with. It applies to a workflow's own actions. The
children of a Conditional have no policy of their own: the Conditional succeeds or fails as one
action, and its own onFailure decides what follows. A workflow that puts onFailure on a child
is refused when it is saved.
The engine a host can call in line through IWorkflowEngine follows the policy too. It retries
nothing, so any failure of a Halt action ends the workflow there, and the actions after it are
written to the execution log as not run.
A node on a version before this one does not know the policy. It runs past a failed or waiting
Halt action, and when it writes a run it drops onFailure and haltedBy from it, so a halted
run retried through such a node can end Succeeded with actions that never ran. That is true
during a rolling upgrade and after a rollback alike. Finish the rollout before saving a workflow
that uses Halt, and before rolling back past this version, change those workflows back or wait
for their runs to finish.
Stopping a workflow or a run
Three requests, all behind manage_workflows, each written to the audit log.
PUT /api/workflows/{id}/enabled with { "enabled": false } switches a workflow off. It starts no
more runs, on any trigger. Runs it had already queued are cancelled by the runner as it reaches
them, which for a run waiting on a backoff is when the wait ends, and switching the workflow back on
does not bring them back. A workflow saved before the flag existed is on. While a workflow is off, a
retry of one of its failed actions answers 409: the runner would cancel the run where it should run
it. Switch the workflow on, then retry.
DELETE /api/workflows/{id} deletes a workflow and cancels its queued runs in the same transaction.
It cancels at most 200 runs. A workflow with more than that queued is refused with a 409: switch it
off first, let the runner cancel them, then delete it. Its finished runs stay as history, and a
retry of one of their actions answers 409, so a deleted workflow sends nothing more. A delete and a
retry of one of the workflow's runs take turns: a retry that arrives during the delete waits and is
then refused, and a delete that arrives during a retry waits and cancels the attempt the retry
queued. A run that finishes while the delete is under way keeps the status it finished with. One gap
is left:
a run queued by an event in the instant between the delete reading the queue and committing is not
cancelled, and executes once with the actions it copied. Switching the workflow off before deleting
it closes that.
POST /api/workflow-runs/{id}/cancel stops one run. Every action that has not started becomes
Cancelled, which always means the action never went out. An action that is running is left,
because its request is already with the third party: it finishes and records its outcome as it
happened, nothing after it starts, and if it failed it is not tried again. An action that was
claimed by a node that then went quiet, so its lease ran out with no outcome, becomes Unknown: it
may have gone out. The run reads Cancelled once nothing of it is in flight, whatever its actions
did. A run that has finished cannot be cancelled, and an action of a cancelled run cannot be retried.
Both answer 409. Cancelling a run that is already stopped and still has an action out changes
nothing and records nothing.
A cancel and the runner claiming the same run cannot both be saved. If the runner got there first the cancel answers 409 and can be sent again for what is left.
Watching the runner
The queue and the runner publish to /metrics, the Prometheus endpoint, which needs
Metrics:ScrapeKey (see docs/upgrading-to-4.0.md for the scrape config). These are the numbers
that answer "are workflows running" without a query against the database.
| Metric | Type | Labels | What it counts |
|---|---|---|---|
barakocms_workflow_runs_queued_total |
counter | trigger |
Runs queued, by the kind of event that fired them. |
barakocms_workflow_attempts_claimed_total |
counter | none | Action attempts this node's runner claimed. |
barakocms_workflow_attempts_total |
counter | action, outcome |
Attempts whose outcome this node recorded. |
barakocms_workflow_action_duration_seconds |
histogram | action |
How long the action took, for those attempts. |
barakocms_workflow_runs_finished_total |
counter | status |
Runs this node's runner finished. |
barakocms_workflow_runs_halted_total |
counter | none | Runs where a Halt action failed and later actions were skipped. |
barakocms_workflow_runner_last_pass_timestamp_seconds |
gauge | none | Unix time this node's runner last completed a pass. |
barakocms_workflow_due_runs |
gauge | none | Runs with an action that could be claimed now. |
barakocms_workflow_oldest_due_run_age_seconds |
gauge | none | Seconds since the oldest due run was queued. 0 when none is due. |
barakocms_workflow_backlog_measured_timestamp_seconds |
gauge | none | Unix time the two gauges above were last measured. |
The label values are fixed, so the number of series is too:
triggeriscreated,updated,deleted,published,unpublished,transitionorother. Every lifecycle transition is counted astransition, whatever it is called. 7 series at most.outcomeissucceeded,failed,retried,unknownorskipped.retriedis a failure the runner queued again, andfailedis one it did not.skippedis the content having been deleted before the action ran. An action skipped behind aHaltwas never attempted and is not counted here.actionis the type of a registered action (Email,Webhookand so on, and any custom action the host registers), orother. A type no handler is registered for is counted asother, and so is every registered type past the first 50 a node sees. Sobarakocms_workflow_attempts_totalis at most 51 actions times 6 outcomes (the five above and a spareother), 306 series, and a host with the seven built in actions has up to 40. The duration histogram has 15 series per action (12 buckets from 50 ms to 5 minutes,+Inf, sum and count), 765 at most.statusissucceeded,failed,partially_failedorcancelled. 4 series.
No label holds a tenant, a workflow, a run, a content type or an error message, and no metric is
kept per tenant. Whoever can read /metrics learns how much workflow activity the whole deployment
has, which action types it uses, how long they take and how often they fail. That is the same kind
of exposure as the request metrics already there, which name every route and count its traffic.
What the numbers do not include:
runs_finished_totalcounts a run when the runner writes its last outcome, or cancels what is left of it. A run cancelled through the API with nothing in flight is finished by the API and is not counted. A run that finishes, is retried by hand and finishes again is counted twice.- An attempt is counted when its outcome is saved. An outcome that could not be saved (the node ran
past its lease, or the run was written twice in between) is claimed but not counted, so
attempts_claimed_totalrunning ahead ofattempts_totalby more than what is in flight is a sign of that. - The counters are per node and start at zero when the node starts. Sum them across nodes.
The backlog gauges
due_runs and oldest_due_run_age_seconds are measured by the runner between passes and never by
a scrape. A scrape reads the last numbers.
{
"Workflows": {
"BacklogIntervalSeconds": 30
}
}
Workflows:BacklogIntervalSeconds is the least time between two measurements. The default is 30.
It takes 0 to 3600, and a value outside that range stops the API from starting, with an error that
names the setting. 0 switches the measurement off: the three backlog gauges are then never
published, and the backlog alerts below have nothing to read, so remove them with it.
What a measurement costs: one count per tenant partition, which reads what a pass already reads
there to find what is due, and one more read of a single timestamp in a partition that has something
due. An idle runner makes a pass every 5 seconds, so at the default the measurement adds about one
partition sweep to every six the runner makes anyway. The partitions are the ones a pass visits:
those holding unfinished runs, or every registered tenant when the list comes from the tenant
registry (Tenancy:DatabaseEnforcement on, or Multi mode). A measurement stops after 10 seconds,
and that is the most it can hold up the next pass.
If a measurement fails or runs out of time, nothing is published and the gauges keep their last values. The runner goes on to its next pass either way. The node logs one warning when the failures start, naming the kind of failure, and then at most one every five minutes while they go on. That is why the time of the measurement is published beside the numbers: old numbers are told apart by their age. On a node where no measurement has ever finished, the three gauges are not published at all, which is what the "never measured" alert below is for.
Every node measures the same database, so two healthy nodes report the same backlog. Do not sum them.
What the age gauge does not catch on its own:
- A runner that has stopped. The runner measures the gauge itself, so the number freezes when the runner does. The last pass alert is the one that catches it.
- A provider that is down. A run that failed and is waiting on a retry is not due until its wait
ends, and after five attempts it is
Failedand no longer waiting at all. The failure ratio alert is the one that catches it.
What it does catch is a runner that is alive and not keeping up. A retried run's age is counted from when it was first queued, so a run on its fifth attempt reads a few minutes old the moment it comes due. Set the age threshold above that.
A node with Workflows:RunnerEnabled off publishes none of the four gauges.
Alerts
Each expression below is evaluated per node (Prometheus adds an instance label to every series it
scrapes), except the failure ratio. With max over the nodes, one node whose runner had stopped
would be hidden by another whose runner had not.
A node's runner has not completed a pass in ten minutes. Fires once for each such node, while
the node still answers scrapes. A pass waits for its actions, so the threshold has to be longer than
the slowest action. A node that is down altogether drops out of this one, and up == 0 reports it.
time() - barakocms_workflow_runner_last_pass_timestamp_seconds > 600
A node has never completed a pass. Fires for each scraped node that publishes no last pass:
its runner has not finished one since it started. It also matches a node with
Workflows:RunnerEnabled off, so leave those out by instance. Change job to the name in your
scrape config.
up{job="barakocms"} == 1 unless on (instance) barakocms_workflow_runner_last_pass_timestamp_seconds
No node at all reports a pass.
absent(barakocms_workflow_runner_last_pass_timestamp_seconds)
Work has been due for fifteen minutes and is not being taken. Read only from a node whose measurement is fresh, so a node that stopped measuring cannot keep an old age firing. Use a few times the measurement interval for the freshness. Every node with a fresh measurement fires together, since they count the same database.
barakocms_workflow_oldest_due_run_age_seconds > 900
and on (instance) (time() - barakocms_workflow_backlog_measured_timestamp_seconds < 120)
A node's backlog numbers are old. Its measurements have been failing or timing out for five minutes, so the alert above says nothing for that node.
time() - barakocms_workflow_backlog_measured_timestamp_seconds > 300
The backlog has never been measured. The first is for one node, or for "no node at all". The
second fires for each scraped node that has never finished a measurement, which is the case where
the count does not fit in its 10 seconds from the start. Both also fire when
Workflows:BacklogIntervalSeconds is 0, so they go with the setting.
absent(barakocms_workflow_backlog_measured_timestamp_seconds)
up{job="barakocms"} == 1 unless on (instance) barakocms_workflow_backlog_measured_timestamp_seconds
More than half the attempts of one action type are failing, across all nodes, which is usually a provider being down:
sum by (action) (rate(barakocms_workflow_attempts_total{outcome=~"failed|retried|unknown"}[10m]))
/ sum by (action) (rate(barakocms_workflow_attempts_total[10m])) > 0.5
Do not alert on queued against finished. A run cancelled through the API is queued and never counted as finished, and so is a run of a workflow with no actions.
This is not an audit trail
Say it plainly, because a retention setting is exactly the kind of thing that quietly becomes a compliance control.
The sweep removes operational records. The audit entries a retry writes are separate documents and are not touched by any of this. If you need workflow history kept for longer than the operational window, export it: a longer retention window is a database that grows without bound, and it is still not an audit trail, because a run can be deleted by an operator changing a setting and nothing records that it was.
How it runs
Hourly, on one instance at a time. It takes a Postgres advisory lock the way the scheduled content sweep does, so a two-node deployment does not have both nodes deleting the same batch. Deletion is batched and bounded, five hundred runs per batch and twenty batches per tick, so a backlog is worked through over a few hours rather than in one transaction that holds a connection all night.
The first sweep waits two minutes after start, so a rollout finishes booting before anything is deleted.
Generated from docs/workflow-runs.md by scripts/wiki-sync.sh. Edit the doc in the repository, not this page.