Skip to content

refactor(amber): read the loop input table from its materialization instead of shipping it in state - #6971

Open
aglinxinyuan wants to merge 6 commits into
apache:mainfrom
aglinxinyuan:loop-table-by-uri
Open

refactor(amber): read the loop input table from its materialization instead of shipping it in state#6971
aglinxinyuan wants to merge 6 commits into
apache:mainfrom
aglinxinyuan:loop-table-by-uri

Conversation

@aglinxinyuan

@aglinxinyuan aglinxinyuan commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

What changes were proposed in this PR?

The loop's input table used to ride inside the State content: LoopStart encoded its buffered input as Arrow IPC bytes, base64'd into the JSON content column, and that payload was re-written and re-read at every loop-body hop, every iteration.

That data already exists. In the fully-materialized mode loops require, the Loop Start's input-port materialization holds exactly the loop's input table for the whole loop — Loop Start re-reads it every iteration, and the back-edge truncates only the state doc at the same base URI, never the result doc. So this PR ships the port's base URI in the setup config and derives both addresses from it:

loopStartPortUris[LoopStart-id] = <base URI of LoopStart's input port>
        ├── state_uri(base)   → back-edge write address   (as before, derived)
        └── result_uri(base)  → the loop's input table    (NEW: read at EndChannel)
Piece Before After
proto field 4 loopStartStateUris = state URI loopStartPortUris = base URI (renamed so the semantic change is loud)
LoopStart's produced state user vars + IPC-encoded table (base64 in JSON) user vars only — small, pure JSON
Loop-body hops fat state re-materialized per hop, per iteration tiny state
LoopEnd's table decoded from state content read once per iteration from result_uri(base) at EndChannel, injected via a new runtime-only attach_loop_table hook
table_to_ipc_bytes / table_from_ipc_bytes second, divergent Arrow codec (lossy from_pandas inference) deleted — the read goes through the canonical iceberg reader

