This is an automated email from the ASF dual-hosted git repository. github-merge-queue[bot] pushed a commit to branch gh-readonly-queue/main/pr-6661-7f49a83ef590ca0ecfb9a90ff7f9a74c83dfb3c0 in repository https://gitbox.apache.org/repos/asf/texera.git
commit 91a701b7c1dc16f3a896971ddef4c1859a50e51f Author: Xinyuan Lin <[email protected]> AuthorDate: Tue Jul 21 21:04:45 2026 -0700 fix(amber): carry the loop StateFrame envelope through JVM operators (#6661) ### 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 | | `DataProcessor` → `OutputManager` | 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](https://github.com/user-attachments/files/30251886/loop.json) <img width="570" height="180" alt="Screenshot 2026-07-21 at 20 56 23" src="https://github.com/user-attachments/assets/0c002e9e-694e-445b-9ca7-b95a2cb405ae" /> ### Was this PR authored or co-authored using generative AI tooling? Generated-by: Claude Code (Fable 5) --- .../messaginglayer/OutputManager.scala | 24 +++- .../pythonworker/PythonProxyClient.scala | 7 +- .../pythonworker/PythonProxyServer.scala | 9 +- .../sendsemantics/partitioners/Partitioner.scala | 4 +- .../engine/architecture/worker/DataProcessor.scala | 17 ++- .../InputPortMaterializationReaderThread.scala | 12 +- .../engine/common/ambermessage/DataPayload.scala | 13 +- .../amber/engine/e2e/LoopIntegrationSpec.scala | 156 +++++++++++++++++++++ .../messaginglayer/OutputManagerSpec.scala | 23 +++ .../partitioners/NetworkOutputBufferSpec.scala | 12 ++ .../architecture/worker/DataProcessorSpec.scala | 47 +++++++ .../org/apache/texera/amber/core/state/State.scala | 12 +- .../apache/texera/amber/core/state/StateSpec.scala | 17 +++ 13 files changed, 333 insertions(+), 20 deletions(-) diff --git a/amber/src/main/scala/org/apache/texera/amber/engine/architecture/messaginglayer/OutputManager.scala b/amber/src/main/scala/org/apache/texera/amber/engine/architecture/messaginglayer/OutputManager.scala index b594cc8ab0..b15e899254 100644 --- a/amber/src/main/scala/org/apache/texera/amber/engine/architecture/messaginglayer/OutputManager.scala +++ b/amber/src/main/scala/org/apache/texera/amber/engine/architecture/messaginglayer/OutputManager.scala @@ -192,9 +192,16 @@ class OutputManager( buffersToFlush.foreach(_.flush()) } - def emitState(state: State): Unit = { - networkOutputBuffers.foreach(kv => kv._2.sendState(state)) - saveStateToStorageIfNeeded(state) + /** + * Emit a State to every network buffer and (if configured) the state + * storage. `loopCounter` / `loopStartId` are the loop envelope riding + * alongside the State (see `StateFrame`); a JVM hop inside a loop body + * 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 = { + networkOutputBuffers.foreach(kv => kv._2.sendState(state, loopCounter, loopStartId)) + saveStateToStorageIfNeeded(state, loopCounter, loopStartId) } def addPort(portId: PortIdentity, schema: Schema, storageURIBaseOption: Option[URI]): Unit = { @@ -236,13 +243,18 @@ class OutputManager( }) } - private def saveStateToStorageIfNeeded(state: State): Unit = { + private def saveStateToStorageIfNeeded( + state: State, + loopCounter: Long, + loopStartId: String + ): Unit = { // The same state row is fanned out to every output port's state // table. This mirrors the broadcast-to-all-workers behavior on the // emit side: state is shared context, not per-key data, so every // downstream operator (and every worker reading the materialization) - // needs the full set. - stateWriterThreads.values.foreach(_.queue.put(Left(state.toTuple()))) + // needs the full set. The loop envelope is materialized as its own + // columns so the downstream reader can rebuild it. + stateWriterThreads.values.foreach(_.queue.put(Left(state.toTuple(loopCounter, loopStartId)))) } /** diff --git a/amber/src/main/scala/org/apache/texera/amber/engine/architecture/pythonworker/PythonProxyClient.scala b/amber/src/main/scala/org/apache/texera/amber/engine/architecture/pythonworker/PythonProxyClient.scala index 144c3ac57a..d3ea86cdd4 100644 --- a/amber/src/main/scala/org/apache/texera/amber/engine/architecture/pythonworker/PythonProxyClient.scala +++ b/amber/src/main/scala/org/apache/texera/amber/engine/architecture/pythonworker/PythonProxyClient.scala @@ -124,8 +124,11 @@ class PythonProxyClient(portNumberPromise: Promise[Int], val actorId: ActorVirtu dataPayload match { case DataFrame(frame) => writeArrowStream(mutable.Queue(ArraySeq.unsafeWrapArray(frame): _*), from, "Data") - case StateFrame(state) => - writeArrowStream(mutable.Queue(state.toTuple()), from, "State") + case StateFrame(state, loopCounter, loopStartId) => + // The Arrow wire format for states IS the State row (content + + // loop_counter + loop_start_id), so the envelope rides its own + // columns to the Python worker -- see network_receiver.py. + writeArrowStream(mutable.Queue(state.toTuple(loopCounter, loopStartId)), from, "State") } } diff --git a/amber/src/main/scala/org/apache/texera/amber/engine/architecture/pythonworker/PythonProxyServer.scala b/amber/src/main/scala/org/apache/texera/amber/engine/architecture/pythonworker/PythonProxyServer.scala index 2ff866365b..82b5cea85e 100644 --- a/amber/src/main/scala/org/apache/texera/amber/engine/architecture/pythonworker/PythonProxyServer.scala +++ b/amber/src/main/scala/org/apache/texera/amber/engine/architecture/pythonworker/PythonProxyServer.scala @@ -128,7 +128,14 @@ private class AmberProducer( dataHeader.payloadType match { case "State" => assert(root.getRowCount == 1) - outputPort.sendTo(to, StateFrame(State.fromTuple(ArrowUtils.getTexeraTuple(0, root)))) + // The row is a full State row (content + loop_counter + loop_start_id; + // see network_sender.py): rebuild the loop envelope alongside the + // content so it survives the hop through this JVM proxy. + val row = ArrowUtils.getTexeraTuple(0, root) + outputPort.sendTo( + to, + StateFrame(State.fromTuple(row), State.loopCounterFrom(row), State.loopStartIdFrom(row)) + ) case "ECM" => assert(root.getRowCount == 1) outputPort.sendTo( diff --git a/amber/src/main/scala/org/apache/texera/amber/engine/architecture/sendsemantics/partitioners/Partitioner.scala b/amber/src/main/scala/org/apache/texera/amber/engine/architecture/sendsemantics/partitioners/Partitioner.scala index 39065ca693..620eb93651 100644 --- a/amber/src/main/scala/org/apache/texera/amber/engine/architecture/sendsemantics/partitioners/Partitioner.scala +++ b/amber/src/main/scala/org/apache/texera/amber/engine/architecture/sendsemantics/partitioners/Partitioner.scala @@ -49,9 +49,9 @@ class NetworkOutputBuffer( } } - def sendState(state: State): Unit = { + def sendState(state: State, loopCounter: Long = 0L, loopStartId: String = ""): Unit = { flush() - dataOutputPort.sendTo(to, StateFrame(state)) + dataOutputPort.sendTo(to, StateFrame(state, loopCounter, loopStartId)) flush() } diff --git a/amber/src/main/scala/org/apache/texera/amber/engine/architecture/worker/DataProcessor.scala b/amber/src/main/scala/org/apache/texera/amber/engine/architecture/worker/DataProcessor.scala index e88b001610..a86d16c926 100644 --- a/amber/src/main/scala/org/apache/texera/amber/engine/architecture/worker/DataProcessor.scala +++ b/amber/src/main/scala/org/apache/texera/amber/engine/architecture/worker/DataProcessor.scala @@ -121,11 +121,20 @@ class DataProcessor( } } - private[this] def processInputState(state: State, port: Int): Unit = { + private[this] def processInputState( + state: State, + port: Int, + loopCounter: Long, + loopStartId: String + ): Unit = { try { val outputState = executor.processState(state, port) if (outputState.isDefined) { - outputManager.emitState(outputState.get) + // Carry the incoming loop envelope through unchanged: loop operators + // are Python-only, so a JVM operator inside a loop body only ever + // forwards the envelope (the +1/-1 bookkeeping lives in the Python + // runtime's _process_state_frame). + outputManager.emitState(outputState.get, loopCounter, loopStartId) } } catch safely { case e => @@ -220,8 +229,8 @@ class DataProcessor( ) inputManager.initBatch(channelId, tuples) processInputTuple(inputManager.getNextTuple) - case StateFrame(state) => - processInputState(state, portId.id) + case StateFrame(state, loopCounter, loopStartId) => + processInputState(state, portId.id, loopCounter, loopStartId) } statisticsManager.increaseDataProcessingTime(System.nanoTime() - dataProcessingStartTime) } diff --git a/amber/src/main/scala/org/apache/texera/amber/engine/architecture/worker/managers/InputPortMaterializationReaderThread.scala b/amber/src/main/scala/org/apache/texera/amber/engine/architecture/worker/managers/InputPortMaterializationReaderThread.scala index 2f4386c1d8..f1ece04b5a 100644 --- a/amber/src/main/scala/org/apache/texera/amber/engine/architecture/worker/managers/InputPortMaterializationReaderThread.scala +++ b/amber/src/main/scala/org/apache/texera/amber/engine/architecture/worker/managers/InputPortMaterializationReaderThread.scala @@ -104,9 +104,17 @@ class InputPortMaterializationReaderThread( .asInstanceOf[VirtualDocument[Tuple]] val stateReadIterator = stateDocument.get() while (stateReadIterator.hasNext) { - val state = State.fromTuple(stateReadIterator.next()) + val row = stateReadIterator.next() + // Rebuild the loop envelope (loop_counter / loop_start_id) alongside + // the content: a JVM operator inside a loop body must carry it through + // unchanged, or the matching LoopEnd loses its back-jump target. + val stateFrame = StateFrame( + State.fromTuple(row), + State.loopCounterFrom(row), + State.loopStartIdFrom(row) + ) inputMessageQueue.put( - FIFOMessageElement(WorkflowFIFOMessage(channelId, getSequenceNumber, StateFrame(state))) + FIFOMessageElement(WorkflowFIFOMessage(channelId, getSequenceNumber, stateFrame)) ) } diff --git a/amber/src/main/scala/org/apache/texera/amber/engine/common/ambermessage/DataPayload.scala b/amber/src/main/scala/org/apache/texera/amber/engine/common/ambermessage/DataPayload.scala index 54f577a0be..b8ac640ad5 100644 --- a/amber/src/main/scala/org/apache/texera/amber/engine/common/ambermessage/DataPayload.scala +++ b/amber/src/main/scala/org/apache/texera/amber/engine/common/ambermessage/DataPayload.scala @@ -24,7 +24,18 @@ import org.apache.texera.amber.core.tuple.Tuple sealed trait DataPayload extends WorkflowFIFOMessagePayload {} -final case class StateFrame(frame: State) extends DataPayload +/** + * A single State travelling between operators. `loopCounter` / `loopStartId` + * are the loop envelope owned by the (Python) worker runtime -- carried + * alongside the State payload (not inside it) so it never collides with user + * state, and materialized/transported as their own columns parallel to the + * content (see `State.toTuple`). Loop operators are Python-only, so a JVM + * operator inside a loop body only ever carries the envelope through + * unchanged; the defaults are the "no loop" values for all non-loop state. + * Mirrors the Python `StateFrame` (core/models/payload.py). + */ +final case class StateFrame(frame: State, loopCounter: Long = 0L, loopStartId: String = "") + extends DataPayload final case class DataFrame(frame: Array[Tuple]) extends DataPayload { val inMemSize: Long = { diff --git a/amber/src/test/integration/org/apache/texera/amber/engine/e2e/LoopIntegrationSpec.scala b/amber/src/test/integration/org/apache/texera/amber/engine/e2e/LoopIntegrationSpec.scala index aa17d6c7a8..2c81f78045 100644 --- a/amber/src/test/integration/org/apache/texera/amber/engine/e2e/LoopIntegrationSpec.scala +++ b/amber/src/test/integration/org/apache/texera/amber/engine/e2e/LoopIntegrationSpec.scala @@ -41,7 +41,9 @@ import org.apache.texera.amber.engine.e2e.TestUtils.{ workflowContext } import org.apache.texera.amber.operator.LogicalOp +import org.apache.texera.amber.operator.limit.LimitOpDesc import org.apache.texera.amber.operator.loop.{LoopEndOpDesc, LoopStartOpDesc} +import org.apache.texera.amber.operator.sleep.SleepOpDesc import org.apache.texera.amber.operator.source.scan.text.TextInputSourceOpDesc import org.apache.texera.amber.tags.IntegrationTest import org.apache.texera.workflow.LogicalLink @@ -162,6 +164,26 @@ class LoopIntegrationSpec op } + private def limit(n: Int): LimitOpDesc = { + val op = new LimitOpDesc() + op.limit = n + op + } + + private def sleep(seconds: Int): SleepOpDesc = { + val op = new SleepOpDesc() + op.sleepTime = seconds + op + } + + // NOTE: a JavaUDF (runtime-compiled Java) case cannot run in this suite. + // The integration tests run forkless under sbt (fork := false), where + // `javax.tools` sees only the sbt launcher on `java.class.path` -- the + // Texera classes live in sbt's layered classloaders, so + // JavaRuntimeCompilation fails with "package ... does not exist" before + // any loop logic runs. Production workers launch with a real classpath, + // so this is a harness limitation, not an engine one. + private def link(from: LogicalOp, to: LogicalOp): LogicalLink = LogicalLink(from.operatorIdentifier, PortIdentity(), to.operatorIdentifier, PortIdentity()) @@ -231,4 +253,138 @@ class LoopIntegrationSpec s"expected 3, got $innerRows (all: $materialized)" ) } + + it should "run a loop whose body contains a JVM (Scala) operator" in { + // TextInput -> LoopStart -> Limit (a Scala built-in) -> LoopEnd. + // + // The loop envelope (loop_counter / loop_start_id) must survive the hop + // through a JVM operator: the Scala worker replays the materialized state, + // forwards it through the default processState, and re-materializes it for + // the LoopEnd. Before the envelope was carried through on the Scala side + // (StateFrame fields + reader/emit/save plumbing), this hop zeroed the + // counter and blanked the id, so the LoopEnd captured loop_start_id = "" + // and the back-jump failed with "no loop-back state URI configured for + // LoopStart ''" -- the loop never iterated. Limit(10) passes every row + // through, so the loop semantics are identical to the single-loop test. + val src = textInput("1\n2\n3") + val start = loopStart("i = 0", "table.iloc[i]") + val mid = limit(10) + val end = loopEnd("i += 1", "i < len(table)") + val materialized = runAndGetMaterializedRowCounts( + List(src, start, mid, end), + List(link(src, start), link(start, mid), link(mid, end)) + ) + val endRows = materialized.getOrElse(end.operatorIdentifier, -1L) + assert( + endRows == 3, + s"LoopEnd must accumulate all 3 iterations with a Scala operator in the " + + s"loop body: expected 3, got $endRows (all: $materialized)" + ) + } + + it should "run a nested loop whose inner body contains a JVM (Scala) operator" in { + // TextInput -> OuterStart -> InnerStart -> Limit -> InnerEnd -> OuterEnd. + // + // The nested variant of the JVM-hop test: the OUTER loop's state crosses + // the Scala operator stamped (loop_counter = 1, loop_start_id = outer), so + // this pins that the JVM hop preserves the counter MAGNITUDE too, not just + // the id. A zeroed counter would make the inner LoopEnd mis-consume the + // outer state (KeyError on the missing 'table' key / clobbered jump id) + // instead of passing it through with -1. + val src = textInput("1\n2\n3") + val outerStart = loopStart("i = 0", "table") + val innerStart = loopStart("j = 0", "table.iloc[j]") + val mid = limit(10) + val innerEnd = loopEnd("j += 1", "j < len(table)") + val outerEnd = loopEnd("i += 1", "i < len(table)") + val materialized = runAndGetMaterializedRowCounts( + List(src, outerStart, innerStart, mid, innerEnd, outerEnd), + List( + link(src, outerStart), + link(outerStart, innerStart), + link(innerStart, mid), + link(mid, innerEnd), + link(innerEnd, outerEnd) + ) + ) + val outerRows = materialized.getOrElse(outerEnd.operatorIdentifier, -1L) + val innerRows = materialized.getOrElse(innerEnd.operatorIdentifier, -1L) + assert( + outerRows == 9, + s"outer LoopEnd must accumulate all 9 inner-iteration rows with a Scala " + + s"operator in the inner body: expected 9, got $outerRows (all: $materialized)" + ) + assert( + innerRows == 3, + s"inner LoopEnd must reset per outer iteration (3 rows, not 9): " + + s"expected 3, got $innerRows (all: $materialized)" + ) + } + + it should "run a nested loop whose inner body chains multiple JVM operators" in { + // TextInput -> OuterStart -> InnerStart -> Limit -> Sleep(0) -> InnerEnd -> OuterEnd. + // + // The strongest combination: nested (the OUTER loop's state crosses the + // JVM hops stamped with loop_counter = 1, so the counter MAGNITUDE must + // survive both hops) x multi-hop (the JVM-to-JVM state handoff between + // the two Scala workers) x both operator kinds. A zeroed counter or a + // blanked id at either hop would mis-route the outer state at the inner + // LoopEnd. + val src = textInput("1\n2\n3") + val outerStart = loopStart("i = 0", "table") + val innerStart = loopStart("j = 0", "table.iloc[j]") + val first = limit(10) + val second = sleep(0) + val innerEnd = loopEnd("j += 1", "j < len(table)") + val outerEnd = loopEnd("i += 1", "i < len(table)") + val materialized = runAndGetMaterializedRowCounts( + List(src, outerStart, innerStart, first, second, innerEnd, outerEnd), + List( + link(src, outerStart), + link(outerStart, innerStart), + link(innerStart, first), + link(first, second), + link(second, innerEnd), + link(innerEnd, outerEnd) + ) + ) + val outerRows = materialized.getOrElse(outerEnd.operatorIdentifier, -1L) + val innerRows = materialized.getOrElse(innerEnd.operatorIdentifier, -1L) + assert( + outerRows == 9, + s"outer LoopEnd must accumulate all 9 inner-iteration rows with two " + + s"chained JVM operators in the inner body: expected 9, got $outerRows " + + s"(all: $materialized)" + ) + assert( + innerRows == 3, + s"inner LoopEnd must reset per outer iteration (3 rows, not 9): " + + s"expected 3, got $innerRows (all: $materialized)" + ) + } + + it should "run a loop whose body chains multiple JVM operators" in { + // TextInput -> LoopStart -> Limit -> Sleep(0) -> LoopEnd. + // + // Two consecutive JVM hops pin the JVM-to-JVM state handoff: the first + // Scala worker WRITES the envelope columns to its output state table and + // the second Scala worker READS them back. The single-JVM-op tests never + // exercise that pairing (there, each hop faces a Python worker on at + // least one side). Sleep(0) is a pure identity pass-through. + val src = textInput("1\n2\n3") + val start = loopStart("i = 0", "table.iloc[i]") + val first = limit(10) + val second = sleep(0) + val end = loopEnd("i += 1", "i < len(table)") + val materialized = runAndGetMaterializedRowCounts( + List(src, start, first, second, end), + List(link(src, start), link(start, first), link(first, second), link(second, end)) + ) + val endRows = materialized.getOrElse(end.operatorIdentifier, -1L) + assert( + endRows == 3, + s"LoopEnd must accumulate all 3 iterations with two chained JVM " + + s"operators in the loop body: expected 3, got $endRows (all: $materialized)" + ) + } } diff --git a/amber/src/test/scala/org/apache/texera/amber/engine/architecture/messaginglayer/OutputManagerSpec.scala b/amber/src/test/scala/org/apache/texera/amber/engine/architecture/messaginglayer/OutputManagerSpec.scala index fa9a52c430..943dd98ab6 100644 --- a/amber/src/test/scala/org/apache/texera/amber/engine/architecture/messaginglayer/OutputManagerSpec.scala +++ b/amber/src/test/scala/org/apache/texera/amber/engine/architecture/messaginglayer/OutputManagerSpec.scala @@ -307,6 +307,29 @@ class OutputManagerSpec extends AnyFlatSpec with MockFactory { outputManager.emitState(state) } + it should "carry the loop envelope onto every emitted StateFrame" in { + // The loop envelope (loop_counter / loop_start_id) is owned by the Python + // runtime; a JVM hop only forwards it. emitState must stamp the incoming + // envelope onto the outgoing frames (and the storage write -- covered by + // State.toTuple in StateSpec), not reset it to the no-loop defaults. + val outputManager: OutputManager = wire[OutputManager] + val portA = PortIdentity(7) + outputManager.addPort(portA, schema, None) + // Fresh receiver: the spec-level gateway tracks sequence numbers per + // channel, so reusing another test's receiver would start at seq 1. + val rec = ActorVirtualIdentity("state-recEnvelope") + outputManager.addPartitionerWithPartitioning( + PhysicalLink(physicalOpId(), portA, physicalOpId(), portA), + OneToOnePartitioning(10, Seq(channelTo(rec))) + ) + + val state = State(Map("k" -> 1)) + (mockHandler.apply _).expects( + WorkflowFIFOMessage(channelTo(rec), 0, StateFrame(state, 2L, "outer-loop")) + ) + outputManager.emitState(state, loopCounter = 2L, loopStartId = "outer-loop") + } + // -- addPort / storage no-ops / getSingleOutputPortIdentity -------------- "addPort" should "be idempotent for a duplicate port id" in { diff --git a/amber/src/test/scala/org/apache/texera/amber/engine/architecture/sendsemantics/partitioners/NetworkOutputBufferSpec.scala b/amber/src/test/scala/org/apache/texera/amber/engine/architecture/sendsemantics/partitioners/NetworkOutputBufferSpec.scala index 33f89a3348..b3f8817bf4 100644 --- a/amber/src/test/scala/org/apache/texera/amber/engine/architecture/sendsemantics/partitioners/NetworkOutputBufferSpec.scala +++ b/amber/src/test/scala/org/apache/texera/amber/engine/architecture/sendsemantics/partitioners/NetworkOutputBufferSpec.scala @@ -177,6 +177,18 @@ class NetworkOutputBufferSpec extends AnyFlatSpec { assert(cap.messages.head.payload == StateFrame(state)) } + it should "stamp the loop envelope onto the sent StateFrame" in { + // A JVM hop inside a loop body forwards the loop envelope unchanged + // (loop operators are Python-only); sendState must put the caller's + // loop_counter / loop_start_id on the frame instead of the no-loop + // defaults, or the matching LoopEnd's back-jump loses its target. + val (buf, cap) = newBuffer() + val state = State(Map("k" -> "v")) + buf.sendState(state, loopCounter = 2L, loopStartId = "outer-loop") + assert(cap.messages.size == 1) + assert(cap.messages.head.payload == StateFrame(state, 2L, "outer-loop")) + } + it should "leave the tuple buffer empty after sendState (trailing flush no-op)" in { // sendState calls flush() AFTER sending the state too. Pin that the // trailing flush doesn't double-send and that subsequent addTuple diff --git a/amber/src/test/scala/org/apache/texera/amber/engine/architecture/worker/DataProcessorSpec.scala b/amber/src/test/scala/org/apache/texera/amber/engine/architecture/worker/DataProcessorSpec.scala index 414b0a46a1..53f167c4ea 100644 --- a/amber/src/test/scala/org/apache/texera/amber/engine/architecture/worker/DataProcessorSpec.scala +++ b/amber/src/test/scala/org/apache/texera/amber/engine/architecture/worker/DataProcessorSpec.scala @@ -286,6 +286,53 @@ class DataProcessorSpec extends AnyFlatSpec with MockFactory with Matchers with assert(emitted.exists(_.payload.isInstanceOf[StateFrame])) } + "data processor" should "carry the loop envelope through a state pass-through unchanged" in { + // Loop operators are Python-only, so a JVM operator inside a loop body + // only ever FORWARDS the StateFrame loop envelope (loop_counter / + // loop_start_id); the +1/-1 bookkeeping lives in the Python runtime. + // A dropped envelope zeroes the counter and blanks the id, which breaks + // the matching LoopEnd's back-jump ("no loop-back state URI configured + // for LoopStart ''") -- so pin exact envelope equality on the emitted + // frame, not just that a StateFrame came out. + val dp = mkDataProcessor + dp.executor = executor + dp.stateManager.transitTo(READY) + val emitted = scala.collection.mutable.ArrayBuffer[WorkflowFIFOMessage]() + (outputHandler.apply _) + .expects(*) + .onCall { (m: Either[MainThreadDelegateMessage, WorkflowFIFOMessage]) => + m.foreach(emitted += _); () + } + .anyNumberOfTimes() + val inputState = State(Map("i" -> 1)) + ( + ( + state: State, + port: Int + ) => executor.processState(state, port) + ) + .expects(inputState, 0) + .returning(Some(inputState)) + dp.inputManager.addPort(inputPortId, schema, List.empty, List.empty) + dp.inputGateway + .getChannel(ChannelIdentity(senderWorkerId, testWorkerId, isControl = false)) + .setPortId(inputPortId) + dp.outputManager.addPort(outputPortId, schema, None) + dp.outputManager.addPartitionerWithPartitioning( + PhysicalLink(testOpId, outputPortId, upstreamOpId, inputPortId), + OneToOnePartitioning(1, Seq(ChannelIdentity(testWorkerId, senderWorkerId, isControl = false))) + ) + dp.processDataPayload( + ChannelIdentity(senderWorkerId, testWorkerId, isControl = false), + StateFrame(inputState, loopCounter = 2L, loopStartId = "outer-loop") + ) + val stateFrames = emitted.map(_.payload).collect { case sf: StateFrame => sf } + assert( + stateFrames.toList == List(StateFrame(inputState, 2L, "outer-loop")), + s"envelope must ride through unchanged, got: $stateFrames" + ) + } + "data processor" should "not emit when processState yields None" in { val dp = mkDataProcessor dp.executor = executor diff --git a/common/workflow-core/src/main/scala/org/apache/texera/amber/core/state/State.scala b/common/workflow-core/src/main/scala/org/apache/texera/amber/core/state/State.scala index f2896c8e19..aa88ce1660 100644 --- a/common/workflow-core/src/main/scala/org/apache/texera/amber/core/state/State.scala +++ b/common/workflow-core/src/main/scala/org/apache/texera/amber/core/state/State.scala @@ -45,10 +45,18 @@ object State { private val Content = "content" // loop-control bookkeeping owned by the (Python) worker runtime; not user // state and never in the content JSON. Materialized as its own columns, - // parallel to content. Scala never originates loop state (loop operators are - // Python-only), so toTuple defaults these to the "no loop" values. + // parallel to content. Scala never ORIGINATES loop state (loop operators are + // Python-only), so toTuple defaults these to the "no loop" values -- but a + // JVM operator inside a loop body must CARRY them through unchanged, so the + // extractors below read them back off a materialized/transported row. private val LoopCounter = "loop_counter" private val LoopStartId = "loop_start_id" + + /** Read the loop-envelope counter off a State row (see `toTuple`). */ + def loopCounterFrom(row: Tuple): Long = row.getField[java.lang.Long](LoopCounter).longValue() + + /** Read the loop-envelope LoopStart id off a State row (see `toTuple`). */ + def loopStartIdFrom(row: Tuple): String = row.getField[String](LoopStartId) private val BytesTypeMarker = "__texera_type__" private val BytesValue = "bytes" private val PayloadMarker = "payload" diff --git a/common/workflow-core/src/test/scala/org/apache/texera/amber/core/state/StateSpec.scala b/common/workflow-core/src/test/scala/org/apache/texera/amber/core/state/StateSpec.scala index f0af0b30c8..8856d48ea9 100644 --- a/common/workflow-core/src/test/scala/org/apache/texera/amber/core/state/StateSpec.scala +++ b/common/workflow-core/src/test/scala/org/apache/texera/amber/core/state/StateSpec.scala @@ -125,6 +125,23 @@ class StateSpec extends AnyFlatSpec { assert(tuple.getField[String]("content") == """{"x":1}""") } + it should "read the loop envelope back off a materialized tuple" in { + // A JVM operator inside a loop body replays materialized states and must + // carry loop_counter / loop_start_id through unchanged -- the columns + // exist precisely so the envelope survives storage. These extractors are + // what InputPortMaterializationReaderThread / PythonProxyServer read; a + // rename of the columns on either side must break this. + val tuple = State(Map("i" -> 1L)).toTuple(2L, "outer-loop") + assert(State.loopCounterFrom(tuple) == 2L) + assert(State.loopStartIdFrom(tuple) == "outer-loop") + } + + it should "default the loop envelope to the no-loop values" in { + val tuple = State(Map("i" -> 1L)).toTuple() + assert(State.loopCounterFrom(tuple) == 0L) + assert(State.loopStartIdFrom(tuple) == "") + } + it should "decode a payload encoded by the Python serializer" in { // Wire-format compatibility check: the bytes-marker keys and the // single-row "content" column must match what core/models/state.py
