Skip to content

fix(amber): carry the loop StateFrame envelope through JVM operators#6661

Merged
aglinxinyuan merged 4 commits into
apache:mainfrom
aglinxinyuan:loop-scala-state-envelope
Jul 22, 2026
Merged

fix(amber): carry the loop StateFrame envelope through JVM operators#6661
aglinxinyuan merged 4 commits into
apache:mainfrom
aglinxinyuan:loop-scala-state-envelope

Conversation

@aglinxinyuan

@aglinxinyuan aglinxinyuan commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

What changes were proposed in this PR?

A loop with a Java/Scala built-in operator in its body could never iterate — the Loop End worker crashed on the first iteration:

RuntimeError: no loop-back state URI configured for LoopStart ''

Root cause. Per-iteration loop state rides a StateFrame envelope — loop_counter (nesting-depth countdown) and loop_start_id (the back-jump target) — materialized as their own columns next to content (#5900). The Python worker carries the envelope on every hop; the Scala side stripped it at every seam, so any JVM hop re-emitted states with the "no loop" defaults (0, ""):

LoopStart ──(0, LS-id)──▶ Limit(Scala) ──(0, "")──▶ LoopEnd
                            ▲ envelope stripped        │
                                                       ▼
                                     back-jump lookup by "" fails

In a nested loop the zeroed loop_counter additionally makes the inner Loop End mis-consume the enclosing loop's state instead of passing it through.

Fix. Loop operators are Python-only, so a JVM operator only ever needs to carry the envelope through unchanged (the +1/−1 bookkeeping lives in the Python runtime). The columns already exist in the materialized State schema and on the Arrow flight wire — no format change, purely Scala-side plumbing:

Seam Change
StateFrame (DataPayload.scala) gains loopCounter / loopStartId fields (defaults 0 / "", mirroring Python's StateFrame)
State (State.scala) loopCounterFrom / loopStartIdFrom extractors read the envelope columns back off a row
InputPortMaterializationReaderThread rebuilds the envelope when replaying materialized states
DataProcessorOutputManager the incoming envelope is threaded through processInputState to emitState / saveStateToStorageIfNeeded / sendState
PythonProxyClient / PythonProxyServer envelope preserved across the Arrow flight bridge in both directions

Scala-originated states (start/end-channel handlers) keep the defaults — correct, since a JVM operator can never be a Loop Start.

Size: the fix itself is +38 / −17 lines of code (excluding comments/blanks; +78 / −20 gross) across the 8 source files above; the remaining +255 lines are test cases (4 unit specs + 4 e2e workflows).

Any related issues, documentation, discussions?

Closes #6660

How was this PR tested?

  • Unit — each seam is pinned locally: StateSpec (envelope column round-trip + no-loop defaults), DataProcessorSpec (a state pass-through emits the exact incoming envelope, not just a StateFrame), OutputManagerSpec (emitState stamps the envelope onto every buffer), NetworkOutputBufferSpec (sendState stamps the frame).
  • E2E — four new LoopIntegrationSpec cases run real workflows with JVM operators in the loop body:
    1. single loop with Limit (TextInput → LoopStart → Limit → LoopEnd, asserts 3 accumulated iterations — reproduces the reported failure verbatim, fails against the pre-fix code);
    2. nested 3×3 loop with Limit in the inner body (asserts 9 outer / 3 inner rows — pins that the counter magnitude survives the JVM hop, not just the id);
    3. single loop with a chain of two JVM operators (Limit → Sleep(0)) — pins the JVM→JVM state handoff (one Scala worker writes the envelope columns, another reads them back), which the single-op cases never exercise;
    4. nested 3×3 loop with the chain inside the inner body — the strongest combination: nested (counter magnitude) × multi-hop × both operator kinds.

Manually tested with the this workflow: loop.json
Screenshot 2026-07-21 at 20 56 23

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

Generated-by: Claude Code (Fable 5)

A loop with a Java/Scala built-in operator (e.g. Limit) in its body could
never iterate: the Loop End worker crashed with "no loop-back state URI
configured for LoopStart ''". Per-iteration loop state rides a StateFrame
envelope (loop_counter + loop_start_id, materialized as their own columns
next to content), and the Python worker carries it on every hop -- but the
Scala side stripped it at every seam, so any JVM hop re-emitted states with
the "no loop" defaults (0, ""). In a nested loop the zeroed counter also
made the inner Loop End mis-consume the enclosing loop's state.

Loop operators are Python-only, so a JVM operator only ever needs to CARRY
the envelope through unchanged (the +1/-1 bookkeeping lives in the Python
runtime). The columns already exist in the materialized State schema and on
the Arrow flight wire; this threads them through the Scala side:

- StateFrame gains loopCounter / loopStartId fields (defaults 0 / "",
  mirroring the Python StateFrame in core/models/payload.py).
- State gains loopCounterFrom / loopStartIdFrom extractors to read the
  envelope columns back off a materialized/transported row.
- InputPortMaterializationReaderThread rebuilds the envelope when replaying
  materialized states.
- DataProcessor threads the incoming envelope through processInputState to
  OutputManager.emitState; emitState / saveStateToStorageIfNeeded /
  NetworkOutputBuffer.sendState stamp it onto the outgoing frame and the
  storage write.
- PythonProxyClient / PythonProxyServer preserve the envelope across the
  Arrow flight bridge in both directions.

Scala-originated states (start/end-channel handlers) keep the defaults --
correct, since a JVM operator can never be a Loop Start.

Tests: unit specs pin each seam (StateSpec envelope round-trip,
DataProcessorSpec pass-through equality, OutputManagerSpec emit stamping,
NetworkOutputBufferSpec sendState stamping); two new LoopIntegrationSpec
e2e cases run a single and a nested loop with a Scala Limit operator in the
loop body (the single-loop case reproduces the reported failure verbatim).
Copilot AI review requested due to automatic review settings July 21, 2026 01:09
@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, @Ma77Ball
    You can notify them by mentioning @Yicong-Huang, @Ma77Ball in a comment.

Copilot AI 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.

Pull request overview

Fixes Amber loop execution when a JVM (Scala/Java) operator appears inside a loop body by preserving the per-iteration loop “envelope” (loop_counter, loop_start_id) end-to-end on the Scala side. This aligns Scala’s StateFrame handling with the existing materialized State schema / Arrow wire format so loop bookkeeping survives any JVM hop.

Changes:

  • Extend Scala StateFrame to carry loopCounter / loopStartId, and plumb them through DataProcessorOutputManagerNetworkOutputBuffer as well as state materialization replay.
  • Add State.loopCounterFrom / State.loopStartIdFrom extractors and use them when rebuilding a StateFrame from materialized tuples and from Arrow Flight state rows.
  • Add unit + integration tests covering envelope pass-through (including nested loops) with a Scala Limit operator in the loop body.

Reviewed changes

Copilot reviewed 13 out of 13 changed files in this pull request and generated no comments.

Show a summary per file
File Description
common/workflow-core/src/test/scala/org/apache/texera/amber/core/state/StateSpec.scala Adds tests ensuring loop envelope columns round-trip via tuple extractors and default correctly.
common/workflow-core/src/main/scala/org/apache/texera/amber/core/state/State.scala Adds loop-envelope extractors (loopCounterFrom, loopStartIdFrom) and clarifies schema/semantics.
amber/src/test/scala/org/apache/texera/amber/engine/architecture/worker/DataProcessorSpec.scala Adds regression test that a JVM state pass-through preserves the exact incoming envelope.
amber/src/test/scala/org/apache/texera/amber/engine/architecture/sendsemantics/partitioners/NetworkOutputBufferSpec.scala Tests that sendState stamps loop envelope onto the outgoing StateFrame.
amber/src/test/scala/org/apache/texera/amber/engine/architecture/messaginglayer/OutputManagerSpec.scala Tests that emitState forwards the envelope onto emitted frames.
amber/src/test/integration/org/apache/texera/amber/engine/e2e/LoopIntegrationSpec.scala Adds E2E coverage for loop + Scala operator (single + nested loop cases).
amber/src/main/scala/org/apache/texera/amber/engine/common/ambermessage/DataPayload.scala Extends StateFrame to include loopCounter / loopStartId with defaults + documentation.
amber/src/main/scala/org/apache/texera/amber/engine/architecture/worker/managers/InputPortMaterializationReaderThread.scala Rebuilds StateFrame envelope from materialized state rows during replay.
amber/src/main/scala/org/apache/texera/amber/engine/architecture/worker/DataProcessor.scala Threads loop envelope through processInputState and preserves it on emission.
amber/src/main/scala/org/apache/texera/amber/engine/architecture/sendsemantics/partitioners/Partitioner.scala Updates NetworkOutputBuffer.sendState to accept and forward envelope fields.
amber/src/main/scala/org/apache/texera/amber/engine/architecture/pythonworker/PythonProxyServer.scala Rebuilds envelope from Arrow state rows when proxying Python → JVM.
amber/src/main/scala/org/apache/texera/amber/engine/architecture/pythonworker/PythonProxyClient.scala Writes full State row (content + loop columns) when proxying JVM → Python.
amber/src/main/scala/org/apache/texera/amber/engine/architecture/messaginglayer/OutputManager.scala Updates emitState / storage write to carry envelope through network + materialization.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@codecov-commenter

codecov-commenter commented Jul 21, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 52.38095% with 10 lines in your changes missing coverage. Please review.
✅ Project coverage is 74.90%. Comparing base (964e915) to head (431d32e).
⚠️ Report is 4 commits behind head on main.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
...anagers/InputPortMaterializationReaderThread.scala 0.00% 6 Missing ⚠️
.../architecture/pythonworker/PythonProxyClient.scala 0.00% 2 Missing ⚠️
...ber/engine/architecture/worker/DataProcessor.scala 66.66% 0 Missing and 1 partial ⚠️
...amber/engine/common/ambermessage/DataPayload.scala 50.00% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##               main    #6661      +/-   ##
============================================
- Coverage     74.96%   74.90%   -0.06%     
- Complexity     3446     3447       +1     
============================================
  Files          1160     1160              
  Lines         45795    45797       +2     
  Branches       5070     5064       -6     
============================================
- Hits          34328    34305      -23     
- Misses         9824     9846      +22     
- Partials       1643     1646       +3     
Flag Coverage Δ *Carryforward flag
access-control-service 70.00% <ø> (ø)
agent-service 76.76% <ø> (ø) Carriedforward from f3cc2dd
amber 67.18% <52.38%> (-0.02%) ⬇️
computing-unit-managing-service 20.49% <ø> (ø)
config-service 66.66% <ø> (ø)
file-service 67.21% <ø> (ø)
frontend 78.73% <ø> (-0.10%) ⬇️ Carriedforward from f3cc2dd
notebook-migration-service 78.94% <ø> (ø)
pyamber 92.13% <ø> (-0.03%) ⬇️ Carriedforward from f3cc2dd
workflow-compiling-service 55.14% <ø> (ø)

*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.

Two more LoopIntegrationSpec cases beyond Limit:
- a runtime-compiled Java UDF (identity map) in the loop body -- the most
  literal "Java native operator" case and a different executor kind
  (OpExecWithCode vs OpExecWithClassName);
- a chain of two JVM operators (Limit -> Sleep(0)) -- pins the JVM-to-JVM
  state handoff (one Scala worker writes the envelope columns, another
  reads them back), which single-op cases never exercise.
@github-actions

github-actions Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

⚠️ Benchmark changes need a look

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

Compared against main 964e915 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 418 0.255 23,090/34,072/34,072 us 🔴 +10.8% / 🔴 +115.5%
🔴 bs=100 sw=10 sl=64 926 0.565 108,724/129,509/129,509 us 🔴 +5.1% / 🔴 +23.3%
🔴 bs=1000 sw=10 sl=64 1,086 0.663 913,350/1,051,991/1,051,991 us 🔴 +13.1% / 🟢 -5.3%
Baseline details

Latest main 964e915 from same runner

config metric PR latest main 7d avg Δ latest Δ 7d
bs=10 sw=10 sl=64 throughput 418 tuples/sec 453 tuples/sec 769.76 tuples/sec -7.7% -45.7%
bs=10 sw=10 sl=64 MB/s 0.255 MB/s 0.277 MB/s 0.47 MB/s -7.9% -45.7%
bs=10 sw=10 sl=64 p50 23,090 us 20,835 us 12,663 us +10.8% +82.4%
bs=10 sw=10 sl=64 p95 34,072 us 34,703 us 15,813 us -1.8% +115.5%
bs=10 sw=10 sl=64 p99 34,072 us 34,703 us 19,001 us -1.8% +79.3%
bs=100 sw=10 sl=64 throughput 926 tuples/sec 957 tuples/sec 1,009 tuples/sec -3.2% -8.3%
bs=100 sw=10 sl=64 MB/s 0.565 MB/s 0.584 MB/s 0.616 MB/s -3.3% -8.3%
bs=100 sw=10 sl=64 p50 108,724 us 103,489 us 99,444 us +5.1% +9.3%
bs=100 sw=10 sl=64 p95 129,509 us 128,824 us 105,012 us +0.5% +23.3%
bs=100 sw=10 sl=64 p99 129,509 us 128,824 us 116,467 us +0.5% +11.2%
bs=1000 sw=10 sl=64 throughput 1,086 tuples/sec 1,114 tuples/sec 1,044 tuples/sec -2.5% +4.0%
bs=1000 sw=10 sl=64 MB/s 0.663 MB/s 0.68 MB/s 0.637 MB/s -2.5% +4.0%
bs=1000 sw=10 sl=64 p50 913,350 us 897,833 us 964,034 us +1.7% -5.3%
bs=1000 sw=10 sl=64 p95 1,051,991 us 929,938 us 1,007,497 us +13.1% +4.4%
bs=1000 sw=10 sl=64 p99 1,051,991 us 929,938 us 1,040,936 us +13.1% +1.1%
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,478.55,200,128000,418,0.255,23090.32,34071.60,34071.60
1,100,10,64,20,2160.98,2000,1280000,926,0.565,108723.58,129509.43,129509.43
2,1000,10,64,20,18419.28,20000,12800000,1086,0.663,913349.65,1051991.47,1051991.47

The JavaUDF case fails at executor setup in the integration harness: the
suite runs forkless under sbt, where javax.tools sees only the sbt launcher
on java.class.path, so JavaRuntimeCompilation cannot resolve the Texera
classes ("package ... does not exist") before any loop logic runs -- a
harness limitation (production workers launch with a real classpath),
documented in the spec so it is not re-tried.

Replaced with the strongest still-missing combination: a nested 3x3 loop
whose inner body chains Limit -> Sleep(0), covering nested (counter
magnitude) x multi-hop (JVM-to-JVM state handoff) x both operator kinds.

@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.

LGTM

@aglinxinyuan
aglinxinyuan added this pull request to the merge queue Jul 22, 2026
Merged via the queue into apache:main with commit 91a701b Jul 22, 2026
36 checks passed
@aglinxinyuan
aglinxinyuan deleted the loop-scala-state-envelope branch July 22, 2026 04:25

@Xiao-zhen-Liu Xiao-zhen-Liu 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.

Approving. I traced the fix end to end and it's correct for the path it targets: every place a StateFrame is built or a state is emitted on the Scala side now carries the loop id and counter, the materialization replay and the Arrow bridge (both directions) rebuild them, and the unit specs pin each one. Case 1 of the e2e reproduces the reported crash, and the specId collision from the #5700 review is fixed here (now 6, unique).

This strictly improves things — before this PR, any JVM operator in a loop body crashed; now the passthrough case works. One non-blocking follow-up inline: a possible remaining edge for stateful JVM operators, which I haven't been able to run down. It doesn't block the fix.

* passes the incoming envelope through unchanged, while a Scala-originated
* state (start/end-channel handlers) uses the "no loop" defaults.
*/
def emitState(state: State, loopCounter: Long = 0L, loopStartId: String = ""): Unit = {

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.

Non-blocking follow-up. These defaults (0 / "") are right for the forwarded-state path, which now carries the real loop id and counter. But two callers keep the defaults for operator-originated boundary state: StartChannelHandler:45 and EndChannelHandler:46 (produceStateOnStart / OnFinish).

For a stateless passthrough (Limit, Sleep — what the e2e cases use) that's fine, since they emit no boundary state. But a stateful JVM operator in a loop body would emit boundary state here with (0, ""), and LoopEnd captures its back-jump target from every state it consumes at counter 0 (main_loop.py:380, self._loop_start_id = frame.loop_start_id). On endChannel that boundary state arrives after the forwarded loop state, so it overwrites the captured id with "" — reproducing the same no loop-back state URI configured for LoopStart '' crash, through a path this fix doesn't cover.

I reasoned this from the code but haven't run it: worth either confirming a stateful JVM operator in a loop body works, or documenting that as unsupported. Related: because these args default silently, a future state path that misses them would drop the loop id and counter with no compile-time error — worth a short comment noting the two boundary-state callers are the deliberate default cases.

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.

Good catch — confirmed, and it's actually worse than the id clobber: the unstamped state also lands in run_update, which expects the loop table payload, so it raises KeyError before the jump even runs. It's language-independent too — a Python UDF's produce_state_on_finish hits the identical path (no JVM built-in overrides produceStateOnFinish today, so that's also the practical repro).

Fixed in #6913 on the consumer side: a real loop state is always stamped by its LoopStart, so LoopEnd now keys on the stamp — unstamped counter-0 states forward through unchanged (operator skipped, captured id untouched). Also added the comment you suggested at the two boundary-state emit sites. Includes a unit test plus an e2e case with a state-emitting UDF in the loop body.

mengw15 pushed a commit to aglinxinyuan/texera that referenced this pull request Jul 26, 2026
…gration (apache#6884)

### What changes were proposed in this PR?

`LoopIntegrationSpec` costs **~4m16s of the ~6-minute
`amber-integration` test step (~70%)** on every PR. The cost tracks
worker spawns almost perfectly (~2s per Python worker boot + Flight
handshake; 132 spawns across the suite), because every loop iteration
respawns each worker in the re-executed regions:

| Test | Time | Worker spawns (py + jvm) |
|---|---:|---:|
| single loop (Python-only) | 17s | 6 + 1 |
| nested 3×3 (Python-only) | 63s | 24 + 1 |
| single + Limit | 19s | 6 + 4 |
| nested + Limit | 64s | 24 + 10 |
| nested + chain (Limit→Sleep) | 72s | 24 + 19 |
| single + chain | 21s | 6 + 7 |

Two cases are **strict subsets** of the nested-chain case, which already
covers nested routing, counter magnitude across JVM hops, the JVM→JVM
state handoff, and both operator kinds in one workflow:

- `nested + Limit` (~64s) — nested × Limit ⊂ nested × (Limit → Sleep)
- `single + chain` (~21s) — chain ⊂ nested chain

This PR removes those two, saving **~85s (~33% of the loop suite)** with
no unique coverage lost. Retained matrix: Python-only single (base),
Python-only nested (pins the no-JVM path), single + Limit (verbatim
repro of the apache#6660 bug), nested + chain (superset JVM case). The
subsumption reasoning is recorded in the retained test's comment so the
cases don't get re-added.

The deeper fix (reusing workers across loop iterations instead of
respawn-per-jump) is an engine design change and out of scope here.

### Any related issues, documentation, discussions?

Follow-up to apache#6661 (which introduced the JVM loop-body cases). Related
engine context: apache#6660.

### How was this PR tested?

Test-only change (removes two e2e cases; no source touched).
`WorkflowExecutionService/Test/compile` + `scalafmtCheckAll` +
`scalafixAll --check` pass locally (Java 17); the remaining suite runs
in the `amber-integration` CI job on this PR.

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

Generated-by: Claude Code (Fable 5)
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.

Loop cannot run when a Java/Scala operator is inside the loop body

5 participants