This is an automated email from the ASF dual-hosted git repository.
github-merge-queue[bot] pushed a commit to branch release/v1.2
in repository https://gitbox.apache.org/repos/asf/texera.git
The following commit(s) were added to refs/heads/release/v1.2 by this push:
new de23d2a03f fix(pyamber, v1.2): disable data sub-queues registered
after disable_data (#8093)
de23d2a03f is described below
commit de23d2a03fcd54bb59889405a62f63700d874a6f
Author: github-actions[bot]
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Thu Sep 24 19:20:27 2026 +0000
fix(pyamber, v1.2): disable data sub-queues registered after disable_data
(#8093)
### What changes were proposed in this PR?
Automated backport of #6724 to `release/v1.2`.
Source: 2173ec57fcc237b9716caf80d4990ba3df479d99 · [automation
run](https://github.com/apache/texera/actions/runs/33237621086)
### Any related issues, documentation, discussions?
Backport of #6724. Originally linked #6723.
### How was this PR tested?
Release-branch CI runs on this branch once the conflicts are resolved
and this PR is marked ready for review.
### Was this PR authored or co-authored using generative AI tooling?
No.
Co-authored-by: Eugene Gu <[email protected]>
Co-authored-by: Yicong Huang
<[email protected]>
Co-authored-by: Xuan Gu <[email protected]>
---
.../src/main/python/core/models/internal_queue.py | 68 +-
.../test/python/core/models/test_internal_queue.py | 691 +++++++++++++++++++++
.../apache/texera/amber/engine/e2e/TestUtils.scala | 22 +-
3 files changed, 740 insertions(+), 41 deletions(-)
diff --git a/amber/src/main/python/core/models/internal_queue.py
b/amber/src/main/python/core/models/internal_queue.py
index abc1793ff6..5900db1cf1 100644
--- a/amber/src/main/python/core/models/internal_queue.py
+++ b/amber/src/main/python/core/models/internal_queue.py
@@ -20,9 +20,8 @@ from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
from threading import RLock
-from typing import TypeVar, Set
+from typing import Tuple, TypeVar, Set
-from core.models.internal_marker import InternalMarker
from core.models.payload import DataPayload
from core.util.customized_queue.linked_blocking_multi_queue import (
LinkedBlockingMultiQueue,
@@ -77,11 +76,19 @@ class InternalQueue(IQueue):
def put(self, item: T) -> None:
if isinstance(item, InternalQueueElement):
if item.tag not in self._queue_ids:
- self._queue.add_sub_queue(item.tag, 1 if item.tag.is_control
else 2)
- self._queue_ids.add(item.tag)
- if isinstance(item, (DataElement, InternalMarker, ECMElement)):
- self._queue.put(item.tag, item)
- elif isinstance(item, DCMElement):
+ # registration must not interleave with
disable_data/enable_data
+ with self._lock:
+ if item.tag not in self._queue_ids:
+ self._queue.add_sub_queue(
+ item.tag, 1 if item.tag.is_control else 2
+ )
+ # while data is disabled, a new data sub-queue must
+ # start disabled too (before its first element is
+ # enqueued), or it would leak data during
pause/backpressure
+ if not item.tag.is_control and self._queue_state:
+ self._queue.disable(item.tag)
+ self._queue_ids.add(item.tag)
+ if isinstance(item, (DataElement, ECMElement, DCMElement)):
self._queue.put(item.tag, item)
else:
raise ValueError(f"item {item} is not recognized by internal
queue")
@@ -94,19 +101,26 @@ class InternalQueue(IQueue):
def enable(self, channel_id: ChannelIdentity) -> None:
self._queue.enable(channel_id)
+ def _control_queue_ids(self) -> Tuple[ChannelIdentity, ...]:
+ """Snapshot of the registered control channels.
+
+ put() can grow _queue_ids from another thread, and iterating the
+ live set while it grows raises RuntimeError, so queries must iterate
+ a snapshot taken through these helpers.
+ """
+ snapshot = tuple(self._queue_ids)
+ return tuple(queue_id for queue_id in snapshot if queue_id.is_control)
+
+ def _data_queue_ids(self) -> Tuple[ChannelIdentity, ...]:
+ """Snapshot of the registered data channels; see _control_queue_ids."""
+ snapshot = tuple(self._queue_ids)
+ return tuple(queue_id for queue_id in snapshot if not
queue_id.is_control)
+
def is_control_empty(self) -> bool:
- return all(
- self.is_empty(queue_id)
- for queue_id in self._queue_ids
- if queue_id.is_control
- )
+ return all(self.is_empty(queue_id) for queue_id in
self._control_queue_ids())
def is_data_empty(self) -> bool:
- return all(
- self.is_empty(queue_id)
- for queue_id in self._queue_ids
- if not queue_id.is_control
- )
+ return all(self.is_empty(queue_id) for queue_id in
self._data_queue_ids())
def __len__(self) -> int:
return self.size()
@@ -115,18 +129,10 @@ class InternalQueue(IQueue):
return self._queue.size()
def size_control(self) -> int:
- return sum(
- self._queue.size(queue_id)
- for queue_id in self._queue_ids
- if queue_id.is_control
- )
+ return sum(self._queue.size(queue_id) for queue_id in
self._control_queue_ids())
def size_data(self) -> int:
- return sum(
- self._queue.size(queue_id)
- for queue_id in self._queue_ids
- if not queue_id.is_control
- )
+ return sum(self._queue.size(queue_id) for queue_id in
self._data_queue_ids())
def enable_data(self, disable_type: DisableType) -> bool:
with self._lock:
@@ -148,14 +154,10 @@ class InternalQueue(IQueue):
def in_mem_size(self) -> int:
return sum(
- self._queue.in_mem_size(queue_id)
- for queue_id in self._queue_ids
- if not queue_id.is_control
+ self._queue.in_mem_size(queue_id) for queue_id in
self._data_queue_ids()
)
def is_data_enabled(self) -> bool:
return any(
- self._queue.is_enabled(queue_id)
- for queue_id in self._queue_ids
- if not queue_id.is_control
+ self._queue.is_enabled(queue_id) for queue_id in
self._data_queue_ids()
)
diff --git a/amber/src/test/python/core/models/test_internal_queue.py
b/amber/src/test/python/core/models/test_internal_queue.py
new file mode 100644
index 0000000000..6cedc31847
--- /dev/null
+++ b/amber/src/test/python/core/models/test_internal_queue.py
@@ -0,0 +1,691 @@
+# 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 threading
+from dataclasses import dataclass
+
+import pytest
+
+from core.models.internal_queue import (
+ DataElement,
+ DCMElement,
+ ECMElement,
+ InternalQueue,
+ InternalQueueElement,
+)
+from core.models.payload import DataPayload
+from proto.org.apache.texera.amber.core import ActorVirtualIdentity,
ChannelIdentity
+from proto.org.apache.texera.amber.engine.architecture.rpc import (
+ EmbeddedControlMessage,
+)
+from proto.org.apache.texera.amber.engine.common import
DirectControlMessagePayloadV2
+
+
+@dataclass
+class UnrecognizedElement(InternalQueueElement):
+ """An InternalQueueElement subclass that InternalQueue does not know."""
+
+ pass
+
+
+class SystemCommand:
+ """A non-InternalQueueElement item, routed to the SYSTEM sub-queue."""
+
+ pass
+
+
+class TestInternalQueue:
+ @pytest.fixture
+ def queue(self):
+ return InternalQueue()
+
+ @pytest.fixture
+ def control_channel(self):
+ return ChannelIdentity(
+ ActorVirtualIdentity("CONTROLLER"),
+ ActorVirtualIdentity("dummy_worker_id"),
+ True,
+ )
+
+ @pytest.fixture
+ def data_channel(self):
+ return ChannelIdentity(
+ ActorVirtualIdentity("upstream_worker_id"),
+ ActorVirtualIdentity("dummy_worker_id"),
+ False,
+ )
+
+ @pytest.fixture
+ def second_data_channel(self):
+ return ChannelIdentity(
+ ActorVirtualIdentity("another_upstream_worker_id"),
+ ActorVirtualIdentity("dummy_worker_id"),
+ False,
+ )
+
+ @staticmethod
+ def data_element(channel):
+ return DataElement(tag=channel, payload=DataPayload())
+
+ @staticmethod
+ def dcm_element(channel):
+ return DCMElement(tag=channel, payload=DirectControlMessagePayloadV2())
+
+ @staticmethod
+ def ecm_element(channel):
+ return ECMElement(tag=channel, payload=EmbeddedControlMessage())
+
+ def test_it_can_init(self, queue):
+ assert queue.is_empty()
+ assert queue.is_control_empty()
+ assert queue.is_data_empty()
+ assert queue.size() == 0
+ assert len(queue) == 0
+
+ @pytest.mark.timeout(2)
+ def test_it_accepts_all_recognized_element_types(
+ self, queue, control_channel, data_channel
+ ):
+ data = self.data_element(data_channel)
+ dcm = self.dcm_element(control_channel)
+ ecm = self.ecm_element(data_channel)
+ # NOTE: LinkedBlockingMultiQueue priority-group ordering is currently
+ # dependent on sub-queue registration order; register control before
data
+ # to preserve control-priority semantics.
+ queue.put(dcm)
+ queue.put(data)
+ queue.put(ecm)
+ assert queue.size() == 3
+ # the control-channel element goes first, data-channel FIFO after
+ assert queue.get() is dcm
+ assert queue.get() is data
+ assert queue.get() is ecm
+ assert queue.is_empty()
+
+ @pytest.mark.timeout(2)
+ @pytest.mark.xfail(
+ reason=(
+ "LinkedBlockingMultiQueue.add_sub_queue does not currently insert
new "
+ "priority groups ahead of lower-priority ones, so registering data
before "
+ "control can break control-priority ordering."
+ )
+ )
+ def
test_control_elements_dequeue_before_data_even_if_data_channel_registered_first(
+ self, queue, control_channel, data_channel
+ ):
+ data = self.data_element(data_channel)
+ dcm = self.dcm_element(control_channel)
+ queue.put(data) # registers the data channel first
+ queue.put(dcm) # registers the control channel later
+ assert queue.get() is dcm
+ assert queue.get() is data
+
+ @pytest.mark.timeout(2)
+ def test_control_elements_dequeue_before_data_elements(
+ self, queue, control_channel, data_channel
+ ):
+ data1 = self.data_element(data_channel)
+ data2 = self.data_element(data_channel)
+ dcm1 = self.dcm_element(control_channel)
+ dcm2 = self.dcm_element(control_channel)
+ queue.put(dcm1)
+ queue.put(data1)
+ queue.put(data2)
+ queue.put(dcm2)
+ # dcm2 was put last but still dequeues before the earlier data;
+ # compare identities since same-payload elements are equal by value
+ results = [queue.get() for _ in range(4)]
+ assert all(
+ got is expected
+ for got, expected in zip(results, [dcm1, dcm2, data1, data2])
+ )
+
+ @pytest.mark.timeout(2)
+ def test_system_elements_dequeue_before_control_and_data(
+ self, queue, control_channel, data_channel
+ ):
+ data = self.data_element(data_channel)
+ dcm = self.dcm_element(control_channel)
+ system_command = SystemCommand()
+ queue.put(dcm)
+ queue.put(data)
+ queue.put(system_command)
+ assert queue.get() is system_command
+ assert queue.get() is dcm
+ assert queue.get() is data
+ assert queue.is_empty()
+
+ def test_it_rejects_unrecognized_internal_queue_elements(self, queue,
data_channel):
+ with pytest.raises(ValueError, match="not recognized"):
+ queue.put(UnrecognizedElement(tag=data_channel))
+ # the rejected element must not be enqueued
+ assert queue.is_empty()
+ assert queue.size() == 0
+
+ @pytest.mark.timeout(2)
+ def test_it_maintains_fifo_order_within_a_channel(self, queue,
data_channel):
+ elements = [self.data_element(data_channel) for _ in range(5)]
+ for element in elements:
+ queue.put(element)
+ results = [queue.get() for _ in range(5)]
+ # compare identities: the elements are equal by value, so a plain
+ # list equality could not detect a reordering
+ assert all(got is put for got, put in zip(results, elements))
+ assert queue.is_empty()
+
+ def test_it_reports_emptiness_per_category(
+ self, queue, control_channel, data_channel
+ ):
+ queue.put(self.dcm_element(control_channel))
+ assert not queue.is_control_empty()
+ assert queue.is_data_empty()
+ assert not queue.is_empty()
+ queue.put(self.data_element(data_channel))
+ assert not queue.is_data_empty()
+ queue.get() # takes the control element
+ assert queue.is_control_empty()
+ assert not queue.is_data_empty()
+ queue.get() # takes the data element
+ assert queue.is_data_empty()
+ assert queue.is_empty()
+
+ def test_it_counts_sizes_per_category(
+ self, queue, control_channel, data_channel, second_data_channel
+ ):
+ queue.put(self.data_element(data_channel))
+ queue.put(self.data_element(second_data_channel))
+ queue.put(self.dcm_element(control_channel))
+ assert queue.size_data() == 2
+ assert queue.size_control() == 1
+ assert queue.size() == 3
+ assert len(queue) == 3
+ # SYSTEM elements count towards the total but neither category
+ queue.put(SystemCommand())
+ assert queue.size() == 4
+ assert queue.size_data() == 2
+ assert queue.size_control() == 1
+
+ @pytest.mark.timeout(2)
+ def test_it_can_disable_data_by_pause(self, queue, control_channel,
data_channel):
+ data = self.data_element(data_channel)
+ dcm = self.dcm_element(control_channel)
+ queue.put(data)
+ queue.put(dcm)
+ assert queue.is_data_enabled()
+ queue.disable_data(InternalQueue.DisableType.DISABLE_BY_PAUSE)
+ assert not queue.is_data_enabled()
+ # only the control element is retrievable; the data element stays
+ # queued and still counts towards the data size
+ assert queue.get() is dcm
+ assert queue.size_data() == 1
+ assert queue.enable_data(InternalQueue.DisableType.DISABLE_BY_PAUSE)
+ assert queue.is_data_enabled()
+ assert queue.get() is data
+
+ @pytest.mark.timeout(2)
+ def test_it_can_disable_data_by_backpressure(self, queue, data_channel):
+ data = self.data_element(data_channel)
+ queue.put(data)
+ queue.disable_data(InternalQueue.DisableType.DISABLE_BY_BACKPRESSURE)
+ assert not queue.is_data_enabled()
+ assert
queue.enable_data(InternalQueue.DisableType.DISABLE_BY_BACKPRESSURE)
+ assert queue.is_data_enabled()
+ assert queue.get() is data
+
+ @pytest.mark.timeout(2)
+ @pytest.mark.parametrize(
+ "first_cleared, second_cleared",
+ [
+ (
+ InternalQueue.DisableType.DISABLE_BY_PAUSE,
+ InternalQueue.DisableType.DISABLE_BY_BACKPRESSURE,
+ ),
+ (
+ InternalQueue.DisableType.DISABLE_BY_BACKPRESSURE,
+ InternalQueue.DisableType.DISABLE_BY_PAUSE,
+ ),
+ ],
+ )
+ def test_it_stays_disabled_until_all_reasons_are_cleared(
+ self, queue, data_channel, first_cleared, second_cleared
+ ):
+ data = self.data_element(data_channel)
+ queue.put(data)
+ queue.disable_data(InternalQueue.DisableType.DISABLE_BY_PAUSE)
+ queue.disable_data(InternalQueue.DisableType.DISABLE_BY_BACKPRESSURE)
+ assert not queue.is_data_enabled()
+ # clearing one of the two reasons must not re-enable data
+ assert not queue.enable_data(first_cleared)
+ assert not queue.is_data_enabled()
+ # clearing the remaining reason re-enables data
+ assert queue.enable_data(second_cleared)
+ assert queue.is_data_enabled()
+ assert queue.get() is data
+
+ def test_it_can_disable_data_by_the_same_reason_twice(self, queue,
data_channel):
+ queue.put(self.data_element(data_channel))
+ queue.disable_data(InternalQueue.DisableType.DISABLE_BY_PAUSE)
+ queue.disable_data(InternalQueue.DisableType.DISABLE_BY_PAUSE)
+ assert not queue.is_data_enabled()
+ # a repeated reason is tracked once, so a single enable clears it
+ assert queue.enable_data(InternalQueue.DisableType.DISABLE_BY_PAUSE)
+ assert queue.is_data_enabled()
+
+ def test_it_can_enable_data_by_a_reason_that_was_never_set(
+ self, queue, data_channel
+ ):
+ queue.put(self.data_element(data_channel))
+ assert
queue.enable_data(InternalQueue.DisableType.DISABLE_BY_BACKPRESSURE)
+ assert queue.is_data_enabled()
+ # with another reason still set, an unset reason must not re-enable
+ queue.disable_data(InternalQueue.DisableType.DISABLE_BY_PAUSE)
+ assert not
queue.enable_data(InternalQueue.DisableType.DISABLE_BY_BACKPRESSURE)
+ assert not queue.is_data_enabled()
+
+ @pytest.mark.timeout(2)
+ def test_it_enqueues_into_an_already_disabled_data_channel(
+ self, queue, control_channel, data_channel
+ ):
+ data_elements = [self.data_element(data_channel) for _ in range(3)]
+ dcm = self.dcm_element(control_channel)
+ queue.put(dcm)
+ queue.put(data_elements[0]) # registers the data channel
+ queue.disable_data(InternalQueue.DisableType.DISABLE_BY_PAUSE)
+ # puts into the disabled channel still enqueue
+ queue.put(data_elements[1])
+ queue.put(data_elements[2])
+ assert queue.size_data() == 3
+ # control still flows while data is disabled
+ assert queue.get() is dcm
+ assert queue.enable_data(InternalQueue.DisableType.DISABLE_BY_PAUSE)
+ # all queued data elements come out in FIFO order
+ results = [queue.get() for _ in range(3)]
+ assert all(got is put for got, put in zip(results, data_elements))
+ assert queue.is_empty()
+
+ @pytest.mark.timeout(2)
+ def test_it_tracks_in_mem_size_of_data_channels_only(
+ self, queue, control_channel, data_channel
+ ):
+ dcm = self.dcm_element(control_channel)
+ system_command = SystemCommand()
+ queue.put(dcm)
+ queue.put(system_command)
+ # control and SYSTEM elements never count towards in_mem_size
+ assert queue.in_mem_size() == 0
+ # the two data elements have the same in-memory size
+ queue.put(self.data_element(data_channel))
+ single_element_size = queue.in_mem_size()
+ assert single_element_size > 0
+ queue.put(self.data_element(data_channel))
+ assert queue.in_mem_size() == 2 * single_element_size
+ # taking the SYSTEM and control elements changes nothing
+ assert queue.get() is system_command
+ assert queue.get() is dcm
+ assert queue.in_mem_size() == 2 * single_element_size
+ # taking the data elements returns the accounting to zero
+ queue.get()
+ assert queue.in_mem_size() == single_element_size
+ queue.get()
+ assert queue.in_mem_size() == 0
+
+ @pytest.mark.timeout(2)
+ def test_it_can_disable_and_enable_a_single_data_channel(
+ self, queue, control_channel, data_channel, second_data_channel
+ ):
+ # the single-channel pause path used by PauseManager
+ dcm = self.dcm_element(control_channel)
+ blocked = self.data_element(data_channel)
+ flowing = self.data_element(second_data_channel)
+ queue.put(dcm)
+ queue.put(blocked)
+ queue.put(flowing)
+ queue.disable(data_channel)
+ # control and the other data channel still flow
+ assert queue.get() is dcm
+ assert queue.get() is flowing
+ # the disabled channel's element stays queued; it counts towards
+ # size_data but is excluded from the getable size
+ assert queue.size_data() == 1
+ assert queue.size() == 0
+ queue.enable(data_channel)
+ assert queue.get() is blocked
+ assert queue.is_empty()
+
+ # Regression tests below: data channels whose sub-queue is created lazily
+ # (on the channel's first put) AFTER disable_data has been called must
+ # come up disabled — a paused or backpressured worker must not be able to
+ # dequeue data from them, and is_data_enabled() must not flip back to
+ # True just because a new channel delivered its first message.
+
+ def test_channel_registered_after_disable_comes_up_disabled(
+ self, queue, data_channel
+ ):
+ # the main regression: disable first, then the channel's FIRST put
+ queue.disable_data(InternalQueue.DisableType.DISABLE_BY_PAUSE)
+ queue.put(self.data_element(data_channel))
+ assert not queue.is_data_enabled()
+ # the element stays queued but must not be dequeuable
+ assert queue.size_data() == 1
+ assert queue._queue.peek() is None
+
+ @pytest.mark.timeout(2)
+ def test_enable_data_releases_a_channel_registered_mid_disable(
+ self, queue, data_channel
+ ):
+ queue.disable_data(InternalQueue.DisableType.DISABLE_BY_PAUSE)
+ data = self.data_element(data_channel)
+ queue.put(data)
+ assert queue.enable_data(InternalQueue.DisableType.DISABLE_BY_PAUSE)
+ assert queue.is_data_enabled()
+ assert queue._queue.peek() is data
+ assert queue.get() is data
+ assert queue.is_empty()
+
+ @pytest.mark.timeout(2)
+ def test_channel_registered_under_stacked_disables_stays_disabled(
+ self, queue, data_channel
+ ):
+ queue.disable_data(InternalQueue.DisableType.DISABLE_BY_PAUSE)
+ queue.disable_data(InternalQueue.DisableType.DISABLE_BY_BACKPRESSURE)
+ data = self.data_element(data_channel)
+ queue.put(data)
+ # releasing only one of the two reasons must not open the channel
+ assert not
queue.enable_data(InternalQueue.DisableType.DISABLE_BY_PAUSE)
+ assert not queue.is_data_enabled()
+ assert queue._queue.peek() is None
+ # releasing the remaining reason makes the element dequeuable
+ assert
queue.enable_data(InternalQueue.DisableType.DISABLE_BY_BACKPRESSURE)
+ assert queue.is_data_enabled()
+ assert queue.get() is data
+
+ @pytest.mark.timeout(2)
+ def test_control_channel_registered_mid_disable_is_never_blocked(
+ self, queue, control_channel, data_channel
+ ):
+ # register a data channel first so is_data_enabled() is meaningful
+ queue.put(self.data_element(data_channel))
+ queue.disable_data(InternalQueue.DisableType.DISABLE_BY_PAUSE)
+ # the control channel's FIRST put happens while data is disabled
+ dcm = self.dcm_element(control_channel)
+ queue.put(dcm)
+ # control must flow immediately, and data must stay disabled
+ assert queue._queue.peek() is dcm
+ assert queue.get() is dcm
+ assert not queue.is_data_enabled()
+ assert queue.size_data() == 1
+
+ @pytest.mark.timeout(2)
+ def test_channel_registered_before_disable_is_disabled_and_reenabled(
+ self, queue, data_channel
+ ):
+ # baseline: the pre-existing behavior for eagerly-registered channels
+ data = self.data_element(data_channel)
+ queue.put(data)
+ queue.disable_data(InternalQueue.DisableType.DISABLE_BY_PAUSE)
+ assert not queue.is_data_enabled()
+ assert queue._queue.peek() is None
+ assert queue.enable_data(InternalQueue.DisableType.DISABLE_BY_PAUSE)
+ assert queue.is_data_enabled()
+ assert queue.get() is data
+
+ @pytest.mark.timeout(2)
+ def test_channel_registered_while_enabled_behaves_normally(
+ self, queue, second_data_channel
+ ):
+ data = self.data_element(second_data_channel)
+ queue.put(data)
+ assert queue.is_data_enabled()
+ assert queue._queue.peek() is data
+ assert queue.get() is data
+ assert queue.is_empty()
+
+ @pytest.mark.timeout(10)
+ def test_concurrent_first_time_puts_while_toggling_disable(self, queue):
+ # concurrency smoke test: receiver threads register brand-new data
+ # channels while the DP thread toggles disable_data/enable_data;
+ # only the final state is asserted, deterministically.
+ n_threads = 8
+ elements_per_thread = 25
+ start_barrier = threading.Barrier(n_threads + 1)
+ errors = []
+
+ def producer(thread_index):
+ channel = ChannelIdentity(
+ ActorVirtualIdentity(f"upstream_{thread_index}"),
+ ActorVirtualIdentity("dummy_worker_id"),
+ False,
+ )
+ try:
+ start_barrier.wait()
+ for _ in range(elements_per_thread):
+ queue.put(self.data_element(channel))
+ except Exception as exc: # pragma: no cover - failure path
+ errors.append(exc)
+
+ threads = [
+ threading.Thread(target=producer, args=(i,)) for i in
range(n_threads)
+ ]
+ for thread in threads:
+ thread.start()
+ start_barrier.wait()
+ for _ in range(5):
+ queue.disable_data(InternalQueue.DisableType.DISABLE_BY_PAUSE)
+ queue.enable_data(InternalQueue.DisableType.DISABLE_BY_PAUSE)
+ for thread in threads:
+ thread.join()
+ # one last full cycle after all puts settled: every channel must be
+ # disabled, then re-enabled with its count added back exactly once
+ queue.disable_data(InternalQueue.DisableType.DISABLE_BY_PAUSE)
+ assert not queue.is_data_enabled()
+ assert queue._queue.peek() is None
+ assert queue.enable_data(InternalQueue.DisableType.DISABLE_BY_PAUSE)
+
+ assert not errors
+ total = n_threads * elements_per_thread
+ assert queue.is_data_enabled()
+ assert queue.size_data() == total
+ # size() is the getable total_count: a mismatch with size_data()
+ # means an element was double-counted or lost by a toggle race
+ assert queue.size() == total
+ drained = 0
+ while queue._queue.peek() is not None:
+ queue.get()
+ drained += 1
+ assert drained == total
+ assert queue.is_empty()
+
+ @pytest.mark.timeout(30)
+ def test_concurrent_first_time_puts_racing_disable_enable_toggles(self):
+ # Receiver threads deliver first-ever messages on distinct new data
+ # channels while the DP-thread side toggles pause on and off. Only
+ # the final state is asserted (deterministic): with the queue left
+ # disabled, nothing is dequeuable; after the final enable_data every
+ # element is dequeuable exactly once, so total_count stayed exact.
+ threads, channels_per_thread, toggles = 4, 10, 10
+ for _ in range(5):
+ queue = InternalQueue()
+ errors = []
+ start = threading.Barrier(threads + 1)
+
+ def producer(thread_id):
+ try:
+ start.wait()
+ for i in range(channels_per_thread):
+ channel = ChannelIdentity(
+ ActorVirtualIdentity(f"upstream-{thread_id}-{i}"),
+ ActorVirtualIdentity("dummy_worker_id"),
+ False,
+ )
+ queue.put(self.data_element(channel))
+ except Exception as exc: # pragma: no cover - failure path
+ errors.append(exc)
+
+ producers = [
+ threading.Thread(target=producer, args=(t,)) for t in
range(threads)
+ ]
+ for producer_thread in producers:
+ producer_thread.start()
+ start.wait()
+ for _ in range(toggles):
+ queue.disable_data(InternalQueue.DisableType.DISABLE_BY_PAUSE)
+ queue.enable_data(InternalQueue.DisableType.DISABLE_BY_PAUSE)
+ queue.disable_data(InternalQueue.DisableType.DISABLE_BY_PAUSE)
+ for producer_thread in producers:
+ producer_thread.join()
+
+ assert errors == []
+ total = threads * channels_per_thread
+ assert queue.size_data() == total
+ assert queue._queue.peek() is None
+ assert not queue.is_data_enabled()
+ assert
queue.enable_data(InternalQueue.DisableType.DISABLE_BY_PAUSE)
+ dequeued = 0
+ while queue._queue.peek() is not None:
+ queue.get()
+ dequeued += 1
+ assert dequeued == total
+
+ @pytest.mark.timeout(2)
+ @pytest.mark.parametrize(
+ "disable_type",
+ [
+ InternalQueue.DisableType.DISABLE_BY_PAUSE,
+ InternalQueue.DisableType.DISABLE_BY_BACKPRESSURE,
+ ],
+ )
+ def
test_ecm_first_on_a_channel_registered_mid_disable_is_delayed_until_resume(
+ self, queue, data_channel, disable_type
+ ):
+ # ECMs ride data channels, so an ECM arriving as the first-ever
+ # message of a channel registered mid-disable is held back with the
+ # channel. This is the engine's intended semantics: reconfigurations
+ # submitted while paused take effect on resume
+ # (ExecutionReconfigurationService), matching the JVM DPThread, which
+ # refuses ALL data-channel traffic — ECMs included — while paused.
+ # The ECM is delayed, not dropped. Misreading exactly this behavior
+ # as an engine deadlock once cost a full redesign of this queue,
+ # hence this pin.
+ queue.disable_data(disable_type)
+ ecm = self.ecm_element(data_channel)
+ queue.put(ecm) # the channel's first-ever message
+ assert not queue.is_data_enabled()
+ assert queue._queue.peek() is None
+ # the ECM sits in a data sub-queue, so it counts towards size_data
+ assert queue.size_data() == 1
+ assert queue.enable_data(disable_type)
+ assert queue.get() is ecm
+
+ # Regression tests below: the per-category query methods iterate
+ # _queue_ids, which put() grows on a channel's first message. Iterating
+ # the live set while another thread grows it raises RuntimeError
+ # ("Set changed size during iteration"), killing the calling thread —
+ # e.g. the DP thread polling is_data_enabled() in the main loop — so the
+ # queries must iterate a snapshot of the set instead.
+
+ @pytest.mark.parametrize(
+ "query, expected",
+ [
+ ("is_control_empty", True),
+ ("is_data_empty", True),
+ ("size_control", 0),
+ ("size_data", 0),
+ ("in_mem_size", 0),
+ ("is_data_enabled", False),
+ ],
+ )
+ def test_queries_survive_a_channel_registration_mid_iteration(
+ self, query, expected
+ ):
+ # Registers a key whose is_control access (evaluated inside the query's
+ # iteration over _queue_ids) delivers the first-ever message of a
+ # brand-new data channel, interleaving a registration into the
+ # iteration exactly like a concurrent Flight reader thread would.
+ queue = InternalQueue()
+ outer = self
+
+ class RegisteringKey:
+ def __init__(self):
+ self.fired = 0
+
+ @property
+ def is_control(self):
+ self.fired += 1
+ late_channel = ChannelIdentity(
+ ActorVirtualIdentity(f"late_upstream_{self.fired}"),
+ ActorVirtualIdentity("dummy_worker_id"),
+ False,
+ )
+ queue.put(outer.data_element(late_channel))
+ return False
+
+ registering_key = RegisteringKey()
+ queue._queue.add_sub_queue(registering_key, 2)
+ # keep this sub-queue disabled and empty so no query short-circuits
+ # on its yielded value: each one must advance the iteration past the
+ # mid-iteration registration, which raises RuntimeError on the live
+ # set and must not raise on a snapshot
+ queue._queue.disable(registering_key)
+ queue._queue_ids.add(registering_key)
+
+ assert getattr(queue, query)() == expected
+ assert registering_key.fired == 1
+
+ @pytest.mark.timeout(20)
+ def test_queries_survive_concurrent_first_time_registrations(self):
+ # realistic race: reader threads deliver first-ever messages on new
+ # data channels while the DP-thread side polls the category queries,
+ # as main_loop's _check_and_process_control does
+ queue = InternalQueue()
+ n_threads, channels_per_thread = 4, 200
+ start_barrier = threading.Barrier(n_threads + 1)
+ errors = []
+
+ def producer(thread_id):
+ try:
+ start_barrier.wait()
+ for i in range(channels_per_thread):
+ channel = ChannelIdentity(
+ ActorVirtualIdentity(f"upstream_{thread_id}_{i}"),
+ ActorVirtualIdentity("dummy_worker_id"),
+ False,
+ )
+ queue.put(self.data_element(channel))
+ except Exception as exc: # pragma: no cover - failure path
+ errors.append(exc)
+
+ producers = [
+ threading.Thread(target=producer, args=(t,)) for t in
range(n_threads)
+ ]
+ for producer_thread in producers:
+ producer_thread.start()
+ start_barrier.wait()
+ # a RuntimeError from any query fails the test right here
+ while any(producer_thread.is_alive() for producer_thread in producers):
+ queue.is_control_empty()
+ queue.is_data_empty()
+ queue.size_control()
+ queue.size_data()
+ queue.in_mem_size()
+ queue.is_data_enabled()
+ for producer_thread in producers:
+ producer_thread.join()
+
+ assert errors == []
+ assert queue.size_data() == n_threads * channels_per_thread
+ assert queue.size_control() == 0
+ assert queue.is_data_enabled()
diff --git
a/amber/src/test/scala/org/apache/texera/amber/engine/e2e/TestUtils.scala
b/amber/src/test/scala/org/apache/texera/amber/engine/e2e/TestUtils.scala
index 113de090db..20f62afc3f 100644
--- a/amber/src/test/scala/org/apache/texera/amber/engine/e2e/TestUtils.scala
+++ b/amber/src/test/scala/org/apache/texera/amber/engine/e2e/TestUtils.scala
@@ -249,20 +249,26 @@ object TestUtils {
val physicalOps = targetOps.flatMap(op =>
workflow.physicalPlan.getPhysicalOpsOfLogicalOp(op.operatorIdentifier)
)
- Await.result(
- client.controllerInterface.reconfigureWorkflow(
- WorkflowReconfigureRequest(
- reconfiguration = physicalOps.map(op => UpdateExecutorRequest(op.id,
newOpExecInitInfo)),
- reconfigurationId = "test-reconfigure-1"
- ),
- ()
+ // Production dispatches the reconfiguration without awaiting its ack and
it
+ // only takes effect on resume (see ExecutionReconfigurationService), so
the
+ // harness must not await the ack while still paused — that await is what
+ // used to deadlock for the full 30s command timeout. The reconfigure ack
is
+ // awaited only after the resume ack, which ResumeHandler completes once
+ // every worker has acknowledged the resume. (There is no RUNNING event to
+ // wait for: the engine only pushes ExecutionStateUpdate to the client for
+ // PAUSED and terminal states.)
+ val reconfigured = client.controllerInterface.reconfigureWorkflow(
+ WorkflowReconfigureRequest(
+ reconfiguration = physicalOps.map(op => UpdateExecutorRequest(op.id,
newOpExecInitInfo)),
+ reconfigurationId = "test-reconfigure-1"
),
- Duration.fromSeconds(5)
+ ()
)
Await.result(
client.controllerInterface.resumeWorkflow(EmptyRequest(), ()),
Duration.fromSeconds(5)
)
+ Await.result(reconfigured, Duration.fromSeconds(5))
Await.result(completion, Duration.fromMinutes(1))
result
}