Skip to content

Run flow compute through pluggable backends, so a worker can run on another host - #827

Draft
Edwardvaneechoud wants to merge 99 commits into
mainfrom
compute/integration
Draft

Edwardvaneechoud wants to merge 99 commits into
mainfrom
compute/integration

Conversation

@Edwardvaneechoud

@Edwardvaneechoud Edwardvaneechoud commented Oct 11, 2026 •

Copy link
Copy Markdown
Owner

Closes #823

Long-lived branch: it collects the work that lets a flow's compute run on a worker on another host, in reviewed pieces that land on it as pull requests (each marked "Part of #823"), and it merges when that work is complete. It stays a draft until then. The roadmap's Remote workers entry describes the direction.

Why

Core never holds a dataset. For each node it builds a Polars query plan and posts it to flowfile_worker, which runs it in a child process, writes the result to its own cache directory and answers with a plan over that file. That works because both services see one disk. Core opens the worker's files itself for previews, the formula editor's first row, pivot values, column statistics, the published data API, exports, catalog previews and the check whether a cached result still exists. The local-or-remote decision lives in globals (OFFLOAD_TO_WORKER, WORKER_URL) and in inline execution_location branches across the engine. A failure between the two services used to degrade silently into in-core execution. And nothing can say that a node cannot run on a given compute.

What this branch delivers