When the read happens matters. The read is issued at EndChannel (the matching state is stashed at consume and the operator's update runs in complete()), not at consume time. At consume time this worker's own materialization reader is still streaming, and issuing a second iceberg/S3 read from the main loop thread in that window made the reader fail with S3 Access DeniedLoopIntegrationSpec hung to the CI job timeout on both OSes. Deferring past the reader removes the overlap: integration went from a 20-minute cancel to green in ~9 minutes. The matching consume emits no state downstream, so moving it is unobservable outside the operator.

Semantics deliberately preserved:

  • the reserved-table collision raise stays (a user var named table would now be silently shadowed by the injected table — worse than before);
  • the "consumed" marker (_loop_table) is still set only by a successful run_update, so condition()'s short-circuit for pass-through-only Loop Ends is unchanged;
  • nested loops work by construction: the inner Loop Start's entry points at the outer Loop Start's output port, whose result doc is recreated per outer iteration but persists across inner iterations (the jump rewinds to the inner level only).

Wins: no ~33% base64 bloat, no JSON-column size ceiling on the table (large-table loops become viable), strictly less I/O for any non-empty loop body (one read per iteration replaces N state-doc writes+reads per hop), and one Arrow codec instead of two.

Note: this deepens the read-side use of storagePairs.head._1 — the same shared upstream URI as the known back-edge fan-out design discussion; if that ever moves to a per-loop private doc, this read moves with it.

Any related issues, documentation, discussions?

Builds on #5900 (State columns) and #6661 (envelope through JVM hops). Related design context: #6660.

How was this PR tested?

  • Unittest_loop_operators.py rewritten for the attach-based flow plus new pins: the produced state carries no table; attaching alone does not mark the loop consumed; run_update fails loud when no table was attached. test_main_loop.py pins the base-URI derivation for the back-edge write (state_uri(base)), the missing-config fail-loud, and that the matching consume stashes the state without touching storage, with the read + update happening once at EndChannel. test_initialize_executor_handler.py covers the renamed proto field. 237 tests green locally (the only failures in a full sweep are pre-existing environment ones, identical on unmodified main).
  • Scala — full test-compile (proto regen included), scalafmtCheckAll, scalafixAll --check, and the worker/descriptor spec suites (WorkerSpec, WorkflowWorkerSpec, SerializationManagerSpec, WorkflowExecutionManagerSpec, LoopStartOpDescSpec, LoopEndOpDescSpec) all pass on Java 17.
  • E2E — the four LoopIntegrationSpec cases (single, nested 3×3, JVM chain, nested JVM chain) exercise the full read path in the amber-integration CI job (both jobs green in ~9 min); the nested cases specifically cover the inner-loop read against the outer Loop Start's per-outer-iteration output doc.

Was this PR authored or co-authored using generative AI tooling?

Generated-by: Claude Code (Fable 5)

…nstead of shipping it in state

The loop's input table used to ride INSIDE the State content: LoopStart
encoded its buffered input as Arrow IPC bytes, base64'd inside the JSON
content, and that payload was re-written and re-read at every loop-body
hop, every iteration (~33% base64 bloat, JSON-column size limits, and a
second, divergent Arrow codec via pa.Table.from_pandas inference).

That data already exists: in the fully-materialized mode loops require,
the Loop Start's input-port materialization holds exactly the loop's
input table for the whole loop (Loop Start re-reads it every iteration;
the back-edge truncates only the state doc at the same base URI, never
the result doc). So ship the port's BASE URI in the setup config and
derive both addresses from it:

- proto: InitializeExecutorRequest.loopStartStateUris ->
  loopStartPortUris (field 4 unchanged); the value is now the base URI.
  WorkflowExecutionManager ships storagePairs.head._1 directly.
- back-edge write: main_loop derives state_uri(base) (unchanged
  behavior, one derivation later).
- table read: on the matching consume, MainLoop reads result_uri(base)
  and injects the table into the LoopEnd via a new runtime-only
  attach_loop_table hook; a read failure is reported like a UDF error
  (report_exception) instead of silently dropping the consume.
- LoopStartOperator.produce_state_on_finish emits only the user loop
  variables (the reserved-`table` collision raise stays: a user var
  named `table` would now be silently shadowed by the injected table);
  run_update uses the attached table; the "consumed" marker
  (_loop_table) is still set only by a successful update, so
  condition()'s short-circuit semantics are unchanged.
- table_to_ipc_bytes / table_from_ipc_bytes and the now-unused
  TableOperator._buffered_table accessor are deleted.

Nested loops keep working by construction: the inner Loop Start's map
entry points at the outer Loop Start's output port, whose result doc is
recreated per OUTER iteration but persists across inner iterations (the
jump rewinds to the inner level only).

Tests updated across test_loop_operators (attach-based flow, plus new
pins: attach alone does not mark the loop consumed; run_update fails
loud when no table was attached; produced state carries no table),
test_main_loop (base-URI derivation for the back-edge write, consume
injects the read table), test_initialize_executor_handler (renamed
field), and the Scala ctor sites. The four LoopIntegrationSpec e2e
cases exercise the full path in CI.
@github-actions

Copy link
Copy Markdown
Contributor

Automated Reviewer Suggestions

Based on the git blame history of the changed files, we recommend the following reviewers:

  • Contributors with relevant context: @Yicong-Huang
    You can notify them by mentioning @Yicong-Huang in a comment.

@codecov-commenter

codecov-commenter commented Jul 28, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 79.37%. Comparing base (7d5276d) to head (a35a63b).
⚠️ Report is 1 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff            @@
##               main    #6971   +/-   ##
=========================================
  Coverage     79.37%   79.37%           
- Complexity     3788     3789    +1     
=========================================
  Files          1158     1159    +1     
  Lines         46131    46114   -17     
  Branches       5124     5124           
=========================================
- Hits          36618    36605   -13     
+ Misses         7889     7887    -2     
+ Partials       1624     1622    -2     
Flag Coverage Δ *Carryforward flag
access-control-service 70.00% <ø> (ø)
agent-service 77.42% <ø> (ø) Carriedforward from adb3ae6
amber 72.56% <ø> (+0.01%) ⬆️ Carriedforward from adb3ae6
computing-unit-managing-service 20.49% <ø> (ø) Carriedforward from adb3ae6
config-service 66.66% <ø> (ø)
file-service 67.21% <ø> (ø) Carriedforward from adb3ae6
frontend 83.05% <ø> (+0.01%) ⬆️ Carriedforward from adb3ae6
notebook-migration-service 78.94% <ø> (ø)
pyamber 97.37% <ø> (-0.02%) ⬇️ Carriedforward from adb3ae6
workflow-compiling-service 26.31% <ø> (ø) Carriedforward from adb3ae6

*This pull request uses carry forward flags. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@github-actions

github-actions Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

⚠️ Benchmark changes need a look

🟢 2 better · 🔴 7 worse · ⚪ 6 noise (<±5%) · 0 without baseline

Compared against main 7d5276d benchmarked on this same runner, so the delta is largely free of cross-runner hardware noise. The "7d avg" column still reflects the gh-pages dashboard. Treat <±5% as noise unless repeated.

Dashboard · Run

config throughput MB/s latency max Δ latest / 7d
🔴 bs=10 sw=10 sl=64 545 0.332 18,222/27,524/27,524 us 🔴 +14.0% / 🔴 +74.5%
🔴 bs=100 sw=10 sl=64 1,160 0.708 84,652/109,903/109,903 us 🔴 +15.0% / 🟢 +16.0%
🟢 bs=1000 sw=10 sl=64 1,356 0.828 737,712/764,288/764,288 us 🟢 -10.7% / 🟢 +31.5%
Baseline details

Latest main 7d5276d from same runner

config metric PR latest main 7d avg Δ latest Δ 7d
bs=10 sw=10 sl=64 throughput 545 tuples/sec 589 tuples/sec 786.12 tuples/sec -7.5% -30.7%
bs=10 sw=10 sl=64 MB/s 0.332 MB/s 0.36 MB/s 0.48 MB/s -7.8% -30.8%
bs=10 sw=10 sl=64 p50 18,222 us 15,984 us 12,305 us +14.0% +48.1%
bs=10 sw=10 sl=64 p95 27,524 us 25,624 us 15,774 us +7.4% +74.5%
bs=10 sw=10 sl=64 p99 27,524 us 25,624 us 18,978 us +7.4% +45.0%
bs=100 sw=10 sl=64 throughput 1,160 tuples/sec 1,198 tuples/sec 999.71 tuples/sec -3.2% +16.0%
bs=100 sw=10 sl=64 MB/s 0.708 MB/s 0.731 MB/s 0.61 MB/s -3.1% +16.0%
bs=100 sw=10 sl=64 p50 84,652 us 81,432 us 100,616 us +4.0% -15.9%
bs=100 sw=10 sl=64 p95 109,903 us 95,530 us 107,356 us +15.0% +2.4%
bs=100 sw=10 sl=64 p99 109,903 us 95,530 us 113,255 us +15.0% -3.0%
bs=1000 sw=10 sl=64 throughput 1,356 tuples/sec 1,349 tuples/sec 1,031 tuples/sec +0.5% +31.5%
bs=1000 sw=10 sl=64 MB/s 0.828 MB/s 0.823 MB/s 0.63 MB/s +0.6% +31.5%
bs=1000 sw=10 sl=64 p50 737,712 us 734,536 us 980,328 us +0.4% -24.7%
bs=1000 sw=10 sl=64 p95 764,288 us 855,425 us 1,027,528 us -10.7% -25.6%
bs=1000 sw=10 sl=64 p99 764,288 us 855,425 us 1,054,298 us -10.7% -27.5%
Raw CSV
config_idx,batch_size,schema_width,string_len,num_batches,total_ms,total_tuples,total_bytes,tuples_per_sec,mb_per_sec,lat_p50_us,lat_p95_us,lat_p99_us
0,10,10,64,20,367.27,200,128000,545,0.332,18222.41,27523.75,27523.75
1,100,10,64,20,1724.81,2000,1280000,1160,0.708,84652.00,109903.00,109903.00
2,1000,10,64,20,14747.63,20000,12800000,1356,0.828,737711.78,764287.87,764287.87

@mengw15 mengw15 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left one comment

# Reading it here at consume time means the table never has to ride
# inside the State content through the loop body.
result_uri = VFSURIFactory.result_uri(self._loop_start_base_uri())
document, _ = DocumentFactory.open_document(result_uri)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

amber-integration is red on both ubuntu and macos here: LoopIntegrationSpec never completes (job cancelled at 20m), with Access Denied errors from the storage read path. The same suite is green on #6884 and on #6974 ~1.5h earlier with none of those, so it looks like it may be coming from this PR rather than environment noise.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — both integration jobs are green now (~9 min each, was a 20 min cancel).

Cause was the timing of the new read, not the read itself: at consume time this worker's own materialization reader is still streaming, and issuing a second iceberg/S3 read from the main loop thread in that window made the reader fail with Access Denied. The read now happens at EndChannel, after the reader has finished — the matching consume emits nothing downstream, so nothing else moves.

(The macos run also hit the unrelated worker still has unprocessed messages race that #6960 fixes; it passed on re-run.)

@aglinxinyuan
aglinxinyuan marked this pull request as draft July 29, 2026 05:05
…lap the reader

CI experiment for the amber-integration failure reported in review: the
Access Denied errors are NOT in the new read path (0 of 56 tracebacks
contain _read_loop_input_table) and never on the doc it opens. They are in
this worker's own materialization READER thread, on loop-INTERNAL docs
(LoopStart / Limit outputs) that region re-execution drops and recreates
while IcebergDocument.get() iterates a lazily-pinned snapshot -- MinIO
answers a deleted key with 403 Access Denied.

The read itself is fine (the Loop Start's input-port materialization is
created once upstream of the loop and is stable for the whole run), but
issuing it at consume time puts a second iceberg/S3 read on the main loop
thread WHILE that reader is still streaming. Move it out of that window:
stash the matching state at consume and run the operator's update (with the
table attached) at EndChannel, by which point the reader has finished.

If this turns amber-integration green, the overlap was the trigger; if the
Access Denied errors persist, the recreate-vs-active-reader race is an
engine bug independent of this PR.

The matching consume emits no state downstream, so deferring it changes
nothing observable outside the operator.
@aglinxinyuan
aglinxinyuan marked this pull request as ready for review July 29, 2026 05:59
@aglinxinyuan
aglinxinyuan requested a review from mengw15 July 29, 2026 06:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants