Operating workflows
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 retried inside the attempt only when the provider throws EmailNotSentException, which
it does only when the message cannot have left. There is no idempotency key to make a second send
safe, so every other failure is sent once and left to the durable queue, and email has no breaker.
Retries sets how many more tries a send that was not taken gets, cut to fit the lease: each
provider says how long a not-sent try can take (MaxNotSentDuration), and every try running that
long, plus the waits between them, must stay within 80% of the shorter of the runner's 5 minute
lease and Jobs:LeaseSeconds. SMTP's bound is three TimeoutSeconds (90 s by default), and
Resend's is its client timeout (100 s), so with the defaults each gets one more try. A provider that
gives no bound is sent once. With EmailSendTimeoutSeconds set, that limit covers every try and all
of Retries is used.
| Provider | Not sent, so tried again | Everything else, sent once |
|---|---|---|
| SMTP | Any failure to connect or log in: DNS, a refused connection, the TLS handshake, no STARTTLS, a refused login. A 4xx or 5xx answer to MAIL FROM or RCPT TO. | A refused DATA (MailKit reports it the same way as a refusal after the whole message), a dropped connection or a timeout during the send |
| Resend | A name not resolved, a connection not opened, a failed TLS handshake. A 429 or 503. | Any other status, a connection lost after the request went, a timeout |
The SMTP failure still leaves the relay's own exception out, because it can carry the relay
password. EmailSendTimeoutSeconds is off by default, so a large attachment over a slow relay still
sends. When it is set it covers every try and the waits between them. When it fires during a send,
the attempt is recorded as unknown and not retried, because the message may already have gone; when
it fires between tries, after a send that was not taken, the attempt fails as retryable.
| 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. That
interface is obsolete in 5.0 and is removed in 6.0: nothing in the host calls it. 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.
Passing a value from one action to the next
An action can return outputs, and a later action of the same run can read them. A custom action
returns them from RunAsync(WorkflowContext, ct) with WorkflowActionResult.Success(outputs); none
of the built-in actions returns any yet.
The workflow says which outputs are kept, per action, in capture:
{
"actions": [
{ "type": "Lookup", "capture": ["total", "reference"] },
{ "type": "Email", "parameters": { "To": "{{data.Email}}", "Subject": "Order {{actions.0.reference}}", "Body": "Total {{actions.0.total}}" } }
]
}
{{actions.N.name}} reads the output name of the action at position N, counting from zero. Only
outputs named in that action's capture are kept on the run, and only those can be read. Everything
else the action returned is dropped before the run is written, so a credential or an address in a
provider's response never reaches the database (D37). An action keeps at most 20 outputs, a name is
letters, digits and underscores, and a value longer than 2000 characters is not kept: the attempt's
error says which.
A kept output is substituted after the action's own placeholders are resolved, encoded the way the
parameter it lands in is encoded, and it is never read as a placeholder itself. Only a reference
written in the workflow is read: an entry value that holds {{actions.0.name}} is sent as that text. It is read only as
it is: a format such as | upper is not applied. It is not read inside a Conditional's branches.
When the output is not there, because the earlier action has not succeeded or does not capture that
name, the reading action fails and is retried like any other failure, so an action set to Continue
can wait for an earlier one still on its own retry. Saving a workflow warns about a reference to an
action that does not run first or a name it does not capture.
Kept outputs are served on each attempt as outputs in GET /api/workflow-runs, and are deleted
with the run when its retention window ends.
Undoing what a run did
An action can declare what undoes it, in compensate: one action, with its own type and
parameters, checked on save the way an action is. A Conditional is not accepted there. Nothing is
declared by default, because not everything can be undone: an SMS cannot be unsent. In this example
both types are custom actions a module registers:
{ "type": "PostInvoice", "capture": ["invoiceId"], "onFailure": "Halt",
"compensate": { "type": "VoidInvoice", "parameters": { "InvoiceId": "{{actions.0.invoiceId}}" } } }
A run is rolled back in two cases. When an action set to Halt fails for good, if any action of the
workflow declares a compensation; a workflow that declares none halts exactly as before, and its
halted action can still be retried. And when an operator abandons it with
POST /api/workflow-runs/{id}/abandon, which needs retry_workflow_actions because the
compensations go out to third parties. An action that ended Unknown starts nothing on its own: it
may have arrived, so an operator decides between retrying it and abandoning the run.
Rolling back cancels what has not started, then undoes each action that succeeded, one at a time and
the last first, under the same retries, leases and backoff as an action. A compensation has its own
idempotency key, the action's key with -undo on the end, is handed CompensationOf with the
position of the action it undoes, and can read that action's captured outputs, which is how it names
the thing to undo. It runs even when the entry has been deleted since.
Each attempt in GET /api/workflow-runs carries compensation once the run is rolled back:
Succeeded, Failed, Unknown, Cancelled, or NotDeclared for an action that succeeded and
declares nothing that undoes it. The run carries compensation: Compensating while one is waiting
or in flight, Compensated when every action that succeeded was undone, and Incomplete when any
was not, so a run that is half applied says so. A run being rolled back reads Running, and its
actions cannot be retried. Cancelling it stops the compensations that have not started, and leaves
it Incomplete. Abandon is refused with 409 for a run nothing of which succeeded, for one that finished
with every action succeeded, while an action is in flight, for a run already rolled back or cancelled, and for a workflow switched off or deleted.
The obsolete IWorkflowEngine path does not compensate.
What runs a workflow
A workflow can name its executor in executor. Left out, or null, it is the built-in runner, which is
what every workflow has always used, and a workflow and its runs read exactly as before. Only the
built-in runner exists in this release, so a save naming anything else is refused with 400. The field
is there so a later executor can take only the workflows pointed at it and leave every other one
alone: routing, not replacement.
A run copies the executor when it is queued and shows it as executor in GET /api/workflow-runs,
null for the built-in runner. The built-in runner claims only its own runs. Whatever another
executor does, it keeps the run document current under the invariants the built-in runner keeps:
attempts advance in ordinal order, only the holder of a lease writes an outcome, a timeout is
Unknown and is not retried on its own, the idempotency key of an attempt is stable across retries,
only captured outputs are written, compensations run last first, and a cancelled or switched off run
starts nothing more.
Changing a workflow
PUT /api/workflows/{id} (capability manage_workflows) replaces a workflow with the definition
sent, in the same shape POST /api/workflows takes, and validates it the same way: an unknown action
type, a missing required parameter or a trigger the content type does not declare is a 400 and
nothing is saved. It is a full update, so a field left out takes the create's default (enabled
left out switches the workflow on). The response carries warnings as a create does, and the change
is written to the audit log as workflow.updated.
Credentials are write-only, so a console never has them to send back. An action sent back
unchanged, in the same position, of the same type and with every parameter as the API returned it,
keeps its stored credentials (Secret, ApiKey, a password, a token), and an unchanged
Conditional keeps its children's too. A credential stays with the destination it was given for, so
an action that changed (a new URL, or another action moved into its place) and leaves a credential
out is refused with a 400 naming it: send it again, or send it blank to clear it. A changed
Conditional branch is taken as sent. An action's compensate is kept or refused on the same rule,
against the compensation stored in the same position. Runs already queued keep the actions they were
queued with.
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.
Every try of an action, and what changed since
An action keeps a history of its tries. Each entry has the try number, when it started and finished, the outcome, the response status the remote call answered with, the error, whether it could be retried, and how long it took. The attempt itself still shows the latest try, so a client that ignores the history sees what it saw before.
The history holds the newest 20 entries, oldest first. When a twenty-first arrives the oldest is dropped and droppedAttempts goes up by one. A manual retry adds to the same history and never clears it. A run stored before this existed shows one entry built from what it has, and reading it does not rewrite it.
Errors are redacted before they are stored, on the attempt and in the history alike: bearer tokens, credential-shaped query parameters and the like are replaced, then the text is cut to 500 characters. The rules are in CredentialRedaction in the abstractions package, and the diagnostics module uses the same ones. Ids and addresses in error text are masked too, since an API key can look like a GUID; the run and action carry their ids in their own fields.
Worst case for the document is every action holding 20 entries with 500-character errors. One such entry is about 680 bytes of JSON, so one action is about 14 KB, and a run of ten such actions is about 137 KB. Compensation (CompensationState.Attempts) keeps no history.
GET /api/workflow-runs takes changedSince to read what changed. The first call passes a timestamp, and every response then carries a cursor to pass back. Runs come back oldest change first, ordered by updatedAt and then id, and paged with the usual page and pageSize. A page whose last run changed more than 5 seconds ago returns the cursor of that run. Any other page, with more behind it or not, returns a cursor 5 seconds behind now, so a run whose write committed late is still seen on the next poll. When a page lies wholly inside that margin and more follows, the cursor of its last run is returned instead, so a client always moves forward. That means a run can be returned twice, and a client should replace by id. The lag is 5 seconds, so a repeat is the worst case, never a skip, with one exception: a save held for more than 5 seconds on a lock can commit after the cursor has passed its stamp, and that run is missed until it is written again. This is accepted. Without changedSince the list is newest first as before and cursor is null.
The list response also carries statusCounts, the number of runs in each status for the current contentId filter, whatever the status filter and page, except that it is null when changedSince is given, since a poll should not pay for a count of the table. It carries too succeededRetainedFrom and failedRetainedFrom, the times before which the retention sweep removes finished runs (null when the sweep is off or keeps them).
updatedAt is set by the store on every write of a run and is indexed. A run written before it existed has none until it is next written.
Running one by hand, and how healthy a workflow is
POST /api/workflows/{id}/run with { "entryId": "..." } queues one run of that workflow on that entry and answers 202 with { "runId": "..." }. The run has trigger Manual and startedBy set to the caller, and the runner picks it up like any other. It needs run_workflows and read access to the entry. A role of your own that holds manage_workflows is given run_workflows once, on the first start after the upgrade, so it keeps being able to run what it could edit. The seeder records that it did and does not give it again, so a grant you remove from such a role stays removed. The seeded Admin and SuperAdmin roles are different: they get their default capabilities back on every start, like every other default capability, run_workflows included.
Refusals:
| Status | When |
|---|---|
| 404 | The workflow or the entry is not in the caller's tenant, or the caller cannot read the entry. |
| 400 | The entry's content type is not one the workflow runs on. |
| 409 | The workflow is switched off, or the entry fails its conditions (the body names the first one). |
Conditions are checked, not bypassed. A definition stored without an Enabled key counts as enabled.
Send an Idempotency-Key header to make a retry safe. The run id is derived from the tenant, the workflow, the entry and the key, so a repeat by the same caller answers 200 with the same runId and repeated: true, two requests at once make one run, and the same key on another workflow or entry, or from another caller, makes another run. The repeat holds until retention removes the run, not past it. This route reads the header itself, and the global idempotency filter leaves it alone.
The action is recorded in the audit log as workflow.run.manual, with the actor and the run id.
GET /api/workflows/health (needs view_workflow_runs) is paged by workflow. Each item has the last 10 finished runs, successRate, finishedRuns and averageActionDurationMs. The rate is succeeded over succeeded, failed and partially failed in the window. Pending, running and cancelled runs are left out, and a workflow with no finished run has a null rate. windowDays defaults to 7 and is clamped to what retention keeps (at most 90); the response says the window it used. The average skips actions with no recorded duration and looks at the newest 200 runs in the window.
The lastOutcomes list includes cancelled and abandoned runs. Workflows are ordered by name, then id.
GET /api/workflow-runs takes workflowId. statusCounts follow it. When you page with a changedSince cursor, send the same filters (workflowId, contentId, status) on every page: the cursor does not remember them.
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.