In order, each piece as its own pull request into this branch:

  • Loud failures. A typed error family (ComputeError: ComputeUnavailable, VersionMismatch, ResultMissing, PlanRejected, OperationUnsupported, ForeignRef, TaskFailed). An unreachable worker is never read as a cache miss. The in-core continuation after a worker failure and both fallbacks of the published-API runner are gone: a plan the worker cannot load fails the node, and the published API answers 503 when the compute is unreachable. A version handshake on first contact (GET /capabilities: app, Polars and Python versions, platform, plugin versions and plugin library paths, plus a functional probe per expression plugin) refuses a mismatched worker. POST /shutdown answers loopback peers only. A ratchet test pins the number of inline location compares and of modules reading the worker settings; both may only fall.
  • One execution seam. flowfile_core/flowfile/execution/: the ComputeBackend interface and its value types, a registry (built-ins plus the flowfile.compute_backends entry-point group; FLOWFILE_COMPUTE_BACKEND, else the offload flag) and an ExecutionContext that a flow derives from its settings and every stage thread and subflow sees. LocalBackend runs in core's process; WorkerBackend is the client of flowfile_worker over its existing routes. Nodes submit their plan with hints, and each backend's materialisation policy reproduces the previous behaviour. Every operation a node runs whole (readers, writers, catalog Delta writes, fuzzy join, random split, custom nodes, ML train and apply, the kernel parquet hand-off, the custom-node dry run) and every bounded read the engine makes (a count, a first row, a null profile, pivot values, schema and column statistics, the Explore Data node's charts) goes through the backend, with no in-core fallback when it fails.
  • The worker settings read in one place. OFFLOAD_TO_WORKER and WORKER_URL are read only by the backend registry; the ratchet asserts zero. A conformance suite checks the backend contract over both backends.
  • No shared disk, behind FLOWFILE_COMPUTE_PROTOCOL. Worker /v1 routes. A result is a reference with its schema and row count; previews, counts and schemas come over the wire; plans carry credential references, never secrets; catalog tables are addressed by identity. Proof: core and worker started with different storage directories run a flow with previews.
  • Remote workers. Capabilities, node requirements and placement, which refuses a flow its backend cannot run before it starts; per-node execution_location; headless runs can name a backend; data crosses between two backends through export and import; a CI job runs core and a worker with no shared volume through a database read, a transform and a catalog write; the flag defaults on and the legacy routes are deprecated; a deployment page and a developer page describe the two-host setup.

Per-node placement across backends with an exchange store, and Polars Cloud as a backend, come after this branch.

Behaviour changes, all deliberate

  • An OOM-killed worker child fails the node instead of moving the dataset into core.
  • The published API answers 503 instead of collecting in core when the worker is unreachable.
  • A node's cached result does not survive a core restart.
  • The export route and the kernel parquet hand-off follow the flow's execution location.
  • Later, behind the flag: cloud reads resolve aws-cli and iam_role connections with the executor's identity.

Verification

CI runs on every push to this branch and on every pull request into it: core, worker and frame suites, Windows, web and end-to-end. The core conftest restores the default backend after every test and fails the test that changed it. Ratchet today: 1 inline compare and 6 reader modules, down from 19 and 14.

…inuing in core; the published API no longer collects in core
… failure path; local runs never probe the worker
…tion

# Conflicts:
#	flowfile_core/CLAUDE.md
#	flowfile_core/flowfile_core/flowfile/flow_node/flow_node.py
…dshake

# Conflicts:
#	flowfile_core/CLAUDE.md
# Conflicts:
#	flowfile_core/CLAUDE.md
#	flowfile_core/flowfile_core/flowfile/execution/registry.py
@netlify

netlify Bot commented Oct 11, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for flowfile-wasm canceled.

Name Link
🔨 Latest commit c3ebc74
🔍 Latest deploy log https://app.netlify.com/projects/flowfile-wasm/deploys/6ada71b1fa916aa1ec501ea9

@claude

claude Bot commented Oct 11, 2026 •

Copy link
Copy Markdown

Claude finished @Edwardvaneechoud's task in 32s —— View job


Review of PR #827: compute interface and loud worker failures

I read the CLAUDE.md files, the error family, the handshake, the worker /shutdown and /capabilities changes, and api_runner._materialize. I did not read through the rest of the 79 files line by line, and I ran no tests. The design is coherent and the failure contract is clear. These are the points I'd look at before merging.

Findings

  1. Handshake gate is open to other threads while the check runs (flow_data_engine/subprocess_operations/handshake.py, ensure_handshake).

    • _handshake_done = True is set before the checks, so the probe's own calls pass through. The side effect is that any other thread calling the worker during that window skips the gate. This includes parallel stage threads, API requests and a REST-fallback poller.
    • On a mismatched worker, one of those threads can ship its plan before VersionMismatch is raised. The docstring covers the probe's calls but not this case.
    • A re-entrancy marker would keep the guarantee ("nothing goes to a mismatched worker") without losing the probe path. For example, use a thread-local flag or a threading.local set only on the probe thread, plus a lock so concurrent callers wait for the first result.
    • The module global is also unguarded when reset_handshake is called from the session on an outage. A failed check clearing the flag can race with a concurrent success.
  2. Handshake probe imports polars_ds inside _probe_plans.

    • If polars_ds is missing or fails to import in core, _probe_plans() raises outside the try/except ComputeError block in probe_plugins. The error would surface as a raw ImportError, not a typed compute error.
    • If polars_ds is a required dependency, say so. Otherwise skip plugins that are absent on both sides.
  3. /shutdown loopback gate.

    • It reads request.client.host, which is correct for a non-proxied setup. If the worker runs behind uvicorn with --proxy-headers or forwarded_allow_ips, request.client can be rewritten from X-Forwarded-For, which a remote caller could set. Please confirm the worker's uvicorn config does not enable that.
    • I found no in-repo caller of the worker /shutdown other than the Tauri shell (127.0.0.1), so docker/compose is unaffected. Worth a line in flowfile_worker/CLAUDE.md that a containerised orchestrator can no longer stop the worker this way.
  4. api_runner._materialize (flowfile_core/flowfile_core/flowfile/api_runner.py:139).

    • file_ref=f"__api_{api_node.hash}" is the same for concurrent requests with identical parameters on the same node. Two simultaneous requests would share a worker result ref, and one could clear or overwrite the other's result. This may predate the PR, but now that failures are loud it would show up as a spurious ResultMissing or TaskFailed. Adding a per-request uuid to the ref would remove the risk.
    • The final result.collect() is a core-side collect of a FlowDataEngine. It is capped by max_rows and predates the PR, but it is still a core materialisation. Confirm it fits the "core never collects" contract when max_rows is None.
  5. errors.worker_failure string handling.

    • ComputeUnavailable.__str__ branches on the lowercase text "connection refused". This is fragile across OS and library versions (Windows reports a different message). Consider matching on the exception type where it is raised and passing a flag.
    • The worker's time-limit kill surfaces as PlanRejected, per your docstring. That is misleading for users, who would read it as a bad plan. A dedicated TaskFailed or a timeout subtype would be clearer. If you keep it, make sure the message text always says "time limit".
  6. Deliberate behaviour changes to confirm with release notes.

    • An OOM-killed child now fails the node instead of falling back to core.
    • The published API now answers 503.
    • A node's cache no longer survives a core restart.
    • These are documented in flowfile_core/CLAUDE.md. The cache change is the most user-visible (re-running scheduled flows after a restart), so it deserves a changelog entry.

Tests and conventions

  • Test coverage looks thorough: handshake, results-exists-unreachable, plan-rejected, held-results release, and the ratchet test.
  • The ratchet test is a good guard. Make sure it fails with a readable message that tells the next contributor what to do.
  • Docstrings in handshake.py and errors.py are long relative to the repo's "one short line" comment rule in CLAUDE.md. They carry real why content, so I'd trim rather than remove them.
  • No database/models.py changes, so no Alembic migration is needed.
  • No public FlowFrame API change that I saw, so no stub regeneration needed.

Database, Kafka, Google Analytics, REST and non-lazy file reads each build
one frozen payload dataclass (execution/payloads.py) and run it as a single
operation on the current execution context's backend, instead of branching
on the flow's execution location. LocalBackend runs the read in core (the
Kafka consumer moved in from the builder, its offsets reported as operation
metadata); WorkerBackend turns the same payload into the worker's read
settings with credentials still encrypted and tracks the task under the
payload's job id, so cancel reaches it. Read results are held as deferred
plans and released once the node's engine is built.
One payloads module for every read and write kind, all on OperationPayload;
the reader sites call FlowNode.run_op like the writer sites, and the file
read, which runs before its node is bound, flow_node.run_operation.
…he backend wrote

Why: core resolved a stored result's schema by opening the file the backend wrote. Every engine built on a
`plan_for` leaf ran `collect_schema()` on it, so a result kept where core cannot read it failed its node, and a
worker result over the `/v1` routes lost an extension dtype its reference carried (a `geoarrow.wkb` column came back
as `Binary`). With the protocol flag on, core also never asked whether such a result was still there, so a node whose
result the worker had dropped was skipped as if it still held it.

`flow_node/flow_node.py`: `_take_result` builds a stored result's engine with the schema and the row count its
reference carries; a deferred one keeps the schema the node built its plan with.

`flow_graph/builders/` (`connectors.engine_from_read`, the fuzzy match, the random split, custom nodes, the
apply-model node): the engine over each operation result takes `schema=ref.schema`. With the flag off a run changes
in nothing but the schema read it no longer makes: each backend resolved the reference's schema from the same plan,
through the same conversion the engine uses.

`flow_node/models.py`: `NodeResults.artifact_is_missing` still checks a result with a file on the shared disk there.
With `FLOWFILE_COMPUTE_PROTOCOL` on, a held result without one is asked of the backend that minted it (`exists`,
`HEAD /v1/results/{id}` on the worker), so a node whose result is gone runs again; an unreachable worker raises
`ComputeUnavailable`, which the executor records as a node failure, never as a missing result. That is one `exists`
per held node on each decision, two for a cached node. With the flag off nothing changes.

`flowfile_core/CLAUDE.md` describes both.

