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 ab58d477ba test(pyamber): extend core model, proxy and start-worker
handler coverage (#6988)
ab58d477ba is described below
commit ab58d477ba77d1f42cbe56f888dda96fe3fffad0
Author: Xinyuan Lin <[email protected]>
AuthorDate: Tue Jul 28 21:51:11 2026 -0700
test(pyamber): extend core model, proxy and start-worker handler coverage
(#6988)
### What changes were proposed in this PR?
Extends two pyamber suites and adds one new test file (59 tests total).
No source changes.
- **`core/models/single_blocking_io.py`** (~70% covered): extends the
existing suite over the uncovered branches.
- **`core/proxy/proxy_server.py`** (~85% covered): extends the existing
suite.
- **`core/architecture/handlers/control/start_worker_handler.py`** (~41%
covered, **no dedicated test**): new test file following the sibling
`test_end_worker_handler.py` pattern for constructing and asserting
against the handler's context/state manager.
Tests are hermetic and deterministic — no real sleeps, and no sockets or
threads left running.
### Any related issues, documentation, discussions?
Closes #6986.
### How was this PR tested?
`python -m pytest` over the three files -> 59 passed. `ruff format
--check` + `ruff check` clean.
### Was this PR authored or co-authored using generative AI tooling?
Generated-by: Claude Code (Opus 4.8 [1M context])
---
.../handlers/control/test_start_worker_handler.py | 218 +++++++++++++++++++++
.../python/core/models/test_single_blocking_io.py | 114 +++++++++++
.../test/python/core/proxy/test_proxy_server.py | 185 ++++++++++++++++-
3 files changed, 515 insertions(+), 2 deletions(-)
diff --git
a/amber/src/test/python/core/architecture/handlers/control/test_start_worker_handler.py
b/amber/src/test/python/core/architecture/handlers/control/test_start_worker_handler.py
new file mode 100644
index 0000000000..4d1afb30ef
--- /dev/null
+++
b/amber/src/test/python/core/architecture/handlers/control/test_start_worker_handler.py
@@ -0,0 +1,218 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+import asyncio
+from types import SimpleNamespace
+from unittest.mock import MagicMock
+
+import pytest
+
+from core.architecture.handlers.control.start_worker_handler import
StartWorkerHandler
+from core.architecture.managers.context import WORKER_STATE_TRANSITIONS
+from core.architecture.managers.state_manager import (
+ InvalidTransitionException,
+ StateManager,
+)
+from core.architecture.packaging.input_manager import InputManager
+from core.models.internal_queue import ECMElement, InternalQueue
+from proto.org.apache.texera.amber.core import (
+ ActorVirtualIdentity,
+ ChannelIdentity,
+ EmbeddedControlMessageIdentity,
+ PortIdentity,
+)
+from proto.org.apache.texera.amber.engine.architecture.rpc import (
+ EmbeddedControlMessageType,
+ EmptyRequest,
+ WorkerStateResponse,
+)
+from proto.org.apache.texera.amber.engine.architecture.worker import
WorkerState
+
+WORKER_ID = "worker-1"
+
+SOURCE_CHANNEL = ChannelIdentity(
+ InputManager.SOURCE_STARTER, ActorVirtualIdentity(WORKER_ID), False
+)
+SOURCE_PORT = PortIdentity(0, False)
+
+
+def make_context(
+ is_source: bool,
+ initial_state: WorkerState = WorkerState.READY,
+ input_manager=None,
+):
+ """Wire the slice of Context that StartWorkerHandler touches.
+
+ The state manager and the input queue are the real ones so state
+ transitions and the self-fed source markers are asserted against real
+ behavior; only the executor is stubbed, since the handler reads nothing
+ from it but `is_source`.
+ """
+ input_queue = InternalQueue()
+ return SimpleNamespace(
+ worker_id=WORKER_ID,
+ input_queue=input_queue,
+
executor_manager=SimpleNamespace(executor=SimpleNamespace(is_source=is_source)),
+ input_manager=(
+ input_manager
+ if input_manager is not None
+ else InputManager(WORKER_ID, input_queue)
+ ),
+ state_manager=StateManager(WORKER_STATE_TRANSITIONS, initial_state),
+ current_input_channel_id=None,
+ )
+
+
+def start(context) -> WorkerStateResponse:
+ return
asyncio.run(StartWorkerHandler(context).start_worker(EmptyRequest()))
+
+
+def drain(input_queue: InternalQueue):
+ return [input_queue.get() for _ in range(input_queue.size())]
+
+
+class TestStartWorkerOnSource:
+ def test_transits_to_running_and_reports_the_bumped_version(self):
+ context = make_context(is_source=True)
+
+ response = start(context)
+
+ assert isinstance(response, WorkerStateResponse)
+ assert response.state == WorkerState.RUNNING
+ # A source worker really transitions here, so the state manager's
+ # logical clock must advance and be reported with the new state.
+ assert response.state_version == 1
+ assert context.state_manager.get_current_state() == WorkerState.RUNNING
+
+ def test_registers_the_source_starter_channel_on_port_zero(self):
+ context = make_context(is_source=True)
+
+ start(context)
+
+ # A source has no upstream, so the handler fabricates port 0 and a
+ # channel from the synthetic SOURCE_STARTER actor to itself.
+ assert context.current_input_channel_id == SOURCE_CHANNEL
+ assert context.input_manager.get_port_id(SOURCE_CHANNEL) == SOURCE_PORT
+ port = context.input_manager.get_port(SOURCE_PORT)
+ assert port.get_channels() == {SOURCE_CHANNEL}
+ # The synthetic port carries no attributes and no materialized readers.
+ assert port.get_schema().get_attr_names() == []
+ assert context.input_manager.get_input_port_mat_reader_threads() == {
+ SOURCE_PORT: []
+ }
+
+ def test_self_feeds_start_then_end_channel_markers(self):
+ context = make_context(is_source=True)
+
+ start(context)
+
+ elements = drain(context.input_queue)
+ assert len(elements) == 2
+ assert all(isinstance(element, ECMElement) for element in elements)
+ assert [element.tag for element in elements] == [SOURCE_CHANNEL] * 2
+
+ start_ecm, end_ecm = (element.payload for element in elements)
+ # The start marker opens the channel without alignment; the end marker
+ # must be port-aligned so downstream completion is ordered correctly.
+ assert start_ecm.id == EmbeddedControlMessageIdentity("StartChannel")
+ assert start_ecm.ecm_type == EmbeddedControlMessageType.NO_ALIGNMENT
+ assert end_ecm.id == EmbeddedControlMessageIdentity("EndChannel")
+ assert end_ecm.ecm_type == EmbeddedControlMessageType.PORT_ALIGNMENT
+
+ def test_markers_carry_a_command_addressed_to_this_worker(self):
+ context = make_context(is_source=True)
+
+ start(context)
+
+ start_ecm, end_ecm = (element.payload for element in
drain(context.input_queue))
+ for ecm, method in ((start_ecm, "StartChannel"), (end_ecm,
"EndChannel")):
+ assert ecm.scope == []
+ # The mapping is keyed by the receiving worker, which is this
+ # worker itself since the source feeds its own input queue.
+ assert list(ecm.command_mapping) == [WORKER_ID]
+ invocation = ecm.command_mapping[WORKER_ID]
+ assert invocation.method_name == method
+ assert invocation.command_id == -1
+
+ def test_a_started_source_is_left_running_on_a_second_start(self):
+ # transit_to() is a no-op when already in the target state, so a
+ # repeated start must not raise nor bump the reported version.
+ context = make_context(is_source=True)
+ start(context)
+
+ response = start(context)
+
+ assert response.state == WorkerState.RUNNING
+ assert response.state_version == 1
+
+ def test_rejects_a_start_before_the_worker_is_ready(self):
+ # UNINITIALIZED -> RUNNING is not a legal edge; the handler must fail
+ # loudly instead of half-starting the source.
+ context = make_context(is_source=True,
initial_state=WorkerState.UNINITIALIZED)
+
+ with pytest.raises(InvalidTransitionException):
+ start(context)
+
+ assert context.state_manager.get_current_state() ==
WorkerState.UNINITIALIZED
+ assert context.input_queue.size() == 0
+ assert context.current_input_channel_id is None
+
+
+class TestStartWorkerOnNonSource:
+ def test_starts_materialization_reader_threads_when_present(self):
+ input_manager = MagicMock(spec=InputManager)
+ input_manager.get_input_port_mat_reader_threads.return_value = {
+ SOURCE_PORT: [object()]
+ }
+ context = make_context(is_source=False, input_manager=input_manager)
+
+ response = start(context)
+
+
input_manager.start_input_port_mat_reader_threads.assert_called_once_with()
+ # Reading from a materialized port does not start the worker; the
+ # main loop transitions on the first arriving tuple instead.
+ assert response.state == WorkerState.READY
+ assert response.state_version == 0
+ assert context.input_queue.size() == 0
+ assert context.current_input_channel_id is None
+
+ def test_does_nothing_without_materialization_reader_threads(self):
+ input_manager = MagicMock(spec=InputManager)
+ input_manager.get_input_port_mat_reader_threads.return_value = {}
+ context = make_context(is_source=False, input_manager=input_manager)
+
+ response = start(context)
+
+ input_manager.start_input_port_mat_reader_threads.assert_not_called()
+ input_manager.add_input_port.assert_not_called()
+ input_manager.register_input.assert_not_called()
+ assert response.state == WorkerState.READY
+ assert response.state_version == 0
+ assert context.input_queue.size() == 0
+
+ def test_reports_a_paused_state_untouched(self):
+ # The handler is a pure reporter for a non-source worker: whatever
+ # state the worker is in is echoed back with its version.
+ input_manager = MagicMock(spec=InputManager)
+ input_manager.get_input_port_mat_reader_threads.return_value = {}
+ context = make_context(is_source=False, input_manager=input_manager)
+ context.state_manager.transit_to(WorkerState.PAUSED)
+
+ response = start(context)
+
+ assert response.state == WorkerState.PAUSED
+ assert response.state_version == 1
diff --git a/amber/src/test/python/core/models/test_single_blocking_io.py
b/amber/src/test/python/core/models/test_single_blocking_io.py
index 1e284b4198..8969a6f368 100644
--- a/amber/src/test/python/core/models/test_single_blocking_io.py
+++ b/amber/src/test/python/core/models/test_single_blocking_io.py
@@ -17,6 +17,8 @@
from threading import Condition
+import pytest
+
from core.models.single_blocking_io import SingleBlockingIO
@@ -114,3 +116,115 @@ class TestReadline:
assert cond.notify_calls >= 1
# The value is cleared after being handed out.
assert io.value is None
+
+ def test_keeps_waiting_across_spurious_wakeups(self):
+ # A wakeup that does not publish a value must not end the loop:
+ # readline() has to re-check `value` and park again, otherwise pdb
+ # would receive None as a command line.
+ cond = ScriptedCondition()
+ io = SingleBlockingIO(cond)
+
+ def produce_on_third_wakeup():
+ if cond.wait_calls == 3:
+ io.write("late")
+ io.flush()
+
+ cond.on_wait = produce_on_third_wakeup
+
+ assert io.readline() == "late\n"
+ assert cond.wait_calls == 3
+ # Every parking round also notifies the producer side.
+ assert cond.notify_calls == 3
+
+ def test_ignores_a_limit_argument(self):
+ # pdb calls readline() through the IO API, which may pass a limit;
+ # the value is always handed out whole regardless.
+ io = SingleBlockingIO(Condition())
+ io.write("abcdef")
+ io.flush()
+
+ assert io.readline(2) == "abcdef\n"
+
+ def test_clears_the_value_even_when_waiting_raises(self):
+ # The clear lives in a `finally`, so an interrupted wait must not
+ # leave a stale line behind for the next reader to pick up.
+ cond = ScriptedCondition()
+ io = SingleBlockingIO(cond)
+
+ def explode():
+ io.value = "half-delivered\n"
+ raise KeyboardInterrupt
+
+ cond.on_wait = explode
+
+ with pytest.raises(KeyboardInterrupt):
+ io.readline()
+
+ assert io.value is None
+
+ def test_consecutive_lines_are_delivered_in_order(self):
+ # The IO holds one line at a time; a write/flush/readline cycle must
+ # be repeatable without any state leaking between lines.
+ io = SingleBlockingIO(Condition())
+
+ for line in ("first", "second", "third"):
+ io.write(line)
+ io.flush()
+ assert io.readline() == f"{line}\n"
+ assert io.value is None
+ assert io.buf == ""
+
+ def test_a_flush_overwrites_an_unread_value(self):
+ # Documented single-element semantics: there is no queue, so a second
+ # flush before a read replaces the pending line.
+ io = SingleBlockingIO(Condition())
+ io.write("stale")
+ io.flush()
+ io.write("fresh")
+ io.flush()
+
+ assert io.readline() == "fresh\n"
+
+
+class TestUnsupportedIOOperations:
+ """The remaining IO methods are deliberate no-ops: pdb never calls them,
+ but SingleBlockingIO is handed to pdb as a full IO, so they must be inert
+ rather than raising or returning junk."""
+
+ @pytest.mark.parametrize(
+ "call",
+ [
+ pytest.param(lambda io: io.close(), id="close"),
+ pytest.param(lambda io: io.fileno(), id="fileno"),
+ pytest.param(lambda io: io.isatty(), id="isatty"),
+ pytest.param(lambda io: io.read(4), id="read"),
+ pytest.param(lambda io: io.readable(), id="readable"),
+ pytest.param(lambda io: io.readlines(4), id="readlines"),
+ pytest.param(lambda io: io.seek(0, 0), id="seek"),
+ pytest.param(lambda io: io.seekable(), id="seekable"),
+ pytest.param(lambda io: io.tell(), id="tell"),
+ pytest.param(lambda io: io.truncate(0), id="truncate"),
+ pytest.param(lambda io: io.writable(), id="writable"),
+ pytest.param(lambda io: io.writelines(["a", "b"]),
id="writelines"),
+ pytest.param(lambda io: io.__next__(), id="next"),
+ pytest.param(lambda io: io.__iter__(), id="iter"),
+ pytest.param(lambda io: io.__enter__(), id="enter"),
+ pytest.param(lambda io: io.__exit__(None, None, None), id="exit"),
+ ],
+ )
+ def test_no_op_methods_return_none_without_raising(self, call):
+ io = SingleBlockingIO(Condition())
+
+ assert call(io) is None
+
+ def test_no_op_methods_do_not_disturb_the_pending_line(self):
+ io = SingleBlockingIO(Condition())
+ io.write("payload")
+ io.flush()
+
+ io.close()
+ io.writelines(["ignored"])
+ io.truncate(0)
+
+ # None of the inert methods may consume or mutate the buffered line.
+ assert io.readline() == "payload\n"
diff --git a/amber/src/test/python/core/proxy/test_proxy_server.py
b/amber/src/test/python/core/proxy/test_proxy_server.py
index 3055ce0c64..59d1f43bde 100644
--- a/amber/src/test/python/core/proxy/test_proxy_server.py
+++ b/amber/src/test/python/core/proxy/test_proxy_server.py
@@ -16,12 +16,13 @@
# under the License.
import threading
-from unittest.mock import patch
+from unittest.mock import MagicMock, patch
import pytest
+from pyarrow import Table
from pyarrow.flight import Action
-from core.proxy.proxy_server import ProxyServer
+from core.proxy.proxy_server import ProxyServer, get_free_local_port
class TestProxyServer:
@@ -85,3 +86,183 @@ class TestProxyServer:
next(results)
assert shutdown_started.wait(timeout=5)
mock_shutdown.assert_called_once()
+
+ @staticmethod
+ def do_put_args(command: bytes = b"cmd", data: Table = None):
+ """Build the (descriptor, reader, writer) triple that Flight hands to
+ do_put. Only the three attributes do_put touches are stubbed."""
+ if data is None:
+ data = Table.from_pydict({"x": [1, 2]})
+ descriptor = MagicMock()
+ descriptor.command = command
+ reader = MagicMock()
+ reader.read_all.return_value = data
+ return descriptor, reader, MagicMock()
+
+ def test_heartbeat_is_registered_and_acks(self, server):
+ # The default liveness action the client polls before sending work.
+ assert "heartbeat" in server._procedures
+ result = next(server.do_action(None, Action("heartbeat", b"")))
+ assert result.body.to_pybytes() == b"ack"
+
+ def test_unknown_action_raises_key_error(self, server):
+ with pytest.raises(KeyError, match="Unknown action"):
+ next(server.do_action(None, Action("no-such-action", b"")))
+
+ def test_registering_the_same_name_overwrites_the_previous_action(self,
server):
+ server.register("hello", lambda: "first")
+ server.register("hello", lambda: "second")
+
+ assert len(server._procedures) == 5 # 4 defaults + "hello", not 6
+ result = next(server.do_action(None, Action("hello", b"")))
+ assert result.body.to_pybytes() == b"second"
+
+ def test_action_returning_bytes_is_passed_through_unencoded(self, server):
+ # bytes results are forwarded verbatim; everything else is str()-ed.
+ server.register("raw", lambda: b"\x00\x01raw")
+
+ result = next(server.do_action(None, Action("raw", b"")))
+ assert result.body.to_pybytes() == b"\x00\x01raw"
+
+ def test_action_receives_the_body_as_payload_when_non_empty(self, server):
+ seen = []
+ server.register("echo", lambda payload: seen.append(payload) or
payload)
+
+ result = next(server.do_action(None, Action("echo", b"payload")))
+ assert seen == [b"payload"]
+ assert result.body.to_pybytes() == b"payload"
+
+ def test_error_inside_an_action_is_reraised_to_the_caller(self, server):
+ # register() wraps the action in a logging catcher, but with
+ # reraise=True so the failure still surfaces on the client call.
+ def boom():
+ raise ValueError("action failed")
+
+ server.register("boom", boom)
+ with pytest.raises(ValueError, match="action failed"):
+ next(server.do_action(None, Action("boom", b"")))
+
+ def test_list_actions_reports_names_with_descriptions(self, server):
+ server.register("hello", lambda: None, description="Say hello.")
+
+ listed = dict(server.list_actions(None))
+ assert listed["hello"] == "Say hello."
+ assert listed["shutdown"] == "Shut down this server."
+ assert listed["control"] == "Process the control message"
+ assert listed["actor"] == "Process the actor message"
+ # heartbeat is registered without a description.
+ assert listed["heartbeat"] == ""
+
+
+class TestProxyServerHandlers:
+ """Handler registration and the data path, which the default constructor
+ leaves unimplemented until the runtime wires them up."""
+
+ @pytest.fixture()
+ def server(self):
+ server = ProxyServer()
+ yield server
+ server.graceful_shutdown()
+
+ def test_data_handler_must_accept_command_and_data(self, server):
+ with pytest.raises(AssertionError):
+ server.register_data_handler(lambda only_one: None)
+
+ def test_control_handler_must_accept_at_least_one_argument(self, server):
+ with pytest.raises(AssertionError):
+ server.register_control_handler(lambda: None)
+
+ def test_do_put_without_a_registered_handler_is_not_implemented(self,
server):
+ descriptor, reader, writer = TestProxyServer.do_put_args()
+ with pytest.raises(NotImplementedError):
+ server.do_put(None, descriptor, reader, writer)
+
+ def test_control_action_without_a_registered_handler_is_not_implemented(
+ self, server
+ ):
+ with pytest.raises(NotImplementedError):
+ next(server.do_action(None, Action("control", b"payload")))
+
+ def
test_actor_action_without_a_registered_handler_is_not_implemented(self, server):
+ with pytest.raises(NotImplementedError):
+ next(server.do_action(None, Action("actor", b"payload")))
+
+ def test_do_put_routes_the_batch_and_writes_back_sender_credits(self,
server):
+ seen = []
+
+ def handler(command, data):
+ seen.append((command, data))
+ return 7
+
+ server.register_data_handler(handler)
+ data = Table.from_pydict({"x": [1, 2, 3]})
+ descriptor, reader, writer = TestProxyServer.do_put_args(b"cmd", data)
+
+ server.do_put(None, descriptor, reader, writer)
+
+ assert seen == [(b"cmd", data)]
+ # The credit is echoed back as a little-endian 8-byte integer.
+ written = writer.write.call_args[0][0]
+ assert written.to_pybytes() == (7).to_bytes(length=8,
byteorder="little")
+
+ def test_do_put_skips_the_credit_reply_for_a_non_int_result(self, server):
+ server.register_data_handler(lambda command, data: None)
+ descriptor, reader, writer = TestProxyServer.do_put_args()
+
+ server.do_put(None, descriptor, reader, writer)
+
+ writer.write.assert_not_called()
+
+ def test_control_action_is_routed_to_the_registered_control_handler(self,
server):
+ seen = []
+
+ def handler(control_message):
+ seen.append(control_message)
+ return 3
+
+ server.register_control_handler(handler)
+
+ result = next(server.do_action(None, Action("control",
b"control-bytes")))
+
+ assert seen == [b"control-bytes"]
+ # The reply carries the queue size used for credit calculation.
+ assert result.body.to_pybytes() == b"3"
+
+ def test_actor_action_is_routed_to_the_registered_actor_handler(self,
server):
+ seen = []
+
+ def handler(message):
+ seen.append(message)
+ return b"actor-ack"
+
+ server.register_actor_message_handler(handler)
+
+ result = next(server.do_action(None, Action("actor", b"actor-bytes")))
+
+ assert seen == [b"actor-bytes"]
+ assert result.body.to_pybytes() == b"actor-ack"
+
+
+class TestProxyServerPort:
+ def test_serves_on_the_requested_port(self):
+ port = get_free_local_port()
+ server = ProxyServer(port=port)
+ try:
+ assert server.get_port_number() == port
+ finally:
+ server.graceful_shutdown()
+
+ def test_picks_a_free_port_when_none_is_given(self):
+ server = ProxyServer()
+ try:
+ port = server.get_port_number()
+ assert isinstance(port, int)
+ assert 0 < port < 65536
+ # A second server must not collide with the first one's port.
+ other = ProxyServer()
+ try:
+ assert other.get_port_number() != port
+ finally:
+ other.graceful_shutdown()
+ finally:
+ server.graceful_shutdown()