Pure and Idempotent Function Blocks
Every function block declares three boolean flags — is_pure,
is_idempotent and reversible — that tell the workflow engine how the block
behaves with respect to side effects. These flags drive retry logic, failure classification, and rollback
decisions across the entire workflow. On top of them, each result can say
whether that one attempt changed anything (side_effects).
Why it matters
When a workflow step fails, the engine must decide: is it safe to retry?
If every completed step was read-only, no device state changed — retry from
scratch. If a step pushed a configuration and the next step failed, retrying
blindly might apply that configuration twice. is_pure and is_idempotent
give the engine the information it needs to make that decision automatically.
Pure function blocks
A pure function block has no side effects. It changes nothing on devices
or in external systems. Changes it declares to the CMS through the
WorkflowContext (its db_updates) do not count: the engine applies them
itself, and only when the whole run succeeds.
Examples:
- Collecting device facts via
show version - Reading running configuration for backup
- Checking compliance against a policy
- Computing a diff between intended and actual state
- Updating or creating CMS entities, such as the base blocks
device_update_device,group_update_group,global_add_devicesandglobal_add_groups
Pure blocks are safe to retry, cache, and skip without consequence. The engine can re-execute them at any time and get the same result (assuming device state has not changed externally).
A pure block has nothing to claim, so its results never say
side_effects=True. If one does, the worker fails the job instead, with
retryable=False and the message pure block reported side effects: fix the
block or its registration; the engine refuses such a result too.
A CMS-only run can leave nothing to remediate
Because CMS changes are not side effects, a run that did only CMS work and then failed to
write its changes back (UNLOCK_FAILED) has an empty remediation ledger. Check the CMS
anyway: the engine has no record of which of those writes landed.
FAILED_SAFE vs FAILED_UNSAFE
When a workflow fails, the engine looks at the non-pure steps that ran before
the failure, leaving out the steps whose every attempt reported
side_effects=False. If none remain, the
workflow transitions to FAILED_SAFE — no side effects occurred and the
workflow can be retried safely.
If some remain and every one of them is reversible, the engine rolls them
back, wave by wave in reverse order. All rollbacks succeed → FAILED_SAFE.
If any remaining step is irreversible, or a rollback fails, the workflow
transitions to FAILED_UNSAFE, signaling that manual inspection is
required before retrying.
Side effects of one attempt
is_pure is a promise about every run of the block. Often a block that can
change a device knows, for one attempt, that it did not: it failed a check
before it sent anything. Say so on the result:
if params.delay is None:
return FunctionBlockResult(
success=False,
message="delay must be provided when reload_type is scheduled.",
retryable=False,
side_effects=False,
data=None,
)
side_effects |
Meaning |
|---|---|
None (default) |
Side effects are assumed |
False |
This attempt changed nothing, on success or failure |
True |
This attempt may have changed something (the same as None for the engine) |
The engine trusts the flag and leaves the step out of the rollback, but only
when every attempt of the step said side_effects=False and no rollback
has run for it yet. A retry that does change something keeps the step in.
- Only an explicit return marks a step safe. An exception raised by the
block always means side effects are assumed, even if it was raised before
anything was sent. Catch it and return a result if you know better: a
PreContactErrorfrom the connection layer means the failing call sent nothing (see Error handling). - The worker sets it for you when the block failed before
run()was entered (for example, parameters that do not validate): those failures getside_effects=Falseandretryable=False. - It is for
run()results only. The worker sends it onEXECUTEjobs only.
side_effects=False alone means up to three retries
A failure with side_effects=False is retried even when the block is neither
pure nor idempotent: the attempt changed nothing, so another one is safe. If the
failure would come out the same on every attempt, such as a missing setting or a
template that does not compile, also set retryable=False, or the step runs up to
three more times before it fails.
Idempotent function blocks
An idempotent function block can be executed multiple times and produce the same outcome each time. It may have side effects, but those side effects converge to a single state.
Examples:
- Pushing a configuration template (applying the same config twice results in the same device state)
- Setting a hostname (the value is the same regardless of how many times you set it)
- Creating entities with
skip_if_identifier_exists=True(duplicates are skipped on re-runs)
Idempotent blocks are safe to retry individually. If the block itself fails partway through, the engine can re-execute it without creating inconsistency.
Reversible function blocks
A reversible function block can have its effects undone.
reversible=True tells the engine it may undo this block’s effects by
dispatching a ROLLBACK job that calls the block’s rollback(). The default,
False, means the engine never attempts to roll the block back.
The engine rolls back not only the failed step but also the earlier steps that
completed. rollback() receives a forward argument describing the earliest
attempt of the step that may have changed something: its result, the
rollback data it saved before
changing anything, whether its outcome is known at all, and the step’s earlier
rollback attempts. What the rollback does when that data is missing, or when it
runs a second time, is up to the block.
A pure block has nothing to undo: registering a block both is_pure=True and
reversible=True logs a warning.
Warning
Declaring reversible=True without overriding rollback() makes every
rollback job fail: the base implementation returns success=False with
"Rollback not implemented", the entry ends ROLLBACK_FAILED, and the run
stays FAILED_UNSAFE until an operator resolves it.
See Rollback for how to implement
rollback().
Decision table
Use this table to classify your function blocks:
| Task | is_pure |
is_idempotent |
Reasoning |
|---|---|---|---|
| Config backup | True |
True |
Reads config, stores as fact. No device state change. |
| Compliance check | True |
True |
Compares state against policy. Read-only. |
| Collect interface counters | True |
True |
Reads operational data. No mutation. |
| Update CMS entities | True |
False |
Its only changes are db_updates, which the engine applies; they are not side effects. |
| Device discovery with dedup | True |
True |
Reads devices and creates entities; entity creation is a db_update, and skip_if_identifier_exists prevents duplicates. |
| Push config template | False |
True |
Changes device state, but same config applied each time converges. |
| Set hostname | False |
True |
Writes to device, but result is the same on repeat. |
| Send Slack notification | False |
False |
Side effect (message sent), not idempotent (sends again on retry). |
| Trigger external CI pipeline | False |
False |
External side effect with no convergence guarantee. |
| Increment a counter | False |
False |
Each execution changes the result. |
Note
A block that is is_pure=True is implicitly safe to repeat — it has no
side effects to repeat. You can set both flags to True for clarity, but
is_pure=True alone is sufficient for the engine’s retry logic.
Registration example
The config backup example demonstrates a pure, idempotent function block:
@register_function_block(
Registration(
name="configBackup",
description="Back up the running configuration of a network device.",
package="fb.examples.neops.io",
version=(1, 0, 0),
run_on="device",
fb_type="facts",
param_cls=ConfigBackupParams,
result_cls=ConfigBackupResult,
is_pure=True,
is_idempotent=True,
)
)
Both flags are True because the block connects to a device, reads the running
configuration, and stores it — without modifying any device state.
Workflow failure semantics
How the engine handles a failed step:
flowchart TD
A["Step N fails"] --> R{"retryable is not False, and the block is<br/>pure or idempotent, or the attempt<br/>said side_effects=False?"}
R -- "Yes (up to 3 retries)" --> S{"Retry succeeded?"}
S -- Yes --> H["Continue workflow"]
S -- No --> B
R -- No --> B{"Any non-pure step left that<br/>may have changed something?"}
B -- No --> C["FAILED_SAFE"]
B -- Yes --> E{"All of them<br/>reversible?"}
E -- Yes --> F["Roll back, wave by wave"]
F -- "all succeed" --> C
F -- "any fails" --> I["FAILED_UNSAFE"]
E -- No --> I
C --> D["Safe to retry<br/>entire workflow"]
I --> J["Manual intervention<br/>required"]
- Retry — a failed attempt is retried, up to three times, when its result
does not say
retryable=Falseand the block is pure, idempotent, or the attempt saidside_effects=False. - Nothing left that changed anything —
FAILED_SAFE. Retry the entire workflow. - Everything left is reversible — the engine rolls it back;
FAILED_SAFEif every rollback succeeds. - Otherwise —
FAILED_UNSAFE. Manual intervention required.
See the Workflow Engine’s retry and rollback documentation for the engine’s side of these rules.
Best practices
Be conservative. Only mark is_pure=True if you are certain the block has
zero side effects — no device writes, no external writes, no notifications, no
file mutations. Changes declared through the WorkflowContext are the only
exception.
Report side_effects=False only where it holds. The engine has no other
evidence: a step left out of the rollback is never undone. The safe places are
checks that run before anything is sent.
Prefer idempotent designs. Use replace over append,
skip_if_identifier_exists when creating entities, and full config templates
rather than incremental patches.
Document non-obvious cases. If idempotency relies on subtle API behavior (e.g., PUT semantics), add a comment near the registration explaining why.
What about acquire() and side effects?
The acquire() phase runs before run() and is used to claim shared
resources (device locks, rate-limit tokens, etc.). The purity and
idempotency flags apply only to the run() method. Resource acquisition
and release are managed separately by the engine and are always safe to
retry.
See also:
- Types & Safety (Engine) – How the engine uses
isPureandisIdempotentfor failure classification - Workflow as a Transaction – The transaction model that these flags drive
Next: Async and Concurrency — learn how to run operations in parallel and offload blocking work to threads.