Tests: `test_unreadable_location.py` runs a flow (manual input, filter, select, a cached join and group_by, a random
split) on a test backend whose stored results are on disk only while one of its own calls runs, with the flag off
and on: the run, the previews and the predicted schemas below the stored results read no file, and with the flag on
a result the backend dropped reruns its node. Two more run over the session worker with the flag on: a stored sort
keeps its geometry column, and a result released on the worker reruns its node. 6 passed (6 failed before the
change). Flag off, the execution and flow_node suites, `test_flowfile.py`, `test_ml_train_apply.py` and the
custom-node worker tests: 1049 passed, 2 skipped, 7 deselected (the ADLS and GCS cases, whose emulators were not
running, and `test_worker_down_gives_clean_node_error`). Flag on, the conformance suite and the `/v1` backend tests:
127 passed, 2 skipped. Not run: the full core suite and the Docker-marked suites. With the flag on,
`flow_node/test_executor.py` has five failures in tests that build references with no holding backend or expect the
task routes' files; three of them failed before this change as well.
…ough the backend when the compute protocol flag is on
…ults core cannot read and over the worker's v1 routes
… gates, column stats and CSV export

Why: with the compute protocol flag on, the formula editor's input row, a formula gate's probe, column statistics
and the CSV export still opened the file a backend stored for a node's result, from core. Over the worker's `/v1`
routes the editor showed no row at all, and over a result kept where core cannot read it each of the four failed.

`flowfile/extensions.py`: the editor's row of an input that has run comes from the backend that holds its reference,
the first row of the node's cached sample, else `sample(ref, 1)`. A secondary output holds no reference of its own,
so its row is read with `bounded.head_rows` on that backend.

`flow_graph/execution.py`: `_gate_formula_matches` reads its one row through `bounded.head_rows` on the run's
backend, so the predicate runs where the result lives, and a formula error comes back as that backend's error, which
the stage loop already reports as a failed gate.

`flow_data_engine/column_stats.py`: the column's dtype comes from the engine's cached schema, which a result engine
carries from its reference, instead of from resolving its plan.

`routes/routes.py`: `/node/data/export` takes its schema checks from the engine's cached schema, reads the first
`limit` rows through `bounded.head_rows` on the flow's backend and writes the CSV in core with `LocalBackend`'s
`file_write`, so the worker no longer writes into core's temp directory for core to serve. An export of a result the
worker has dropped now answers 422 instead of 409.

With the flag on, an editor evaluation over an input with no cached sample (Performance mode) asks its backend for
one row each time, and a gate probe and an export are a deferred submit and a sample each, as a column statistics
query already was. With the flag off every site does what it did. `flowfile_core/CLAUDE.md` describes the gate probe
and the three readers.

Tests: `test_unreadable_location.py` runs a flow (manual input, a cached select, a formula, a random split, and a
formula gate controlled by the split's second output) with the flag on, on a test backend whose stored results are
on disk only while one of its own calls runs: the editor's row with and without the cached sample, the gate's run,
column statistics and a three-row export read no file. The same flow over the session worker's `/v1` routes reads
the same rows over the wire, and no `file_write` reaches the worker. 11 passed (the five new tests failed before the
change). The formula preview, column statistics and gate suites: 207 passed with the flag off and on. The editor,
formula-chain, column statistics and export endpoint tests: 24 passed, 157 deselected, with the flag off and on (the
two first-row tests failed with the flag on before). The execution package with the flag off: 603 passed, 2 skipped.
Not run: the full core suite and the Docker-marked suites. With the flag on, `test_bounded.py` still fails the 15
tests that fake failures and count tasks on the task routes, which the flag bypasses, as it did before this change.
…ere the compute wrote them when the compute protocol flag is on
Why: with the compute protocol flag on, the catalog writer still told the compute where to write a table. Core
resolved the table's directory on its own disk, sent that path to the worker and registered and read the table back
at it, so a table in the catalog tables directory was written correctly only where core and the worker share that
disk.

`execution/payloads.py`: `CatalogTableIdentity` names a catalog table by its id (`None` for a table the write
creates), its directory under the catalog's storage root and, for object storage, that root's `base_uri`. The three
Delta payloads take it as `table`, with `path` then `None`; the cloud storage writer keeps sending a path.

`flow_graph/catalog_write.py`: with the flag on, the writer builds the identity from the destination it resolved and
sends no path. A table whose stored location is not its catalog's root joined with a directory name (one registered
at a path of its own, or under another spelling of the root) is refused before anything touches it, with a message
naming the location. After the write, the registration, both directory cleanups, the change-feed enable and the SCD2
output scan use the `table_path` the compute answered, read with the catalog's storage options as before; a skipped
write answers no path and the table stays where it was.

