Skip to content

feat(workflow): Loop outputs, Loop retry and flow outputs (parity package #6) - #840

Merged
viethqb merged 19 commits into
mainfrom
feat/workflow-loop-outputs
Nov 7, 2026
Merged

viethqb merged 19 commits into
mainfrom
feat/workflow-loop-outputs

Conversation

@viethqb

@viethqb viethqb commented Nov 7, 2026 •

Copy link
Copy Markdown
Member

Summary

Workflow parity package #6 runs three things a stored flow already declared but egret never ran:

  • a Loop's outputs: and fetchType:;
  • a Loop's retry:, now inherited by each iteration;
  • the flow-level outputs:.

Each now behaves as Kestra does, except for the egret-only limits listed below.

Spec: docs/en/specs/2026-10-29-workflow-loop-outputs-design.md (+ VI). Plan: docs/en/plans/2026-10-29-workflow-loop-outputs.md. Roadmap row #6.

Changes

  • Loop outputs.
    • Evaluated at the end of each iteration and appended to the Loop run's outputs list as {item: {value, iteration, key?}, outputs}.
    • Every Loop run carries the counters iterationCount, runningIterations and terminatedIterations.
    • fetchType: STORE (or AUTO over a file values:) writes each iteration's outputs to outputs.ion, and the list holds {uri}.
    • A failed output fails the iteration. With transmitFailed: true, the default, that fails the Loop.
  • Loop retry. An iteration's tasks and Subflows inherit the nearest retry:, in this order: their own, an ancestor inside the iteration, the Loop's or its ancestors', then the flow's.
  • Flow outputs.
    • Evaluated when the execution ends, before afterExecution:. A failure appends FAILED.
    • {{ execution.outputs }} holds them, and a Subflow parent reads outputs.<sub>.outputs.
    • A Flow trigger's inputs: can use {{ trigger.outputs }}, merged with the window's for dependsOn.
  • API.
    • GET /executions/{id} gains outputs, with SECRET values shown as ******.
    • The list endpoint does not carry outputs, because they can reach 16 MiB per execution.
    • The OpenAPI spec and the generated client are regenerated.
  • Bug fix. A failed flowable task under an ancestor's or the flow's retry: now ends its parent FAILED. Before, the parent stayed RUNNING forever.
  • Migration 0214_workflow_execution_outputs.sql. It was built as 0211. Under D12 it was renumbered at merge time, because main had reached 0213 and no PR was open.
    • It adds workflow.executions.outputs jsonb and secret_outputs bytea, and task_runs.outputs_bytes.
    • Every statement is ADD COLUMN IF NOT EXISTS.
  • Whole-branch review fixes (no Critical; 4 Important, all resource bounds):
    • I1: a Loop append moves one entry, not the whole list, using a SQL append and a running byte total.
    • I2: settles no longer read a running Loop's list.
    • I3: SECRET values count toward the 16 MiB cap at their clear size.
    • I4: API responses are bounded.
    • Minors m1, m5, m7, m9 and m12 are fixed. The rest are recorded in spec §7.

egret-only limits (Kestra has none)

  • A Loop iteration's FETCH outputs may be at most 1 MiB, and the whole list at most 16 MiB. A flow's outputs may be at most 16 MiB. Each overflow fails with a message pointing to STORE or to a FILE output.
  • A type: FILE output must name a file of the execution itself or of its Loop family. Anything else is refused.
  • A SECRET output under STORE is refused. Elsewhere, SECRET outputs are sealed with secrets.master_key.
  • Duplicate output ids in a Loop's outputs: are refused at save, and so is secret() in an output's value:.

Behaviour change for stored flows. A flow saved before this release can now give a different answer. The full before/after table is in spec §7, "Release notes (#6)", and on the docs upgrade page.

Test plan

  • Oracle ratchet: 301 / 35 / 35 / 0 → 301 / 25 / 25 / 0. 10 entries are banked, including both subflow-secret-output-*.
  • Review fixes: each Important finding has a test that fails without its fix. Each was mutated once, then restored, and the restore was cmp-checked. For example, the_loop_book_moves_one_entry_per_iteration_end_at_any_list_length measures 298–366 bytes per iteration end at N = 2 000 and at N = 10 000.
  • After merging origin/main and the renumber (2026-11-07):
    • cargo fmt --check and workspace clippy --all-targets -D warnings are clean.
    • egret-workflow: 3 590 passed. The 7 failures are all sql_starrocks_live, which needs a live StarRocks (EGRET_TEST_STARROCKS_URL); the same 7 failed before the merge.
    • egret-server --lib passed, including the openapi currency and client digest gates. egret-metastore --lib passed, including the migration-version uniqueness test.
  • Live DBs: none of the shared dev or test DBs has a row for version 211, so nothing needs cleaning.

Documentation

egret-data/docs#67: Loop outputs, Loop retry and flow outputs. It updates inputs and outputs, flow control, error handling, execution detail, Flow triggers, the YAML reference and the REST reference. It also adds an upgrade note and marks KI-117 fixed.

Checklist

🤖 Generated with Claude Code

viethqb and others added 19 commits October 29, 2026 14:49
…try and flow outputs

Survey, spec and plan (EN + VI for spec and plan) for roadmap row #6 as
one PR: Loop outputs/fetchType evaluated per iteration, Loop retry
inherited by iterations, flow-level outputs with migration 0211
(provisional), SECRET outputs sealed and masked, FILE outputs scoped to
the execution, a 1 MiB / 16 MiB cap. Corrects the roadmap's premises
(8 banks, the four unattributed Loop entries named).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Ratchet 301 / 35 / 35 / 0 and census 295 / 25 / 21 on the merge
271b1c1, as predicted; the differing field of each of the eight
entries the package targets; migration 0211 still free on every local
ref.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…t save (package #6 A1)

models::Loop reads `outputs` (the flow's FlowOutput bean) and
`fetchType` (LoopFetchType, AUTO by default), and writes them back.
The not_honoured warning for both keys is gone; property-treatment.json
moves them to "honoured", and the schema README says what "honoured"
measures. A fence test holds every honoured key of the Flow and Loop
rows to being run by egret or warned.

Save-time checks: a Loop output needs a type (run-time gate, as the
flow's), output ids are unique on the flow (Kestra's rule) and on a Loop,
a SECRET Loop output under STORE, or AUTO with a string values:, is
refused, and a CREATE_NEW_EXECUTION retry that reaches a Loop iteration
warns, since worker::replay refuses it at run. The last three are
save-only.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…age #6 A2)

New `outputs::evaluate(cx, &[FlowOutput]) -> Result<Typed, OutputError>`,
a port of Kestra's renderOutputs + typedOutputs (spec D2): required
outputs render together and one failure fails them all; each optional
output renders alone and a failure becomes null plus a warning line; every
value is then coerced by its type with package #3's coercer
(`inputs_check::coerce_output`, Kestra's parseType). Every error names the
output. SECRET ids are reported for the caller to seal (D9); FILE is
checked for shape only (D10 is B4's). Not wired into the worker yet (A3,
B1), so the ratchet does not move.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…l (package #6 A3)

In the outbox phase of the settle that terminates a LOOP sub-execution,
read the Loop from the parent's source and, when it declares outputs:,
evaluate them with outputs::evaluate on the iteration's own surface (after
its errors:/finally:). A failure, or a FETCH map over 1 MiB, logs an ERROR
line on the sub-execution, appends FAILED after the terminal it reached
(CREATED RUNNING SUCCESS FAILED), persists it, and reports FAILED to the
Loop. Optional-output warnings are logged at WARN; every line is masked
with the render context's redactions. The typed map travels on the
terminal report to on_execution_terminal (A4 appends it).

Executor ratchet 301 / 35 / 35 / 0 -> 301 / 34 / 34 / 0:
valids/loop-outputs-failed-render banked; derived constants,
handler-surface.txt and the README's register re-measured.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…arent lock (package #6 A4)

on_execution_terminal now writes the Loop run's task_runs.outputs under
the parent row lock: iterationCount, runningIterations and
terminatedIterations on every Loop run (initialised by the first batch,
{0,0,{}} for a Loop with no values), and {item: {value, iteration, key?},
outputs} appended in termination order when the iteration's map is
non-empty and it was not a failure under transmitFailed. An append that
would take the list past 16 MiB fails the iteration (logged on the Loop
run); the limit can be lowered per workspace in tests.

Security, brought forward from B4 so A4 never stores either unchecked:
SECRET output values are stored as the mask and sealed with the
secret_inputs cipher into task_runs.secret_outputs (migration 0211, the
whole D11 migration), revealed only by the render surface and masked in
every line; a deployment with no master key fails the output naming
secrets.master_key. A FILE output must be a readable file of the
execution's loop family, else "egret refuses output <id>: <uri> is not a
file of this execution". The 1 MiB per-iteration cap now applies to
every fetchType until A5 writes STORE maps to storage.

Executor ratchet 301 / 34 / 34 / 0 -> 301 / 30 / 30 / 0: if-in-parallel
and loop-outputs (predicted), loop-outputs-auto and loop-outputs-store
(ahead of A5, states only) banked; derived constants, handler-surface.txt
and the README re-measured.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ation and resolve AUTO (package #6 A5)

fetchType STORE (and AUTO when the Loop run's values rendered to a URI,
Kestra's guessOutputMode, decided once per Loop run) writes the typed map
as Ion to kestra:///{ns}/{flow}/executions/{iteration}/outputs.ion,
indexes it in the iteration's transaction, and the list entry holds
{uri}. The STORE branch comes before the 1 MiB per-iteration cap, which
is now FETCH's only; the 16 MiB total still applies to the {uri} entries.

The write never happens inside a transaction: Prefetch::upload records
it, the transaction owner rolls back, writes the object with nothing
held and runs the attempt again (the prefetch rule applied to a write).

A SECRET output under STORE is refused at evaluation, before rendering,
naming the output and how STORE was reached (covers versions stored
before #6). STORE with no internal storage fails the iteration naming
[storage]. Ratchet unchanged at 301/30/30/0; loop-outputs-auto and
loop-outputs-store now match the artifact's content too.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…kage #6 A6)

sub_flow_source now attaches the nearest retry: of the Loop or of its
ancestors in the parent flow (else what an enclosing iteration
inherited) to the iteration's flow as x-egret-inherited-retry, a field
distinct from the flow's own retry. retry::policy_for consults it after
the run chain and before the flow's retry: own > ancestor inside the
iteration > inherited > flow, Kestra's searchForParentTaskWithRetry
precedence. An inherited CREATE_NEW_EXECUTION stays refused at run by
worker::replay. The key is refused at save as unknown_property.

Survey R3 pinned first: an iteration that goes FAILED then RETRYING in
one settle reports nothing to its Loop (passed before the change).

Also fixed, found by the precedence test: flowable::current_list asked
policy_for for a failed FLOWABLE run (a Loop under a Sequential with a
retry:, or any flowable under a flow-level retry) and held its list in
RetryPending for a retry retry::scan never schedules, so the execution
stalled RUNNING. "Will be retried" now uses scan's own gate
(Runnable/Executable only, ExecutorService.java:572).

Executor ratchet 301 / 30 / 30 / 0 -> 301 / 29 / 29 / 0:
valids/retry-flowable-parallel banked, as predicted. Derived constants,
handler-surface.txt (ceiling unchanged at 287) and README re-measured.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…rminal (package #6 B1)

Evaluate a flow's `outputs:` with the shared evaluator in the settle
that makes the execution terminal, before `afterExecution:` runs, and
store them in `workflow.executions.outputs` (migration 0211, landed in
A4). A failure logs `Failed to render output values` and appends FAILED
after the terminal; any terminal of an execution that ran evaluates
(FAILED and KILLED included, as Kestra's onEnd), never a Loop iteration
(`sub_flow_source` clears the parent's outputs, spec D6).

SECRET values are masked and sealed into `secret_outputs`, refused with
no master key; FILE values must be files of the execution's loop family
(D9, D10 brought forward from B4). `fail_after_terminal` no longer
appends FAILED to FAILED (Kestra's `State.withState` is a no-op there).

Banks `valids/flow-with-outputs-failed` and, unpredicted,
`valids/subflow-secret-output-child` (no master key on either harness):
ratchet 301/29/29/0 -> 301/27/27/0; census 295/25/21 unchanged.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… (package #6 B2)

`worker::render::Surface::load` reads `workflow.executions.outputs` and
reveals the sealed SECRET values (`secret_outputs`) to the execution's own
renders, adding them to the redactions so every line and failure message
masks them; NULL still renders `{}`. A Flow trigger's render context over
the execution (`execution_render_context`, whose rendered inputs become
another execution's) gets the masked map.

Controller decision 2026-10-29: flow-level outputs are capped at 16 MiB
serialised per execution (`MAX_FLOW_OUTPUTS_BYTES`); beyond it the
evaluation fails at the terminal pointing to a FILE output. Recorded in
spec 2.2 items 10-11 and section 6 (EN + VI).

Banks `valids/after-execution`: ratchet 301/27/27/0 -> 301/26/26/0;
census 295/25/21 unchanged.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…lish trigger.outputs and return outputs on the execution API (package #6 B3)

A waiting Subflow run now carries the child's stored flow outputs as
outputs.<sub>.outputs, with the child's sealed SECRET values copied into
task_runs.secret_outputs and revealed to the parent's own renders. A
child whose declared outputs were not stored fails the run as Kestra's
failSubflowDueToOutput: the child's state, then FAILED once, and one
fresh attempt [CREATED, FAILED] (new Command::FailSubflowOutputs). This
banks valids/subflow-secret-output-parent: ratchet 301/25/25/0.

A Flow trigger's inputs render with trigger: {outputs} bound when the
source's outputs are non-empty; the fired execution's trigger record
carries variables.outputs; a dependsOn window keeps its creator's
outputs and the fire deep-merges them as Kestra's MapUtils.deepMerge.
SECRET values stay masked throughout.

The Flow trigger render context now also hides a Loop run's and a
Subflow run's sealed outputs (controller decision); SECRET inputs there
are recorded as a pre-existing KI-112 limit in spec 2.2 item 12.

The execution API (get and list) returns outputs, masked; no statement
selects secret_outputs. openapi.json and the generated client are
regenerated.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… and pin the SECRET/FILE output surface (package #6 B4)

A read (the render snapshot and the FILE-input check) now also admits a
URI a waited-for Subflow child published as a flow output: the child row
must name the Subflow run that copied it, and the URI must lie in the
child's own loop family. The FILE-output rule (D10) and the files API keep
the plain readability decision, so a parent cannot re-publish the file.

Adversarial tests pin the rest of the B4 surface: a replay source's file,
a non-execution kestra:// URI and nsfile:// refused as flow FILE outputs;
a sibling iteration's file accepted as a Loop FILE output; secret() in an
output value refused at save and at run. Spec section 2.2 item 14 and
section 6 item 2 record it (EN and VI).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…d the fence-test sweep (package #6 Z)

- Spec section 7 "As built" and release notes (EN + VI); Q3/D9 corrected:
  both subflow-secret-output-* entries banked because the oracle's egret
  has no master key, as Kestra's harness; section 4 gets the measured
  sequence 35 -> 34 -> 30 -> 30 -> 29 -> 27 -> 26 -> 25 -> 25; section 6
  gains duplicate_output_id on a Loop, outputs.ion, nextOffset.
- Plan status (EN + VI) with the measured ratchet and gates; D12's
  networked migration re-check is still owed before merge.
- Roadmap row #6 done (EN + VI), note 6 closed, Appendix A corrected,
  #11 gains binding `trigger` in a fired flow's task renders.
- Oracle README and baseline.txt sweep for stale counts.
- keys.rs fence test: Loop.outputs/fetchType/retry and Flow.outputs move
  from the PACKAGE_6 list into RUN; still checked honoured.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
- Loop list appends no longer rewrite the list: SQL append with a running
  byte total in task_runs.outputs_bytes; SECRET entries sealed one by one.
- The inherited-run query reads terminal runs only.
- The 16 MiB cap counts SECRET values at their clear size.
- Execution outputs are on GET detail only; dependsOn-merged trigger outputs
  and window outputs are capped at 16 MiB.
- Flow-trigger context loads sealed columns masked; a child with an
  unparsable source fails the Subflow run unless KILLED.
- OpenAPI and the frontend client regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… 0211 to 0214

Main reached 0213 (SP2b). The migration is renamed under D12 and every
mention updated; the API client digest is regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@viethqb
viethqb merged commit 25a9291 into main Nov 7, 2026
9 checks passed
@viethqb
viethqb deleted the feat/workflow-loop-outputs branch November 7, 2026 15:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant