Repository navigation
Run flow compute through pluggable backends, so a worker can run on another host - #827
Edwardvaneechoud wants to merge 99 commits into
Conversation
…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
… core before sending it work
…dshake # Conflicts: # flowfile_core/CLAUDE.md
…er handle a refused or unreachable worker
…core and worker CLAUDE.md
…ry and the execution context a flow owns
…the one reader of the legacy offload flag
# Conflicts: # flowfile_core/CLAUDE.md
…protects its built-in backends
… local branches delegating to it
# Conflicts: # flowfile_core/CLAUDE.md # flowfile_core/flowfile_core/flowfile/execution/registry.py
…; the three execution strategies are gone
…ion context's backend
✅ Deploy Preview for flowfile-wasm canceled.
|
|
Claude finished @Edwardvaneechoud's task in 32s —— View job Review of PR #827: compute interface and loud worker failuresI read the CLAUDE.md files, the error family, the handshake, the worker Findings
Tests and conventions
|
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.
…ol flag off and on
…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.
…otocol flag is on
…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.
…ts own catalog root
…ere the compute wrote them when the compute protocol flag is on
…on disk and in object storage
…paces in both compute protocol flag states
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.
…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.
# Conflicts: # flowfile/flowfile/__main__.py
# Conflicts: # flowfile_core/flowfile_core/flowfile/flow_graph/builders/ml.py
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 inlineexecution_locationbranches 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:
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 /shutdownanswers loopback peers only. A ratchet test pins the number of inline location compares and of modules reading the worker settings; both may only fall.flowfile_core/flowfile/execution/: theComputeBackendinterface and its value types, a registry (built-ins plus theflowfile.compute_backendsentry-point group;FLOWFILE_COMPUTE_BACKEND, else the offload flag) and anExecutionContextthat a flow derives from its settings and every stage thread and subflow sees.LocalBackendruns in core's process;WorkerBackendis the client offlowfile_workerover 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.OFFLOAD_TO_WORKERandWORKER_URLare read only by the backend registry; the ratchet asserts zero. A conformance suite checks the backend contract over both backends.FLOWFILE_COMPUTE_PROTOCOL. Worker/v1routes. 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.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
aws-cliandiam_roleconnections 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.