Skip to content

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.

is_pure=True

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_devices and global_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 PreContactError from 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 get side_effects=False and retryable=False.
  • It is for run() results only. The worker sends it on EXECUTE jobs 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.

is_idempotent=True

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

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"]
  1. Retry — a failed attempt is retried, up to three times, when its result does not say retryable=False and the block is pure, idempotent, or the attempt said side_effects=False.
  2. Nothing left that changed anything — FAILED_SAFE. Retry the entire workflow.
  3. Everything left is reversible — the engine rolls it back; FAILED_SAFE if every rollback succeeds.
  4. 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:


Next: Async and Concurrency — learn how to run operations in parallel and offload blocking work to threads.