This is an automated email from the ASF dual-hosted git repository.
github-merge-queue[bot] pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/texera.git
The following commit(s) were added to refs/heads/main by this push:
new ed16a605da refactor(amber): read the loop input table from its
materialization instead of shipping it in state (#6971)
ed16a605da is described below
commit ed16a605dacc0185bc5f069d14157ee3e128b68a
Author: Xinyuan Lin <[email protected]>
AuthorDate: Mon Aug 10 23:57:45 2026 -0700
refactor(amber): read the loop input table from its materialization instead
of shipping it in state (#6971)
### 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 Denied` — `LoopIntegrationSpec` 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?
- **Unit** — `test_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)
---
.../texera/amber/bench/ArrowFlightActorBench.scala | 2 +-
.../engine/architecture/rpc/controlcommands.proto | 21 +-
.../control/initialize_executor_handler.py | 4 +-
.../python/core/architecture/managers/context.py | 6 +-
amber/src/main/python/core/models/operator.py | 89 +++---
amber/src/main/python/core/models/table.py | 29 --
amber/src/main/python/core/runnables/main_loop.py | 198 ++++++++++--
.../core/util/console_message/replace_print.py | 13 +-
.../scheduling/RegionExecutionManager.scala | 10 +-
.../scheduling/WorkflowExecutionManager.scala | 19 +-
.../amber/engine/e2e/LoopIntegrationSpec.scala | 12 +-
.../control/test_initialize_executor_handler.py | 16 +-
.../test/python/core/models/test_loop_operators.py | 151 ++++-----
.../test/python/core/runnables/test_main_loop.py | 345 ++++++++++++++++++---
.../engine/architecture/worker/WorkerSpec.scala | 2 +-
.../architecture/worker/WorkflowWorkerSpec.scala | 2 +-
.../worker/managers/SerializationManagerSpec.scala | 2 +-
.../texera/amber/core/workflow/PhysicalOp.scala | 5 +-
.../amber/operator/loop/LoopEndOpDescSpec.scala | 11 +-
.../amber/operator/loop/LoopStartOpDescSpec.scala | 2 +-
20 files changed, 660 insertions(+), 279 deletions(-)
diff --git
a/amber/src/bench/scala/org/apache/texera/amber/bench/ArrowFlightActorBench.scala
b/amber/src/bench/scala/org/apache/texera/amber/bench/ArrowFlightActorBench.scala
index e3b79ea804..602f4f0841 100644
---
a/amber/src/bench/scala/org/apache/texera/amber/bench/ArrowFlightActorBench.scala
+++
b/amber/src/bench/scala/org/apache/texera/amber/bench/ArrowFlightActorBench.scala
@@ -287,7 +287,7 @@ object ArrowFlightActorBench {
1,
OpExecWithCode(IdentityPythonCode, "python"),
isSource = false,
- loopStartStateUris = Map.empty
+ loopStartPortUris = Map.empty
),
ctx,
0L
diff --git
a/amber/src/main/protobuf/org/apache/texera/amber/engine/architecture/rpc/controlcommands.proto
b/amber/src/main/protobuf/org/apache/texera/amber/engine/architecture/rpc/controlcommands.proto
index 8a6403a97e..e60b7cd74f 100644
---
a/amber/src/main/protobuf/org/apache/texera/amber/engine/architecture/rpc/controlcommands.proto
+++
b/amber/src/main/protobuf/org/apache/texera/amber/engine/architecture/rpc/controlcommands.proto
@@ -254,12 +254,21 @@ message InitializeExecutorRequest {
int32 totalWorkerCount = 1;
core.OpExecInitInfo opExecInitInfo = 2;
bool isSource = 3;
- // Loop-back write addresses: Loop Start logical operator id -> state URI of
- // that Loop Start's input port. Constant per execution (minted at schedule
- // time); a Loop End worker selects the entry by the loop_start_id carried on
- // the consumed StateFrame and writes the next-iteration state there. Empty
- // for plans without loops.
- map<string, string> loopStartStateUris = 4;
+ // Loop bookkeeping addresses: Loop Start logical operator id -> the BASE
+ // URI of the materialized port that Loop Start's input port READS FROM,
+ // i.e. the upstream operator's output-port materialization (the storage
+ // pair of the Loop Start's input port config), not a port of the Loop
+ // Start itself. Constant per execution
+ // (minted at schedule time); a Loop End worker selects the entry by the
+ // loop_start_id carried on the consumed StateFrame and derives two URIs
+ // from the base:
+ // - state URI (VFSURIFactory.state_uri): the loop-back write address for
+ // the next-iteration state;
+ // - result URI (VFSURIFactory.result_uri): the loop's input table, read
+ // at consume time so the table never rides inside the State content
+ // through the loop body.
+ // Empty for plans without loops.
+ map<string, string> loopStartPortUris = 4;
}
message UpdateExecutorRequest {
diff --git
a/amber/src/main/python/core/architecture/handlers/control/initialize_executor_handler.py
b/amber/src/main/python/core/architecture/handlers/control/initialize_executor_handler.py
index 50fceab7fe..d98e67bcce 100644
---
a/amber/src/main/python/core/architecture/handlers/control/initialize_executor_handler.py
+++
b/amber/src/main/python/core/architecture/handlers/control/initialize_executor_handler.py
@@ -30,6 +30,6 @@ class InitializeExecutorHandler(ControlHandler):
self.context.executor_manager.initialize_executor(
op_exec_with_code.code, req.is_source, op_exec_with_code.language
)
- # Loop-back write addresses; see the proto field doc on
loopStartStateUris.
- self.context.loop_start_state_uris = dict(req.loop_start_state_uris)
+ # Loop bookkeeping base URIs; see the proto field doc on
loopStartPortUris.
+ self.context.loop_start_port_uris = dict(req.loop_start_port_uris)
return EmptyReturn()
diff --git a/amber/src/main/python/core/architecture/managers/context.py
b/amber/src/main/python/core/architecture/managers/context.py
index dfcc30f9aa..18bbdd3958 100644
--- a/amber/src/main/python/core/architecture/managers/context.py
+++ b/amber/src/main/python/core/architecture/managers/context.py
@@ -87,9 +87,9 @@ class Context:
self.debug_manager = DebugManager(
self.tuple_processing_manager.context_switch_condition
)
- # Loop-back write addresses delivered at setup; see the proto field doc
- # on InitializeExecutorRequest.loopStartStateUris
(controlcommands.proto).
- self.loop_start_state_uris: Dict[str, str] = {}
+ # Loop bookkeeping base URIs delivered at setup; see the proto field
doc
+ # on InitializeExecutorRequest.loopStartPortUris
(controlcommands.proto).
+ self.loop_start_port_uris: Dict[str, str] = {}
def report_exception(self, err: BaseException) -> None:
"""Route an operator-facing exception to the exception manager and
diff --git a/amber/src/main/python/core/models/operator.py
b/amber/src/main/python/core/models/operator.py
index c27dc154b5..682b18bb64 100644
--- a/amber/src/main/python/core/models/operator.py
+++ b/amber/src/main/python/core/models/operator.py
@@ -24,7 +24,7 @@ from typing import Iterator, List, Mapping, Optional, Union,
MutableMapping, Pro
from . import Table, TableLike, Tuple, TupleLike, Batch, BatchLike
from .state import State
-from .table import all_output_to_tuple, table_from_ipc_bytes,
table_to_ipc_bytes
+from .table import all_output_to_tuple
import base64
@@ -279,17 +279,6 @@ class TableOperator(TupleOperatorV2):
table = Table(self.__table_data[port])
yield from self.process_table(table, port)
- def _buffered_table(self, port: int) -> Table:
- """Tuples buffered for ``port`` so far, materialized as a Table.
-
- Exposed so subclasses (e.g. ``LoopStartOperator``) can read the
- buffer outside the ``process_table`` callback without reaching into
- the parent's name-mangled private field. Inside this class
- ``self.__table_data`` resolves via normal name mangling, so a future
- rename of ``TableOperator`` keeps callers transparent.
- """
- return Table(self.__table_data[port])
-
@abstractmethod
def process_table(self, table: Table, port: int) ->
Iterator[Optional[TableLike]]:
"""
@@ -308,10 +297,13 @@ class TableOperator(TupleOperatorV2):
# namespaces the loop expressions run in. It is NOT user state: a user loop
# variable of the same name collides with it, so both operators raise on
# collision (see ``_reserved_name_error``) rather than silently dropping the
-# user's value. The envelope names (``loop_counter`` / ``loop_start_id``) never
-# enter user state -- they ride the StateFrame envelope (see
-# ``core.models.payload``). The loop-back write address is setup config, not
-# state (see ``loopStartStateUris`` on the ``InitializeExecutorRequest``
proto).
+# user's value. The table itself never rides the State content: the LoopEnd
+# runtime reads it from the Loop Start's input-port materialization at consume
+# time and injects it via ``attach_loop_table``. The envelope names
+# (``loop_counter`` / ``loop_start_id``) never enter user state -- they ride
+# the StateFrame envelope (see ``core.models.payload``). The loop bookkeeping
+# base URI is setup config, not state (see ``loopStartPortUris`` on the
+# ``InitializeExecutorRequest`` proto).
_TABLE_KEY = "table"
_RESERVED_STATE_KEYS: frozenset = frozenset({_TABLE_KEY})
@@ -359,8 +351,9 @@ class LoopStartOperator(TableOperator):
``open()`` seeds ``self.state`` with the user's loop variables;
``process_state`` merges upstream state in; ``produce_state_on_finish``
- emits those variables plus the input table (Arrow IPC; see
- ``table_to_ipc_bytes`` in ``core.models.table``) to the matching LoopEnd.
+ emits those variables to the matching LoopEnd. The input table does NOT
+ ride the state: the LoopEnd runtime reads it from this operator's
+ input-port materialization at consume time (``loopStartPortUris``).
``loop_counter`` and the nested pass-through are owned by
``MainLoop._process_state_frame``, not this operator.
@@ -404,18 +397,15 @@ class LoopStartOperator(TableOperator):
@overrides.final
def produce_state_on_finish(self, port: int) -> State:
- # Emit the user's loop variables plus the buffered input table for the
- # matching LoopEnd. The table rides as an Arrow IPC stream, not pickle
- # (see `table_to_ipc_bytes` in core.models.table for why). Reads the
- # buffer through `_buffered_table` so a rename of `TableOperator`
- # doesn't silently break this.
- # A user loop variable named `table` would be overwritten by the input
- # table below, so flag the collision instead of silently dropping it.
+ # Emit the user's loop variables for the matching LoopEnd. The input
+ # table does NOT ride the state: the LoopEnd runtime reads it from
+ # this operator's input-port materialization at consume time
+ # (loopStartPortUris). A user loop variable named `table` would be
+ # silently shadowed by that injected table in the LoopEnd's
+ # update/condition namespaces, so flag the collision here.
if _TABLE_KEY in self.state:
raise _reserved_name_error(_TABLE_KEY)
- produced = State(self.state)
- produced[_TABLE_KEY] = table_to_ipc_bytes(self._buffered_table(port))
- return produced
+ return State(self.state)
class LoopEndOperator(TableOperator):
@@ -428,9 +418,10 @@ class LoopEndOperator(TableOperator):
``eval_condition``); all substantive logic lives here.
``process_table`` yields each input table through as-is; ``process_state``
- runs the user's ``update`` and persists only user variables back into
- ``self.state`` (keeping the decoded table on ``self._loop_table``);
- ``condition()`` decides whether ``MainLoop.complete()`` fires the
back-edge.
+ runs the user's ``update`` (against the table the runtime attached via
+ ``attach_loop_table``) and persists only user variables back into
+ ``self.state``; ``condition()`` decides whether ``MainLoop.complete()``
+ fires the back-edge.
Subclass contract: the generated subclass overrides ``process_state()`` and
``condition()`` only; all other methods are ``@overrides.final``.
@@ -452,21 +443,47 @@ class LoopEndOperator(TableOperator):
# AttributeError; a None _loop_table means "nothing consumed yet" and
# condition() short-circuits to False (see eval_condition).
self.state: State = State()
+ # Set by the runtime (attach_loop_table) right before the matching
+ # consume and taken (cleared) by run_update. The clear is defensive
+ # rather than load-bearing today -- a worker instance handles a single
+ # iteration (workers are recreated per region execution), so there is
+ # no second consume within one instance for a stale table to leak
+ # into -- but nothing may run an update against a table it was not
+ # explicitly handed. It stays separate from _loop_table because
+ # _loop_table doubles as the "consumed" marker that condition()
+ # short-circuits on, and only a SUCCESSFUL update may set that.
+ self._attached_table: Optional[Table] = None
self._loop_table: Optional[Table] = None
@overrides.final
def process_table(self, table: Table, port: int) ->
Iterator[Optional[TableLike]]:
yield table
+ @overrides.final
+ def attach_loop_table(self, table: Table) -> None:
+ # Runtime-only hook: MainLoop reads the loop's input table from the
+ # Loop Start's input-port materialization (loopStartPortUris) and
+ # attaches it here right before the matching consume, so the table
+ # never has to ride inside the State content through the loop body.
+ self._attached_table = table
+
@overrides.final
def run_update(self, update_code: str, state: State) -> None:
# Run the user's `update` in a throwaway namespace seeded with the
# incoming loop variables and the input table, then persist the user
- # variables back into self.state. The table arrives as an Arrow IPC
- # stream, not pickle (see `table_to_ipc_bytes` in core.models.table
- # for why); the decoded table is kept on self._loop_table so
- # condition() can read it after the update.
- input_table = table_from_ipc_bytes(state[_TABLE_KEY])
+ # variables back into self.state. The table is attached by the runtime
+ # (attach_loop_table) from the Loop Start's input materialization; on
+ # a successful update it is kept on self._loop_table so condition()
+ # can read it afterwards.
+ if self._attached_table is None:
+ raise RuntimeError(
+ "loop input table was not attached before the update; the "
+ "runtime must call attach_loop_table on the matching consume"
+ )
+ input_table = self._attached_table
+ # Take it: a later iteration that never got a table must raise above
+ # rather than silently run the update against the previous one.
+ self._attached_table = None
namespace = {**state, _TABLE_KEY: input_table}
# Pass the namespace as exec globals (not a locals-only mapping) so a
# comprehension / generator expression / lambda in the user's `update`
diff --git a/amber/src/main/python/core/models/table.py
b/amber/src/main/python/core/models/table.py
index a85e75cbef..4716e0eba0 100644
--- a/amber/src/main/python/core/models/table.py
+++ b/amber/src/main/python/core/models/table.py
@@ -16,7 +16,6 @@
# under the License.
import pandas
-import pyarrow as pa
from pampy import match
from typing import Iterator, TypeVar, List
@@ -83,34 +82,6 @@ class Table(pandas.DataFrame):
return super().__eq__(other).all()
-def table_to_ipc_bytes(table: Table) -> bytes:
- """Serialize ``table`` as an Apache Arrow IPC stream.
-
- Used by the loop operators to round-trip a Table through a state dict
- (and through iceberg storage) without resorting to ``pickle.dumps``,
- which would expose ``pickle.loads`` as a remote-code-execution surface
- on the receiving side. Arrow IPC is a length-prefixed, schema-typed
- format that carries data only -- no executable payload.
- """
- arrow_table = pa.Table.from_pandas(table, preserve_index=False)
- sink = pa.BufferOutputStream()
- with pa.ipc.new_stream(sink, arrow_table.schema) as writer:
- writer.write_table(arrow_table)
- return sink.getvalue().to_pybytes()
-
-
-def table_from_ipc_bytes(buf: bytes) -> Table:
- """Inverse of :func:`table_to_ipc_bytes`.
-
- Reconstruct a Table from an Apache Arrow IPC stream buffer. Raises if
- ``buf`` is not a well-formed Arrow IPC stream, so malformed input
- surfaces as a parse error rather than executing anything.
- """
- with pa.ipc.open_stream(pa.py_buffer(buf)) as reader:
- arrow_table = reader.read_all()
- return Table(arrow_table.to_pandas())
-
-
def all_output_to_tuple(output) -> Iterator[Tuple]:
"""
Convert all kinds of types into Tuples.
diff --git a/amber/src/main/python/core/runnables/main_loop.py
b/amber/src/main/python/core/runnables/main_loop.py
index 048bc0a289..108078ba4f 100644
--- a/amber/src/main/python/core/runnables/main_loop.py
+++ b/amber/src/main/python/core/runnables/main_loop.py
@@ -30,6 +30,7 @@ from core.architecture.rpc.async_rpc_server import
AsyncRPCServer
from core.models import (
InternalQueue,
StateFrame,
+ Table,
Tuple,
)
from core.models.internal_marker import StartChannel, EndChannel
@@ -43,7 +44,9 @@ from core.models.operator import LoopEndOperator,
LoopStartOperator
from core.models.state import State
from core.runnables.data_processor import DataProcessor
from core.storage.document_factory import DocumentFactory
+from core.storage.vfs_uri_factory import VFSURIFactory
from core.util import StoppableQueueBlockingRunnable, get_one_of
+from core.util.console_message.replace_print import replace_print
from core.util.console_message.timestamp import current_time_in_local_timezone
from core.util.customized_queue.queue_base import QueueElement
from core.util.virtual_identity import get_logical_op_id
@@ -85,19 +88,26 @@ class MainLoop(StoppableQueueBlockingRunnable):
self._output_queue: InternalQueue = output_queue
# Captured from the consumed StateFrame envelope when a matching
# LoopEnd (loop_counter == 0) takes a state; used for the jump RPC
- # and the setup-config URI lookup (context.loop_start_state_uris).
+ # and the setup-config URI lookup (context.loop_start_port_uris).
self._loop_start_id: str = ""
- # Whether this LoopEnd already consumed its loop state this execution.
+ # Whether this LoopEnd already took its loop state this execution.
# A loop body may branch and converge on the Loop End, and every reader
# on its input port replays that branch's states independently, so the
- # same iteration's state arrives once per branch. Workers are recreated
- # on each region re-execution, so this instance flag is per iteration.
+ # same iteration's state arrives once per branch. Cleared when the
+ # deferred consume takes the stash (_consume_pending_loop_state), so
+ # the flag is per-execution by construction -- not by the scheduler
+ # happening to recreate workers each iteration.
self._loop_state_consumed: bool = False
# Whether this LoopEnd forwarded an UNstamped counter-0 state instead
# of consuming it. Paired with _loop_start_id by
# _check_loop_state_arrived: forwarding one and never capturing a stamp
# means the loop's own state reached here with its stamp lost.
self._forwarded_unstamped_state: bool = False
+ # The matching state a LoopEnd will consume, stashed when taken and
+ # actually handed to the operator at EndChannel (see
+ # _consume_pending_loop_state for why the table read must not overlap
+ # this worker's own materialization reader).
+ self._pending_loop_state: Optional[State] = None
self.context = Context(worker_id, input_queue)
self._async_rpc_server = AsyncRPCServer(output_queue,
context=self.context)
@@ -108,21 +118,98 @@ class MainLoop(StoppableQueueBlockingRunnable):
target=self.data_processor.run, daemon=True,
name="data_processor_thread"
).start()
- def _jump_to_loop_start(
- self, executor: LoopEndOperator, coordinator_interface
- ) -> None:
- # The write address is setup config, keyed by the captured id. Fail
- # loud BEFORE the jump RPC so a misconfigured loop does not rewind the
- # schedule without a back-edge write. Anything raised here (a missing
- # URI, or a failed state write after the jump) is reported by
- # complete()'s guard as an operator-facing error.
- uri = self.context.loop_start_state_uris.get(self._loop_start_id)
+ def _loop_start_base_uri(self) -> str:
+ # The loop's bookkeeping base URI is setup config, keyed by the
+ # captured id (see InitializeExecutorRequest.loopStartPortUris). Fail
+ # loud on a missing entry: anything raised here is reported by the
+ # caller's guard as an operator-facing error.
+ uri = self.context.loop_start_port_uris.get(self._loop_start_id)
if not uri:
raise RuntimeError(
- f"no loop-back state URI configured for LoopStart "
+ f"no loop bookkeeping URI configured for LoopStart "
f"'{self._loop_start_id}' "
- f"(have: {sorted(self.context.loop_start_state_uris)})"
+ f"(have: {sorted(self.context.loop_start_port_uris)})"
)
+ return uri
+
+ def _read_loop_input_table(self) -> Table:
+ # The loop's input table is the Loop Start's input-port
+ # materialization: the output doc of whatever feeds this loop level.
+ # It is stable for the duration of the loop level that reads it -- the
+ # back-edge rewrites only the state doc under the same base URI, never
+ # this result doc. For an INNER loop that upstream is the outer Loop
+ # Start, whose output doc is recreated on each OUTER iteration; that is
+ # still stable across every inner iteration that reads it, which is the
+ # window that matters here. Reading it means the table never has to
+ # ride inside the State content through the loop body. Callers must
+ # invoke this OUTSIDE the window where this worker's own
materialization
+ # reader is streaming -- see _consume_pending_loop_state.
+ result_uri = VFSURIFactory.result_uri(self._loop_start_base_uri())
+ document, schema = DocumentFactory.open_document(result_uri)
+ # Normalize the same way as the only other reader of this doc: the
+ # input-port reader casts every tuple to the doc's schema before it
+ # reaches an operator (input_port_materialization_reader_runnable), so
+ # this read must too -- two readers of one document normalizing
+ # differently would diverge on the first storage/provider change.
+ rows = []
+ for tup in document.get():
+ tup.cast_to_schema(schema)
+ rows.append(tup)
+ return Table(rows)
+
+ def _consume_pending_loop_state(self, executor: LoopEndOperator) -> None:
+ # Run the matching consume that _process_state_frame deferred.
+ #
+ # The loop's input table is read from the Loop Start's input-port
+ # materialization (see _read_loop_input_table for its lifetime). The
+ # read is deferred to here rather than done when the state arrives: at
+ # arrival time THIS worker's own materialization reader is still
+ # streaming its input, and in runs where this read overlapped that
+ # reader, the reader failed with S3 "Access Denied" (MinIO's answer
+ # for a deleted key) while iterating a lazily-pinned snapshot of a doc
+ # that region re-execution drops and recreates. Removing the overlap
+ # made those failures stop; that the overlap CAUSED them is the
+ # working hypothesis, not a ruled-out fact -- a doc dropped under a
+ # live reader would break the reader with or without this read -- so
+ # treat a recurrence as new evidence. By EndChannel the reader has
+ # finished, so the two never overlap.
+ if self._pending_loop_state is None:
+ return
+ pending = self._pending_loop_state
+ self._pending_loop_state = None
+ # Re-arm the duplicate guard here rather than relying on the scheduler
+ # recreating workers each iteration: EndChannel is PORT_ALIGNMENT, so
+ # nothing more can arrive on the port -- every duplicate is already in
+ # by the time this consume runs.
+ self._loop_state_consumed = False
+ executor.attach_loop_table(self._read_loop_input_table())
+ # A Loop End has exactly one input port (port 0); the generated
+ # operator's process_state ignores the port anyway. That single input
+ # port is also what makes THIS a safe place for the read above:
+ # EndChannel is PORT_ALIGNMENT (dispatched once every channel of the
+ # port has delivered it) and each reader emits its EndChannel only
+ # after its iterator is exhausted, so with one input port every reader
+ # of this worker has finished before the read. A second input port
+ # would break that -- alignment is per port -- and silently
+ # reintroduce the reader/read overlap this deferral removes.
+ # The user's `update` runs here, on the main loop thread rather than
+ # inside DataProcessor._executor_session, so capture its prints
+ # explicitly -- otherwise they go to the worker's stdout instead of
+ # the console.
+ with replace_print(
+ self.context.worker_id,
self.context.console_message_manager.print_buf
+ ):
+ executor.process_state(pending, 0)
+
+ def _jump_to_loop_start(
+ self, executor: LoopEndOperator, coordinator_interface
+ ) -> None:
+ # Resolve the write address BEFORE the jump RPC so a misconfigured
+ # loop does not rewind the schedule without a back-edge write.
+ # Anything raised here (a missing URI, or a failed state write after
+ # the jump) is reported by complete()'s guard as an operator-facing
+ # error.
+ uri = VFSURIFactory.state_uri(self._loop_start_base_uri())
coordinator_interface.jump_to_operator_region(
JumpToOperatorRegionRequest(OperatorIdentity(self._loop_start_id))
)
@@ -195,12 +282,23 @@ class MainLoop(StoppableQueueBlockingRunnable):
# worker, instead of killing the thread through run()'s
# @logger.catch(reraise=True).
try:
- if executor.condition():
+ # condition() is user code on the main loop thread, same as the
+ # `update` in the deferred consume: capture its prints too.
+ with replace_print(
+ self.context.worker_id,
+ self.context.console_message_manager.print_buf,
+ ):
+ should_iterate = executor.condition()
+ if should_iterate:
self._jump_to_loop_start(executor, coordinator_interface)
except Exception as err:
self.context.report_exception(err)
self._check_exception()
return
+ # The opening flush above happened before condition() ran, and the
+ # worker is about to shut down, so anything it printed would never
+ # be sent. (_check_exception flushes on the error path.)
+ self._check_and_report_console_messages(force_flush=True)
executor.close()
# stop the data processing thread
self.data_processor.stop()
@@ -298,7 +396,7 @@ class MainLoop(StoppableQueueBlockingRunnable):
executor = self.context.executor_manager.executor
if isinstance(executor, LoopStartOperator):
# A LoopStart stamps its own logical op id; the write address
- # is setup config
(InitializeExecutorRequest.loop_start_state_uris).
+ # is setup config
(InitializeExecutorRequest.loop_start_port_uris).
output_loop_start_id =
get_logical_op_id(self.context.worker_id)
self._emit_and_save_state(
output_state,
@@ -452,29 +550,49 @@ class MainLoop(StoppableQueueBlockingRunnable):
# A real loop state is always stamped: the matching LoopStart
# stamps its own id on every iteration's output state.
# Forward it downstream unchanged, skipping the operator, like
- # any default pass-through: consuming it would clobber the
- # captured back-jump id with "" and hand run_update a State
- # with no `table` payload.
+ # any default pass-through: taking it would clobber the
+ # captured back-jump id with "" and then fail the deferred
+ # consume's bookkeeping-URI lookup for LoopStart ''.
+ #
+ # This MUST stay ahead of the dedup and the stash below: an
+ # unstamped frame is not the loop's own state, so it may
+ # neither mark that state as taken nor be handed to the
+ # operator as the iteration's state.
self._forwarded_unstamped_state = True
self._emit_and_save_state(state, in_counter,
frame.loop_start_id)
self._check_and_process_control()
return
# Matching LoopEnd (in_counter == 0, stamped): it will consume this
# state and jump back. Remember which LoopStart to jump to (it
- # rides the envelope) for complete()/_jump_to_loop_start.
+ # rides the envelope) for complete()/_jump_to_loop_start, and STASH
+ # the state -- the operator runs its update at EndChannel instead
+ # of here, because the loop's input table is read from storage and
+ # that read must not overlap this worker's own materialization
+ # reader (see _consume_pending_loop_state). The LoopEnd emits no
+ # state downstream on the matching consume, so deferring it
+ # changes nothing observable outside the operator.
#
# With a branching loop body, each branch's reader replays the same
- # iteration's state, so this fires once per inbound link. Consume
- # only the first: running the user's `update` again would advance
- # the loop variables once per branch. The copies are identical --
- # they are the same state emitted by the one matching LoopStart --
- # so dropping them loses nothing (a consume emits nothing
- # downstream either way).
+ # iteration's state, so this fires once per inbound link. The
+ # deferred consume runs the user's update exactly once either way
+ # (and run_update seeds its namespace from the incoming copy, so
+ # re-running it on an identical copy would even be idempotent);
+ # what this flag pins is WHICH copy is stashed -- the first,
+ # instead of each later arrival silently overwriting
+ # _pending_loop_state. The copies are the same state emitted by
+ # the one matching LoopStart unless a body operator overrides
+ # process_state to forward a modified one; rewriting the loop
+ # state in a branching body is not supported (which copy arrives
+ # first is scheduling-dependent either way), so take the first
+ # deliberately.
if self._loop_state_consumed:
self._check_and_process_control()
return
self._loop_state_consumed = True
self._loop_start_id = frame.loop_start_id
+ self._pending_loop_state = state
+ self._check_and_process_control()
+ return
self.context.state_processing_manager.current_input_state = state
self.process_input_state(
@@ -498,11 +616,31 @@ class MainLoop(StoppableQueueBlockingRunnable):
self._check_exception()
if self.context.exception_manager.has_exception():
# A state-emission error was reported on the main loop thread (see
- # _emit_and_save_state). Hold the region: skip port_completed and
- # complete() so the coordinator does not mark the region complete
- # (region completion is port-based) with partial, single-iteration
+ # _emit_and_save_state). Hold the region BEFORE any loop work --
+ # no storage read and no user `update` stacked on top of an
+ # already-reported error -- and skip port_completed / complete()
+ # so the coordinator does not mark the region complete (region
+ # completion is port-based) with partial, single-iteration
# results. The reported error surfaces instead of a false success.
return
+ # Run the deferred loop consume HERE, not in complete(): complete() is
+ # the tail of this method, by which point port_completed has been sent
+ # for every output port, so a failing read or `update` would be
+ # reported after the coordinator already considers the region done
+ # (region completion is port-based) -- a reported error that still
+ # reads as success. This is still past the reader: EndChannel means it
+ # finished streaming.
+ executor = self.context.executor_manager.executor
+ if isinstance(executor, LoopEndOperator):
+ try:
+ self._consume_pending_loop_state(executor)
+ except Exception as err:
+ self.context.report_exception(err)
+ self._check_exception()
+ if self.context.exception_manager.has_exception():
+ # The deferred consume failed (the table read or the user's
+ # update). Hold the region for the same reason as above.
+ return
self.process_input_tuple()
input_port_id = self.context.input_manager.get_port_id(
diff --git a/amber/src/main/python/core/util/console_message/replace_print.py
b/amber/src/main/python/core/util/console_message/replace_print.py
index 7feeeeb52d..6a31050cd5 100644
--- a/amber/src/main/python/core/util/console_message/replace_print.py
+++ b/amber/src/main/python/core/util/console_message/replace_print.py
@@ -64,6 +64,13 @@ class replace_print(ContextManager):
if "file" in kwargs:
self.builtins_print(*args, **kwargs)
return
+ # The frame that called print(). Look __name__ up with .get, not
+ # []: code run through eval/exec against a bare dict (a Loop End's
+ # condition/update, a Loop Start's output) has no __name__ in its
+ # frame globals -- eval/exec inject __builtins__ but never
+ # __name__ -- and a print() there must not crash the capture.
+ caller = inspect.currentframe().f_back
+ module_name = caller.f_globals.get("__name__", "<unknown>")
with StringIO() as tmp_buf, redirect_stdout(tmp_buf):
self.builtins_print(*args, **kwargs)
complete_str = tmp_buf.getvalue()
@@ -71,11 +78,7 @@ class replace_print(ContextManager):
worker_id=self.worker_id,
timestamp=current_time_in_local_timezone(),
msg_type=ConsoleMessageType.PRINT,
- source=(
-
f"{inspect.currentframe().f_back.f_globals['__name__']}"
- f":{inspect.currentframe().f_back.f_code.co_name}"
- f":{inspect.currentframe().f_back.f_lineno}"
- ),
+
source=(f"{module_name}:{caller.f_code.co_name}:{caller.f_lineno}"),
title=complete_str,
message="",
)
diff --git
a/amber/src/main/scala/org/apache/texera/amber/engine/architecture/scheduling/RegionExecutionManager.scala
b/amber/src/main/scala/org/apache/texera/amber/engine/architecture/scheduling/RegionExecutionManager.scala
index 0b32b74101..2f19887cc3 100644
---
a/amber/src/main/scala/org/apache/texera/amber/engine/architecture/scheduling/RegionExecutionManager.scala
+++
b/amber/src/main/scala/org/apache/texera/amber/engine/architecture/scheduling/RegionExecutionManager.scala
@@ -113,10 +113,10 @@ class RegionExecutionManager(
maxTerminationAttempts: Int =
RegionExecutionManager.DefaultMaxTerminationAttempts,
killRetryBaseBackoffMs: Long =
RegionExecutionManager.DefaultKillRetryBaseBackoffMs,
killRetryTimer: Timer = new JavaTimer(true),
- // Loop-back write addresses (Loop Start logical op id -> its input port's
- // state URI), shipped to every worker in InitializeExecutorRequest. See
- // WorkflowExecutionManager.loopStartStateUris.
- loopStartStateUris: Map[String, String] = Map.empty
+ // Loop bookkeeping addresses (Loop Start logical op id -> its input
+ // port's BASE materialization URI), shipped to every worker in
+ // InitializeExecutorRequest. See
WorkflowExecutionManager.loopStartPortUris.
+ loopStartPortUris: Map[String, String] = Map.empty
) extends AmberLogging {
initRegionExecution()
@@ -452,7 +452,7 @@ class RegionExecutionManager(
workerConfigs.length,
physicalOp.opExecInitInfo,
physicalOp.isSourceOperator,
- loopStartStateUris
+ loopStartPortUris
),
asyncRPCClient.mkContext(workerId)
)
diff --git
a/amber/src/main/scala/org/apache/texera/amber/engine/architecture/scheduling/WorkflowExecutionManager.scala
b/amber/src/main/scala/org/apache/texera/amber/engine/architecture/scheduling/WorkflowExecutionManager.scala
index 0b63d1f17c..b736cd171c 100644
---
a/amber/src/main/scala/org/apache/texera/amber/engine/architecture/scheduling/WorkflowExecutionManager.scala
+++
b/amber/src/main/scala/org/apache/texera/amber/engine/architecture/scheduling/WorkflowExecutionManager.scala
@@ -21,7 +21,6 @@ package org.apache.texera.amber.engine.architecture.scheduling
import com.twitter.util.Future
import com.typesafe.scalalogging.LazyLogging
-import org.apache.texera.amber.core.storage.VFSURIFactory
import org.apache.texera.amber.core.workflow.{GlobalPortIdentity, PhysicalLink}
import
org.apache.texera.amber.engine.architecture.scheduling.config.InputPortConfig
import org.apache.texera.amber.engine.architecture.common.{
@@ -57,15 +56,21 @@ class WorkflowExecutionManager(
}
/**
- * Loop-back write addresses shipped to every worker at setup; semantics are
- * documented on `InitializeExecutorRequest.loopStartStateUris`
(controlcommands.proto).
+ * Loop bookkeeping addresses shipped to every worker at setup; semantics
are
+ * documented on `InitializeExecutorRequest.loopStartPortUris`
(controlcommands.proto).
+ * The value is the BASE URI of the materialized port the Loop Start's
+ * input port READS FROM -- the upstream operator's output-port
+ * materialization (`cfg.storagePairs.head._1` below), not a port of the
+ * Loop Start itself. The Python worker derives the state URI (loop-back
+ * write) and the result URI (the loop's input table, read at consume time)
+ * from it.
*
* Derived from the final (resource-allocated) schedule, so the URIs are
* exactly the ones `AssignPort` later ships to the Loop Start's input
* readers. Kept a `def`: `schedule` is a `var` that is only populated after
* `StartWorkflow`, and the first use is inside `coordinateRegionExecutors`.
*/
- private def loopStartStateUris: Map[String, String] =
+ private def loopStartPortUris: Map[String, String] =
schedule.levelSets.values.flatten.flatMap { region =>
region.getOperators.filter(_.isLoopStart).map { op =>
require(
@@ -86,7 +91,7 @@ class WorkflowExecutionManager(
s"Loop Start input port $gpid expected exactly one reader URI, " +
s"got ${cfg.storagePairs.size}"
)
- op.id.logicalOpId.id ->
VFSURIFactory.stateURI(cfg.storagePairs.head._1).toString
+ op.id.logicalOpId.id -> cfg.storagePairs.head._1.toString
}
}.toMap
@@ -132,7 +137,7 @@ class WorkflowExecutionManager(
}
executedRegions.append(nextRegions)
- val loopUris = loopStartStateUris
+ val loopUris = loopStartPortUris
Future
.collect(
nextRegions
@@ -151,7 +156,7 @@ class WorkflowExecutionManager(
coordinatorConfig,
actorService,
actorRefService,
- loopStartStateUris = loopUris
+ loopStartPortUris = loopUris
)
regionExecutionManagers(region.id)
})
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 9da249f996..0a567b678b 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
@@ -253,7 +253,7 @@ class LoopIntegrationSpec
//
// This is the case that exercises the loop_counter increment/decrement and
// the loop_start_id routing on the StateFrame envelope (write addresses:
- // see InitializeExecutorRequest.loopStartStateUris): the outer loop's
state
+ // see InitializeExecutorRequest.loopStartPortUris): the outer loop's state
// passes THROUGH the inner LoopStart (+1) and inner LoopEnd (-1)
untouched,
// and is consumed only at the outer LoopEnd (counter == 0). A routing or
// counter bug would change the 4, or mis-consume and hang.
@@ -298,7 +298,7 @@ class LoopIntegrationSpec
// 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
+ // and the back-jump failed with "no loop bookkeeping 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")
@@ -373,10 +373,10 @@ class LoopIntegrationSpec
// on both engine sides), which reaches the LoopEnd with the "no loop"
// envelope (counter 0, no LoopStart stamp) AFTER the forwarded loop
// state. The LoopEnd must pass that unstamped state through instead of
- // consuming it: consuming would clobber the captured back-jump id with ""
- // ("no loop-back state URI configured for LoopStart ''") and hand
- // run_update a State with no `table` payload (KeyError). Regression test
- // for #discussion_r3648708075 on #6661.
+ // consuming it: taking it would clobber the captured back-jump id with ""
+ // and then fail the deferred consume's bookkeeping-URI lookup ("no loop
+ // bookkeeping URI configured for LoopStart ''"). Regression test for
+ // #discussion_r3648708075 on #6661.
val src = textInput("1\n2\n3")
val start = loopStart("i = 0", "table.iloc[i]")
val mid = statefulPythonUDF()
diff --git
a/amber/src/test/python/core/architecture/handlers/control/test_initialize_executor_handler.py
b/amber/src/test/python/core/architecture/handlers/control/test_initialize_executor_handler.py
index 7b4cd2ec68..036bd3735f 100644
---
a/amber/src/test/python/core/architecture/handlers/control/test_initialize_executor_handler.py
+++
b/amber/src/test/python/core/architecture/handlers/control/test_initialize_executor_handler.py
@@ -43,10 +43,10 @@ def make_request(**kwargs) -> InitializeExecutorRequest:
def make_handler() -> InitializeExecutorHandler:
"""Wire a handler with a SimpleNamespace context exposing the fields the
- handler writes: executor_manager and loop_start_state_uris."""
+ handler writes: executor_manager and loop_start_port_uris."""
context = SimpleNamespace(
executor_manager=MagicMock(),
- loop_start_state_uris={},
+ loop_start_port_uris={},
)
return InitializeExecutorHandler(context)
@@ -60,7 +60,7 @@ class TestInitializeExecutorHandler:
"# code", False, "python"
)
- def test_stores_loop_start_state_uris_on_context(self):
+ def test_stores_loop_start_port_uris_on_context(self):
# The loop-back write addresses (LoopStart op id -> its input port's
# state URI) are per-operator setup config delivered on this RPC; the
# handler must expose them on the context for a Loop End's
@@ -68,18 +68,16 @@ class TestInitializeExecutorHandler:
handler = make_handler()
asyncio.run(
handler.initialize_executor(
- make_request(loop_start_state_uris={"loop-start-1":
"vfs:///x/state"})
+ make_request(loop_start_port_uris={"loop-start-1": "vfs:///x"})
)
)
- assert handler.context.loop_start_state_uris == {
- "loop-start-1": "vfs:///x/state"
- }
+ assert handler.context.loop_start_port_uris == {"loop-start-1":
"vfs:///x"}
def test_defaults_to_empty_map_for_plans_without_loops(self):
# betterproto defaults an absent map field to {}; the handler must
# store that default rather than leaving stale config behind (a
# recreated worker re-runs initialize_executor every region run).
handler = make_handler()
- handler.context.loop_start_state_uris = {"stale": "vfs:///old"}
+ handler.context.loop_start_port_uris = {"stale": "vfs:///old"}
asyncio.run(handler.initialize_executor(make_request()))
- assert handler.context.loop_start_state_uris == {}
+ assert handler.context.loop_start_port_uris == {}
diff --git a/amber/src/test/python/core/models/test_loop_operators.py
b/amber/src/test/python/core/models/test_loop_operators.py
index f9dad97327..6b496a5f17 100644
--- a/amber/src/test/python/core/models/test_loop_operators.py
+++ b/amber/src/test/python/core/models/test_loop_operators.py
@@ -29,10 +29,11 @@ Coverage:
- LoopEnd's process_table identity yield; condition is abstract.
- The guarded eval/exec helpers (eval_output / run_update / eval_condition)
keep the reserved `table` name out of the persistent loop state, so user
- code cannot silently clobber loop machinery. The table crosses the loop
- boundary as Arrow IPC bytes (see table_to_ipc_bytes in core.models.table);
- a user loop variable named `table` is a raised collision, not a silent
- drop (TestReservedNameCollision).
+ code cannot silently clobber loop machinery. The table never rides the
+ State content: the runtime reads it from the Loop Start's input-port
+ materialization and injects it via attach_loop_table; a user loop variable
+ named `table` is a raised collision, not a silent drop
+ (TestReservedNameCollision).
- A multi-iteration loop driven to completion through the operators and the
State to_tuple/from_tuple round-trip (TestLoopRunsToCompletion).
- The exact generated-code shape -- base64 + decode_python_template + exec,
@@ -51,7 +52,6 @@ so their handling is covered in
test_main_loop.py::TestMainLoop.
import base64
from typing import Iterator, Optional
-import pyarrow as pa
import pytest
from core.models import State, Table, TableLike, Tuple
@@ -60,7 +60,6 @@ from core.models.operator import (
LoopEndOperator,
LoopStartOperator,
)
-from core.models.table import table_from_ipc_bytes, table_to_ipc_bytes
# ---------------------------------------------------------------------------
@@ -121,9 +120,11 @@ class _StubLoopEnd(LoopEndOperator):
# ---------------------------------------------------------------------------
-def _ipc_one_row():
- """One-row table as Arrow IPC bytes (the loop `table` payload)."""
- return table_to_ipc_bytes(Table([Tuple({"v": 1})]))
+def _one_row_table() -> Table:
+ """One-row Table, standing in for the loop's input table that the runtime
+ reads from the Loop Start's input-port materialization and injects via
+ ``attach_loop_table``."""
+ return Table([Tuple({"v": 1})])
class TestLoopStartProcessState:
@@ -150,58 +151,22 @@ class TestLoopStartProcessState:
# ---------------------------------------------------------------------------
-class TestBufferedTableAccessor:
- """`TableOperator._buffered_table(port)` replaces the name-mangled
- `self._TableOperator__table_data[port]` read, so a rename of the parent
- class doesn't silently break LoopStart's table access."""
-
- def test_returns_buffered_tuples_as_table(self):
- op = _StubLoopStart()
- op.open()
- list(op.process_tuple(Tuple({"v": 1}), port=0))
- list(op.process_tuple(Tuple({"v": 2}), port=0))
-
- table = op._buffered_table(port=0)
-
- assert isinstance(table, Table)
- assert list(table.as_tuples()) == [Tuple({"v": 1}), Tuple({"v": 2})]
-
- def test_buffers_are_keyed_by_port(self):
- op = _StubLoopStart()
- op.open()
- list(op.process_tuple(Tuple({"v": 1}), port=0))
- list(op.process_tuple(Tuple({"v": 99}), port=1))
-
- assert list(op._buffered_table(port=0).as_tuples()) == [Tuple({"v":
1})]
- assert list(op._buffered_table(port=1).as_tuples()) == [Tuple({"v":
99})]
-
-
class TestLoopStartProduceStateOnFinish:
- def test_serializes_buffered_table_as_arrow_into_state_table_field(self):
- # produce_state_on_finish serializes the buffered table as an Apache
- # Arrow IPC stream, not pickle (see table_to_ipc_bytes in
- # core.models.table for why). The bytes must round-trip back to the
- # same tuples and parse as a real Arrow stream.
+ def test_produced_state_carries_only_user_variables_never_the_table(self):
+ # The input table does NOT ride the state: the LoopEnd runtime reads
+ # it from the Loop Start's input-port materialization at consume time
+ # (loopStartPortUris) and injects it via attach_loop_table. The
+ # produced state must therefore stay small, pure-JSON user variables.
op = _StubLoopStart()
op.open()
- # Drive a couple of tuples through to populate the per-port buffer.
list(op.process_tuple(Tuple({"v": 1}), port=0))
list(op.process_tuple(Tuple({"v": 2}), port=0))
produced = op.produce_state_on_finish(port=0)
assert isinstance(produced, dict)
- assert "table" in produced
- assert isinstance(produced["table"], bytes), "table must be serialized
bytes"
- # The bytes are an Arrow IPC stream (stronger than a no-pickle-prefix
- # check): if a future change swaps the encoder back to pickle, the
- # Arrow reader raises here.
- with pa.ipc.open_stream(pa.py_buffer(produced["table"])) as reader:
- reader.read_all()
- # Round-trip through the public helper must give back our two tuples.
- decoded = table_from_ipc_bytes(produced["table"])
- assert isinstance(decoded, Table)
- assert list(decoded.as_tuples()) == [Tuple({"v": 1}), Tuple({"v": 2})]
+ assert "table" not in produced
+ produced.to_tuple(0) # pure-JSON user vars: must serialize cleanly
def test_user_state_fields_survive_into_produced_state(self):
# Any vars the user set in open() (e.g. i, accumulators) must
@@ -216,8 +181,10 @@ class TestLoopStartProduceStateOnFinish:
assert produced["i"] == 0
assert produced["acc"] == []
# loop_counter is no longer seeded into the operator's state; it is
- # runtime-owned and rides on the StateFrame envelope.
+ # runtime-owned and rides on the StateFrame envelope. The table is
+ # injected at the LoopEnd by the runtime, never embedded here.
assert "loop_counter" not in produced
+ assert "table" not in produced
# ---------------------------------------------------------------------------
@@ -261,6 +228,20 @@ class TestLoopEndBase:
# None is what condition() short-circuits on.
assert op._loop_table is None
assert op.condition() is False
+ # Attaching the table alone (what the runtime does right before the
+ # consume) must NOT mark the loop as consumed: only a successful
+ # run_update does.
+ op.attach_loop_table(_one_row_table())
+ assert op._loop_table is None
+ assert op.condition() is False
+
+ def test_run_update_fails_loud_when_no_table_attached(self):
+ # The runtime must attach the loop's input table before the matching
+ # consume; a missing attach is a runtime wiring bug and must not
+ # surface as a confusing NameError from the user's expression.
+ op = _StubLoopEnd(update="i += 1")
+ with pytest.raises(RuntimeError, match="not attached"):
+ op.process_state(State({"i": 0}), port=0)
# ---------------------------------------------------------------------------
@@ -275,17 +256,13 @@ class TestLoopEndMatchingBranch:
# state flows downstream; the actual loop-back is driven by
# main_loop.complete() reading executor.state.
op = _StubLoopEnd(update="i += 1", condition_expr="i < 3")
- # Simulate LoopStart's produced state arriving here. The table rides as
- # Arrow IPC bytes (see produce_state_on_finish), not pickle.
- # The content carries only user data (i) and the per-iteration table
- # scratch. loop_counter / LoopStartId are runtime-owned and ride the
- # StateFrame envelope, never the content.
- incoming = State(
- {
- "i": 1,
- "table": _ipc_one_row(),
- }
- )
+ # Simulate the runtime's consume: it reads the loop's input table from
+ # the Loop Start's input-port materialization and attaches it, then
+ # hands over LoopStart's produced state (pure user variables --
+ # loop_counter / LoopStartId are runtime-owned and ride the StateFrame
+ # envelope, never the content).
+ op.attach_loop_table(_one_row_table())
+ incoming = State({"i": 1})
result = op.process_state(incoming, port=0)
@@ -299,17 +276,16 @@ class TestLoopEndMatchingBranch:
assert op.condition() is True # i became 2, 2 < 3
# Run another iteration to push i past the threshold.
- op.process_state(
- State(
- {
- "i": 2,
- "table": _ipc_one_row(),
- }
- ),
- port=0,
- )
+ op.attach_loop_table(_one_row_table())
+ op.process_state(State({"i": 2}), port=0)
assert op.condition() is False # i became 3, 3 < 3 is False
+ # Each update TAKES its attach: a further update without a fresh
+ # attach must fail loud rather than silently reuse the previous
+ # table (the clear in run_update is what this pins).
+ with pytest.raises(RuntimeError, match="not attached"):
+ op.process_state(State({"i": 3}), port=0)
+
# ---------------------------------------------------------------------------
# Nested-loop counter behaviour -- LoopStart +1, LoopEnd -1, and the
@@ -370,6 +346,10 @@ class TestLoopRunsToCompletion:
update="total += int(table.iloc[i]['v']); i += 1; output =
total",
condition_expr="i < len(table)",
)
+ # The runtime reads the loop's input table from the Loop Start's
+ # input-port materialization (the same rows the LoopStart
+ # buffered) and attaches it before the consume.
+ end.attach_loop_table(Table(rows))
end.process_state(forwarded, port=0)
if not end.condition():
break
@@ -419,7 +399,8 @@ class TestReservedNameCollision:
def test_loop_end_raises_when_update_rebinds_table(self):
# `update` rebinds `table`, which run_update would otherwise strip.
op = _StubLoopEnd(update="table = 1")
- incoming = State({"i": 1, "table": _ipc_one_row()})
+ op.attach_loop_table(_one_row_table())
+ incoming = State({"i": 1})
with pytest.raises(ValueError, match="'table' is reserved by the loop
runtime"):
op.process_state(incoming, port=0)
@@ -428,7 +409,8 @@ class TestReservedNameCollision:
# still flag the reserved-name collision (namespace.get) rather than
# escape as a bare KeyError on the missing key.
op = _StubLoopEnd(update="del table")
- incoming = State({"i": 1, "table": _ipc_one_row()})
+ op.attach_loop_table(_one_row_table())
+ incoming = State({"i": 1})
with pytest.raises(ValueError, match="'table' is reserved by the loop
runtime"):
op.process_state(incoming, port=0)
@@ -457,15 +439,15 @@ class TestLoopExpressionScoping:
# `update` assigns from a genexp whose body references the loop
# variable `base`.
op = _StubLoopEnd(update="total = sum(v + base for v in [1, 2, 3])")
- incoming = State({"base": 10, "table": _ipc_one_row()})
- op.process_state(incoming, port=0)
+ op.attach_loop_table(_one_row_table())
+ op.process_state(State({"base": 10}), port=0)
assert op.state["total"] == 36
def test_run_update_resolves_lambda_capturing_loop_vars(self):
# A lambda in `update` closes over the loop variable `offset`.
op = _StubLoopEnd(update="ranked = sorted([3, 1, 2], key=lambda e: e -
offset)")
- incoming = State({"offset": 0, "table": _ipc_one_row()})
- op.process_state(incoming, port=0)
+ op.attach_loop_table(_one_row_table())
+ op.process_state(State({"offset": 0}), port=0)
assert op.state["ranked"] == [1, 2, 3]
def test_eval_condition_resolves_genexp_over_loop_vars(self):
@@ -474,8 +456,8 @@ class TestLoopExpressionScoping:
op = _StubLoopEnd(
update="i += 1", condition_expr="all(x > floor for x in [1, 2, 3])"
)
- incoming = State({"i": 0, "floor": 0, "table": _ipc_one_row()})
- op.process_state(incoming, port=0)
+ op.attach_loop_table(_one_row_table())
+ op.process_state(State({"i": 0, "floor": 0}), port=0)
assert op.condition() is True
def test_run_initialization_resolves_genexp_over_init_vars(self):
@@ -501,8 +483,8 @@ class TestLoopExpressionScoping:
def test_updated_state_has_no_builtins_leak(self):
op = _StubLoopEnd(update="i += 1")
- incoming = State({"i": 0, "table": _ipc_one_row()})
- op.process_state(incoming, port=0)
+ op.attach_loop_table(_one_row_table())
+ op.process_state(State({"i": 0}), port=0)
assert "__builtins__" not in op.state
op.state.to_tuple(0) # must not raise
@@ -584,7 +566,8 @@ class TestGeneratedCodeShape:
exec(source, namespace)
op = namespace["ProcessLoopEndOperator"]()
- incoming = State({"i": 1, "note": "it's", "table": _ipc_one_row()})
+ op.attach_loop_table(_one_row_table())
+ incoming = State({"i": 1, "note": "it's"})
assert op.process_state(incoming, port=0) is None
assert op.state["i"] == 2 # update ran
assert op.condition() is True # quoted condition round-tripped
diff --git a/amber/src/test/python/core/runnables/test_main_loop.py
b/amber/src/test/python/core/runnables/test_main_loop.py
index 1da2d19691..5aa259c428 100644
--- a/amber/src/test/python/core/runnables/test_main_loop.py
+++ b/amber/src/test/python/core/runnables/test_main_loop.py
@@ -28,8 +28,10 @@ from threading import Thread
from core.models import (
DataFrame,
InternalQueue,
+ Schema,
State,
StateFrame,
+ Table,
Tuple,
)
from core.models.internal_queue import (
@@ -38,6 +40,7 @@ from core.models.internal_queue import (
ECMElement,
)
from core.models.operator import LoopEndOperator, LoopStartOperator
+from core.storage.vfs_uri_factory import VFSURIFactory
from core.runnables import MainLoop
from core.util import set_one_of
from proto.org.apache.texera.amber.core import (
@@ -2211,7 +2214,7 @@ class TestMainLoop:
executor.state = State({"i": 1})
main_loop.context.executor_manager.executor = executor
main_loop._loop_start_id = "loop-start-1"
- main_loop.context.loop_start_state_uris = {"loop-start-1":
"vfs:///x/state"}
+ main_loop.context.loop_start_port_uris = {"loop-start-1": "vfs:///x"}
console_msgs = []
pauses = []
@@ -2486,18 +2489,23 @@ class TestMainLoop:
# the outer loop's id rides through unchanged
assert emitted_id == "outer-loop"
- def test_loopend_consume_invokes_operator_at_counter_zero(
+ def test_loopend_consume_defers_operator_to_end_channel(
self, main_loop, monkeypatch
):
- # loop_counter == 0 is the matching loop: the runtime runs the operator
- # (consume) via the context switch. The operator returns None, so no
- # state is emitted; the loop-back is driven by complete() separately.
+ # loop_counter == 0 is the matching loop. The runtime STASHES the state
+ # here and runs the operator at EndChannel instead
+ # (_consume_pending_loop_state): the loop's input table is read from
the
+ # Loop Start's input-port materialization, and that read must not
+ # overlap this worker's own materialization reader, which is still
+ # streaming at consume time. Nothing observable moves: the matching
+ # consume emits no state downstream either way.
# Reviewer feedback (#discussion_r3285892237): the envelope's loop
# metadata (loop_counter / loop_start_id) is internal runtime data --
# the runtime captures it onto its own instance state, and the
# user-facing State handed to the operator carries only the inner
# State's keys, never the envelope names.
- main_loop.context.executor_manager.executor = _FalseLoopEnd()
+ executor = _FalseLoopEnd()
+ main_loop.context.executor_manager.executor = executor
emitted, switched, reset_calls = self._capture_state_emit(
main_loop, monkeypatch
)
@@ -2507,17 +2515,29 @@ class TestMainLoop:
"get_output_state",
lambda: None,
)
+ # Stub the runtime's table read (the real one opens the Loop Start's
+ # input-port materialization; pinned by the jump/read URI tests).
+ loop_table = Table([Tuple({"v": 1})])
+ reads = []
+
+ def _read():
+ reads.append(True)
+ return loop_table
+
+ monkeypatch.setattr(main_loop, "_read_loop_input_table", _read)
+ incoming = State({"i": 42, "acc": [1, 2, 3]})
main_loop._process_state_frame(
- StateFrame(
- State({"i": 42, "acc": [1, 2, 3]}),
- loop_counter=0,
- loop_start_id="outer-loop",
- )
+ StateFrame(incoming, loop_counter=0, loop_start_id="outer-loop")
)
- assert switched == [True], "consume branch must invoke the operator"
- assert emitted == [], "operator returned None -> nothing emitted"
+ # At consume: state stashed, operator NOT invoked, table NOT read yet
+ # (no storage I/O while this worker's reader is still streaming).
+ assert switched == [], "consume must not invoke the operator yet"
+ assert reads == [], "the table must not be read at consume time"
+ assert main_loop._pending_loop_state is incoming
+ assert executor._attached_table is None
+ assert emitted == [], "the matching consume emits no state downstream"
assert reset_calls == [], "consume / single loop must not reset output"
# The runtime captured the envelope metadata onto its own instance
# state...
@@ -2525,13 +2545,25 @@ class TestMainLoop:
# ...but never wrote it into the user-facing State the operator sees.
# (The consume branch sets `current_input_state` BEFORE the stubbed
# context switch, so this is exactly what the operator would receive.)
- passed_to_operator = (
- main_loop.context.state_processing_manager.current_input_state
+ # ...and the state it stashed for the operator carries only the inner
+ # State's keys, never the envelope names.
+ assert set(main_loop._pending_loop_state.keys()) == {"i", "acc"}
+ assert "loop_start_id" not in main_loop._pending_loop_state
+ assert "loop_counter" not in main_loop._pending_loop_state
+
+ # Then at EndChannel the deferred consume runs: the table is read once
+ # (the reader has finished by now) and handed to the operator.
+ consumed = []
+ monkeypatch.setattr(
+ executor, "process_state", lambda st, port: consumed.append((st,
port))
)
- assert isinstance(passed_to_operator, State)
- assert set(passed_to_operator.keys()) == {"i", "acc"}
- assert "loop_start_id" not in passed_to_operator
- assert "loop_counter" not in passed_to_operator
+
+ main_loop._consume_pending_loop_state(executor)
+
+ assert reads == [True], "the table is read exactly once, at EndChannel"
+ assert executor._attached_table is loop_table
+ assert consumed == [(incoming, 0)]
+ assert main_loop._pending_loop_state is None, "stash must be cleared"
def test_loopend_forwards_unstamped_state_without_consuming(
self, main_loop, monkeypatch
@@ -2539,9 +2571,10 @@ class TestMainLoop:
# A loop-body operator that emits its own boundary state
# (produce_state_on_start/finish -- a public API on both engine sides)
# sends it with the "no loop" envelope (counter 0, id ""). That state
- # is NOT the loop's own boundary state: consuming it would clobber the
- # captured back-jump id with "" and hand run_update a State with no
- # `table` payload (KeyError). A real loop state is always stamped --
+ # is NOT the loop's own boundary state: taking it would clobber the
+ # captured back-jump id with "" and then fail the deferred consume's
+ # bookkeeping-URI lookup for LoopStart ''. A real loop state is always
+ # stamped --
# the matching LoopStart stamps its own id on every iteration's output
# -- so an UNstamped counter-0 frame at a LoopEnd must be forwarded
# downstream unchanged, skipping the operator, like any default
@@ -2680,10 +2713,12 @@ class TestMainLoop:
# A loop body may branch and converge on the Loop End, so its input
# port takes fan-in. Every reader on that port replays its own
# branch's states, so the SAME iteration's state arrives once per
- # branch. Only the first may be consumed: running the user's `update`
- # again would advance the loop variables once per branch (e.g. `i += 1`
- # twice), ending the loop early with wrong results.
- main_loop.context.executor_manager.executor = _FalseLoopEnd()
+ # branch. The stash must take the FIRST and drop the rest -- a later
+ # arrival silently overwriting _pending_loop_state would make the
+ # consumed copy depend on reader scheduling. Assert through the whole
+ # deferred path, not just the stash.
+ executor = _FalseLoopEnd()
+ main_loop.context.executor_manager.executor = executor
emitted, switched, reset_calls = self._capture_state_emit(
main_loop, monkeypatch
)
@@ -2692,24 +2727,39 @@ class TestMainLoop:
"get_output_state",
lambda: None,
)
+ monkeypatch.setattr(
+ main_loop, "_read_loop_input_table", lambda: Table([Tuple({"v":
1})])
+ )
+ consumed = []
+ monkeypatch.setattr(
+ executor, "process_state", lambda st, port: consumed.append((st,
port))
+ )
- def deliver():
+ first = State({"i": 42})
+ second = State({"i": 42})
+
+ def deliver(state):
main_loop._process_state_frame(
- StateFrame(
- State({"i": 42}),
- loop_counter=0,
- loop_start_id="outer-loop",
- )
+ StateFrame(state, loop_counter=0, loop_start_id="outer-loop")
)
- deliver() # branch A
- deliver() # branch B replays the same iteration's state
+ deliver(first) # branch A
+ deliver(second) # branch B replays the same iteration's state
- assert switched == [True], "the operator must consume exactly once"
+ assert main_loop._loop_state_consumed is True
+ assert main_loop._pending_loop_state is first, "the duplicate must not
stash"
assert emitted == [], "a consume emits nothing downstream, duplicate
or not"
assert reset_calls == []
assert main_loop._loop_start_id == "outer-loop"
- assert main_loop._loop_state_consumed is True
+
+ main_loop._consume_pending_loop_state(executor)
+
+ assert switched == [], "the deferred consume does not switch context"
+ assert consumed == [(first, 0)], "the operator must update exactly
once"
+ # The consume re-arms the duplicate guard (everything on the port is
+ # already in by EndChannel), making the flag per-execution by
+ # construction rather than by worker recreation.
+ assert main_loop._loop_state_consumed is False
# ------------------------------------------------------------------ #
# _jump_to_loop_start
@@ -2719,7 +2769,7 @@ class TestMainLoop:
# stamps is now computed inline in process_input_state via the
# canonical `get_logical_op_id` (pinned by that helper's own suite),
# and the loop-back write address is not computed worker-side at all:
- # it is setup config (InitializeExecutorRequest.loopStartStateUris --
+ # it is setup config (InitializeExecutorRequest.loopStartPortUris --
# see the proto comment for the full story).
# ------------------------------------------------------------------ #
@@ -2769,16 +2819,220 @@ class TestMainLoop:
_create,
)
+ @pytest.mark.timeout(2)
+ def test_complete_flushes_prints_from_the_user_condition(
+ self, main_loop, monkeypatch
+ ):
+ # complete() flushes console messages on entry, before condition()
+ # runs, and then shuts the worker down -- so without a second flush a
+ # print() inside the user's condition would be captured and never sent.
+ #
+ # Mirrors LoopEndOpDesc.generatePythonCode: the user's text goes
+ # through eval_condition, i.e. eval() against a bare namespace dict --
+ # NOT a method of a module-level class -- so the print executes in a
+ # frame whose globals have no __name__. The capture must survive that
+ # (replace_print looks the module name up with .get); a plain method
+ # here would pass even with a capture that crashes on the generated
+ # path.
+ class _PrintingLoopEnd(LoopEndOperator):
+ def condition(self):
+ return self.eval_condition("print('hello from condition') or
False")
+
+ executor = _PrintingLoopEnd()
+ # eval_condition short-circuits to False before a consume; run a
+ # minimal successful update first so the user expression actually
+ # evaluates (this is the state a real matching consume leaves behind).
+ executor.attach_loop_table(Table([Tuple({"v": 1})]))
+ executor.run_update("pass", State())
+ main_loop.context.executor_manager.executor = executor
+
+ console_msgs = []
+ monkeypatch.setattr(
+ main_loop, "_send_console_message", lambda msg:
console_msgs.append(msg)
+ )
+ monkeypatch.setattr(main_loop.data_processor, "stop", lambda: None)
+ monkeypatch.setattr(
+ main_loop.context.state_manager, "transit_to", lambda state: None
+ )
+ monkeypatch.setattr(main_loop.context, "close", lambda: None)
+
+ class _Coordinator:
+ def worker_execution_completed(self, request):
+ pass
+
+ monkeypatch.setattr(
+ main_loop._async_rpc_client, "coordinator_stub", lambda:
_Coordinator()
+ )
+
+ main_loop.complete()
+
+ printed = [m for m in console_msgs if "hello from condition" in
m.title]
+ assert printed, (
+ "a print() in the user's condition must reach the console before "
+ f"the worker completes; sent: {[m.title for m in console_msgs]}"
+ )
+
+ def test_deferred_consume_captures_user_prints(self, main_loop,
monkeypatch):
+ # The `update` runs on the main loop thread, outside
+ # DataProcessor._executor_session, so its print capture has to be
+ # applied explicitly here -- otherwise a print() in the user's update
+ # goes to the worker's stdout and never reaches the console.
+ #
+ # Mirrors LoopEndOpDesc.generatePythonCode: the user's text goes
+ # through run_update, i.e. exec() against a bare namespace dict, so
+ # the print executes in a frame whose globals have no __name__ and the
+ # capture must survive that (replace_print looks the module name up
+ # with .get). A plain print() in an overridden method here would pass
+ # even with a capture that crashes on the generated path.
+ class _PrintingLoopEnd(LoopEndOperator):
+ def condition(self):
+ return self.eval_condition("False")
+
+ def process_state(self, state, port):
+ self.run_update("print('hello from update')\ni += 1", state)
+ return None
+
+ executor = _PrintingLoopEnd()
+ main_loop.context.executor_manager.executor = executor
+ main_loop._pending_loop_state = State({"i": 1})
+ monkeypatch.setattr(
+ main_loop, "_read_loop_input_table", lambda: Table([Tuple({"v":
1})])
+ )
+
+ main_loop._consume_pending_loop_state(executor)
+
+ printed = [
+ msg
+ for msg in main_loop.context.console_message_manager.get_messages(
+ force_flush=True
+ )
+ if "hello from update" in msg.title
+ ]
+ assert printed, "the user's print must be captured as a console
message"
+ assert printed[0].msg_type == ConsoleMessageType.PRINT
+
+ def test_read_loop_input_table_opens_the_result_uri_of_the_configured_base(
+ self, main_loop, monkeypatch
+ ):
+ # The other half of the base-URI split (the jump test pins the state
+ # URI): the loop's input table is read from result_uri(base) of the
+ # SAME configured base, through DocumentFactory.open_document.
+ main_loop._loop_start_id = "outer-loop"
+ main_loop.context.loop_start_port_uris = {"outer-loop":
"vfs:///wf/port/outer"}
+
+ opened = []
+ rows = [Tuple({"v": 1}), Tuple({"v": 2})]
+ schema = Schema(raw_schema={"v": "LONG"})
+ casts = []
+ for tup in rows:
+ monkeypatch.setattr(
+ tup,
+ "cast_to_schema",
+ lambda s, _t=tup: casts.append((_t, s)),
+ raising=True,
+ )
+
+ class _Doc:
+ def get(self):
+ return iter(rows)
+
+ monkeypatch.setattr(
+ "core.runnables.main_loop.DocumentFactory.open_document",
+ lambda uri: (opened.append(uri) or (_Doc(), schema)),
+ )
+
+ table = main_loop._read_loop_input_table()
+
+ assert opened == [VFSURIFactory.result_uri("vfs:///wf/port/outer")]
+ assert isinstance(table, Table)
+ assert list(table.as_tuples()) == rows
+ # Every tuple is normalized to the doc's schema, exactly like the
+ # input-port reader that streams this same doc.
+ assert casts == [(tup, schema) for tup in rows]
+
+ def test_read_loop_input_table_raises_when_uri_not_configured(
+ self, main_loop, monkeypatch
+ ):
+ # Same fail-loud contract as the back-edge write: a LoopEnd whose
+ # captured id has no setup-config entry must raise rather than read
+ # from a guessed location.
+ main_loop._loop_start_id = "outer-loop"
+ main_loop.context.loop_start_port_uris = {}
+ opened = []
+ monkeypatch.setattr(
+ "core.runnables.main_loop.DocumentFactory.open_document",
+ lambda uri: (opened.append(uri) or (None, None)),
+ )
+
+ with pytest.raises(RuntimeError, match="no loop bookkeeping URI"):
+ main_loop._read_loop_input_table()
+
+ assert opened == [], "must fail before touching storage"
+
+ @pytest.mark.timeout(2)
+ def test_end_channel_holds_the_region_when_the_deferred_consume_fails(
+ self, main_loop, monkeypatch
+ ):
+ # The deferred consume runs the table read and the user's `update`.
+ # Both can fail, and the failure must hold the region: complete() is
+ # the tail of _process_end_channel, so reporting the error there would
+ # arrive after port_completed had already been sent for every port and
+ # would read as a false success (region completion is port-based).
+ class _BoomLoopEnd(LoopEndOperator):
+ def condition(self):
+ return False
+
+ def process_state(self, state, port):
+ raise ValueError("name 'i' is not defined")
+
+ executor = _BoomLoopEnd()
+ main_loop.context.executor_manager.executor = executor
+ main_loop._pending_loop_state = State({"i": 1})
+ monkeypatch.setattr(
+ main_loop, "_read_loop_input_table", lambda: Table([Tuple({"v":
1})])
+ )
+
+ completed = []
+ port_completed_calls = []
+ console_msgs = []
+ monkeypatch.setattr(main_loop, "process_input_state", lambda *a, **k:
None)
+ monkeypatch.setattr(main_loop, "process_input_tuple", lambda: None)
+ monkeypatch.setattr(main_loop, "complete", lambda:
completed.append(True))
+ monkeypatch.setattr(
+ main_loop, "_send_console_message", lambda msg:
console_msgs.append(msg)
+ )
+ monkeypatch.setattr(
+ main_loop.context.pause_manager,
+ "pause",
+ lambda pause_type, change_state=True: None,
+ )
+
+ class _Coordinator:
+ def port_completed(self, request):
+ port_completed_calls.append(request)
+
+ monkeypatch.setattr(
+ main_loop._async_rpc_client, "coordinator_stub", lambda:
_Coordinator()
+ )
+
+ main_loop._process_end_channel()
+
+ assert main_loop.context.exception_manager.has_exception()
+ error_msgs = [m for m in console_msgs if m.msg_type ==
ConsoleMessageType.ERROR]
+ assert len(error_msgs) == 1
+ assert "name 'i' is not defined" in error_msgs[0].title
+ assert port_completed_calls == [], "no port may be reported complete"
+ assert completed == [], "the worker must not complete"
+
def test_jump_to_loop_start_sends_rpc_then_writes_state_in_order(
self, main_loop, monkeypatch
):
# One shared event log for the jump RPC and the storage calls, so
# the cross-channel ordering is pinned along with each contract.
main_loop._loop_start_id = "outer-loop"
- # The write address is setup-injected config keyed by the captured id.
- main_loop.context.loop_start_state_uris = {
- "outer-loop": "vfs:///wf/state/outer"
- }
+ # The bookkeeping BASE URI is setup-injected config keyed by the
+ # captured id; the write address is derived from it (state_uri).
+ main_loop.context.loop_start_port_uris = {"outer-loop":
"vfs:///wf/port/outer"}
events = []
self._patch_create_document(monkeypatch, events)
@@ -2800,14 +3054,15 @@ class TestMainLoop:
assert kind == "jump"
assert request.target_operator_id.id == "outer-loop"
# (ii) Then the exact iceberg write contract, in order:
- # create_document with the configured URI and State.SCHEMA, open
+ # create_document with the state URI DERIVED from the configured base
+ # URI and State.SCHEMA, open
# writer("0"), a single put_one with the State as a depth-0 tuple
# (the back-edge fires only after the matching LoopEnd consumed at
# loop_counter == 0, so the next iteration starts at depth 0),
# then close. The tuple object's internals are exercised elsewhere.
assert events[1] == (
"create_document",
- "vfs:///wf/state/outer",
+ VFSURIFactory.state_uri("vfs:///wf/port/outer"),
State.SCHEMA,
)
assert events[2] == ("writer", "0")
@@ -2824,7 +3079,7 @@ class TestMainLoop:
# RPC and before any storage write -- rewinding the schedule
# without a back-edge write would hang the loop.
main_loop._loop_start_id = "outer"
- main_loop.context.loop_start_state_uris = {}
+ main_loop.context.loop_start_port_uris = {}
rpc_calls = []
write_log = []
@@ -2833,7 +3088,7 @@ class TestMainLoop:
class _Executor:
state = State({"i": 7})
- with pytest.raises(RuntimeError, match="no loop-back state URI"):
+ with pytest.raises(RuntimeError, match="no loop bookkeeping URI"):
main_loop._jump_to_loop_start(
_Executor(), self._stub_coordinator(rpc_calls)
)
diff --git
a/amber/src/test/scala/org/apache/texera/amber/engine/architecture/worker/WorkerSpec.scala
b/amber/src/test/scala/org/apache/texera/amber/engine/architecture/worker/WorkerSpec.scala
index 7093dbfe8e..11cf67ab87 100644
---
a/amber/src/test/scala/org/apache/texera/amber/engine/architecture/worker/WorkerSpec.scala
+++
b/amber/src/test/scala/org/apache/texera/amber/engine/architecture/worker/WorkerSpec.scala
@@ -201,7 +201,7 @@ class WorkerSpec
"org.apache.texera.amber.engine.architecture.worker.DummyOperatorExecutor"
),
isSource = false,
- loopStartStateUris = Map.empty
+ loopStartPortUris = Map.empty
),
AsyncRPCContext(COORDINATOR, identifier1),
4
diff --git
a/amber/src/test/scala/org/apache/texera/amber/engine/architecture/worker/WorkflowWorkerSpec.scala
b/amber/src/test/scala/org/apache/texera/amber/engine/architecture/worker/WorkflowWorkerSpec.scala
index a5b31b4801..277ff486cd 100644
---
a/amber/src/test/scala/org/apache/texera/amber/engine/architecture/worker/WorkflowWorkerSpec.scala
+++
b/amber/src/test/scala/org/apache/texera/amber/engine/architecture/worker/WorkflowWorkerSpec.scala
@@ -119,7 +119,7 @@ class WorkflowWorkerSpec
"org.apache.texera.amber.engine.architecture.worker.DummyOperatorExecutor"
),
isSource = false,
- loopStartStateUris = Map.empty
+ loopStartPortUris = Map.empty
)
)
diff --git
a/amber/src/test/scala/org/apache/texera/amber/engine/architecture/worker/managers/SerializationManagerSpec.scala
b/amber/src/test/scala/org/apache/texera/amber/engine/architecture/worker/managers/SerializationManagerSpec.scala
index 30f91ddf79..5c6278a1c0 100644
---
a/amber/src/test/scala/org/apache/texera/amber/engine/architecture/worker/managers/SerializationManagerSpec.scala
+++
b/amber/src/test/scala/org/apache/texera/amber/engine/architecture/worker/managers/SerializationManagerSpec.scala
@@ -57,7 +57,7 @@ class SerializationManagerSpec extends AnyFlatSpec {
totalWorkerCount = totalWorkers,
opExecInitInfo = info,
isSource = false,
- loopStartStateUris = Map.empty
+ loopStartPortUris = Map.empty
)
"SerializationManager.restoreExecutorState" should
diff --git
a/common/workflow-core/src/main/scala/org/apache/texera/amber/core/workflow/PhysicalOp.scala
b/common/workflow-core/src/main/scala/org/apache/texera/amber/core/workflow/PhysicalOp.scala
index a1e9305066..c4dc2307ee 100644
---
a/common/workflow-core/src/main/scala/org/apache/texera/amber/core/workflow/PhysicalOp.scala
+++
b/common/workflow-core/src/main/scala/org/apache/texera/amber/core/workflow/PhysicalOp.scala
@@ -208,8 +208,9 @@ case class PhysicalOp(
// restricting it to only the requiring operator's regions is a possible
// future optimization. Default false.
requiresMaterializedExecution: Boolean = false,
- // Marks the Loop Start operator of a loop; the scheduler resolves the
loop-back
- // write address from it (see
InitializeExecutorRequest.loopStartStateUris). Default false.
+ // Marks the Loop Start operator of a loop; the scheduler resolves the
loop's
+ // bookkeeping base URI from it (loop-back write address + input-table
read;
+ // see InitializeExecutorRequest.loopStartPortUris). Default false.
isLoopStart: Boolean = false,
// hint for number of workers
suggestedWorkerNum: Option[Int] = None,
diff --git
a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/loop/LoopEndOpDescSpec.scala
b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/loop/LoopEndOpDescSpec.scala
index c8dc04688f..2c87aeba64 100644
---
a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/loop/LoopEndOpDescSpec.scala
+++
b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/loop/LoopEndOpDescSpec.scala
@@ -96,11 +96,12 @@ class LoopEndOpDescSpec extends AnyFlatSpec with
LoopOpDescSpecMixin {
}
it should "not exec user code inline against self.state (the guard lives in
the base helpers)" in {
- // The table decode (Arrow IPC) and the user update/condition exec run in
- // the LoopEnd base helpers (run_update / eval_condition) against a
- // throwaway namespace, so the reserved `table` never persists in the loop
- // state (a user rebind raises). The generated operator must not touch it
- // directly or exec user code against self.state.
+ // The user update/condition exec runs in the LoopEnd base helpers
+ // (run_update / eval_condition) against a throwaway namespace seeded with
+ // the runtime-attached input table, so the reserved `table` never
+ // persists in the loop state (a user rebind raises). The generated
+ // operator must not touch it directly or exec user code against
+ // self.state.
val code = desc(update = "i = i + 7", condition = "i <
3").generatePythonCode()
code should not include "exec("
code should not include "self.state[\"table\"]"
diff --git
a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/loop/LoopStartOpDescSpec.scala
b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/loop/LoopStartOpDescSpec.scala
index 94451b6ed7..d6853cd9c4 100644
---
a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/loop/LoopStartOpDescSpec.scala
+++
b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/loop/LoopStartOpDescSpec.scala
@@ -118,7 +118,7 @@ class LoopStartOpDescSpec extends AnyFlatSpec with
LoopOpDescSpecMixin {
it should "mark the physical op as the loop start" in {
// The scheduler resolves each Loop Start's loop-back write address (the
// state URI of its input port) from this flag and delivers it to workers
- // at setup via InitializeExecutorRequest.loopStartStateUris.
+ // at setup via InitializeExecutorRequest.loopStartPortUris.
desc().getPhysicalOp(workflowId, executionId).isLoopStart shouldBe true
}