`execution/backends/local.py` joins the name onto the `base_uri` or onto the catalog tables directory.
`execution/backends/worker.py` sends `table_id`, `table_name` and a `storage_payload` holding the `base_uri` beside
the encrypted connection, and no `output_path`. In the worker, `write_delta`, `merge_delta` and `scd2_delta` resolve
such a table under the `base_uri` or their own catalog tables directory, joined as `/catalog/materialize` joins a new
table, fail a name with a path separator or `..`, and report the location they wrote.

With the flag off nothing changes: the payload carries the path core resolved, the worker gets `output_path` and core
keeps its own location. With the flag on, a table outside its catalog's root can no longer be written by the catalog
writer, on either backend. Both `CLAUDE.md` files describe the identity.

Tests: the worker's Delta suites, 74 passed (69 before), with the three operations writing a table by name under the
catalog directory, a name that is a path failing the task, and a write by name into MinIO. The catalog writer suites
over local and MinIO-backed namespaces, 373 passed with the flag off and 373 with it on (344 before): what each
backend is sent in both flag states, overwrite, upsert and SCD2 into object storage on both backends, a table the
compute moved registered and read where it answered, and a table outside its catalog root refused with the flag on
and appended to with it off. The execution package, 632 passed, 2 skipped; the cloud storage writer's Delta tests, 18
passed, 1 skipped (no Azurite). Not run: the full core and worker suites and the Docker-marked suites.
@Edwardvaneechoud
Edwardvaneechoud removed this pull request from stack #829 October 13, 2026 07:53
@Edwardvaneechoud
Edwardvaneechoud added this pull request to stack #835 October 13, 2026 07:53
@Edwardvaneechoud
Edwardvaneechoud removed this pull request from stack #835 October 13, 2026 09:11
@Edwardvaneechoud
Edwardvaneechoud added this pull request to stack #836 October 13, 2026 09:11
…e/no-shared-disk

# Conflicts:
#	flowfile_core/tests/flowfile/execution/test_catalog_compute.py
… carries

Why: the engine over a read now takes its schema from the result reference instead of reading it back from the worker's file, so a fake reference with no columns stamps no fields on the node and the remote-execution test failed on every platform.

The fake builds the reference with the frame's columns, as a real worker read does.

Tests: flowfile_core/tests/flowfile/test_rest_api_reader.py, 15 passed.
…iter tests

Why: the tests' helper split the registered path on the forward slash only, so on Windows the expected table name was the whole path and the table moved elsewhere was moved onto itself; three tests failed on the Windows shard.

The helper splits on either separator; URIs are unchanged.

Tests: flowfile_core/tests/flowfile/execution/test_catalog_compute.py, 57 passed with a worker.
@Edwardvaneechoud
Edwardvaneechoud removed this pull request from stack #836 October 13, 2026 17:15
@Edwardvaneechoud
Edwardvaneechoud added this pull request to stack #839 October 13, 2026 17:15

This branch has not been deployed

No deployments
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.

Describe strategy for decoupling compute from Flowfile

1 participant