Wait for a deadline instead of polling for one
Docs / docs (push) Successful in 25s
Playwright Tests / test-playwright (1, 2) (push) Successful in 2m23s
Playwright Tests / test-playwright (2, 2) (push) Successful in 2m0s
pre-commit / pre-commit (push) Failing after 4m31s
Test Backend / test-backend (push) Successful in 2m55s
Compose Smoke Test / test-compose (push) Successful in 35s
Playwright Tests / merge-reports (push) Successful in 1m11s
Docs / docs (push) Successful in 25s
Playwright Tests / test-playwright (1, 2) (push) Successful in 2m23s
Playwright Tests / test-playwright (2, 2) (push) Successful in 2m0s
pre-commit / pre-commit (push) Failing after 4m31s
Test Backend / test-backend (push) Successful in 2m55s
Compose Smoke Test / test-compose (push) Successful in 35s
Playwright Tests / merge-reports (push) Successful in 1m11s
The timer thread promoted due work on a fixed one-second tick, so every delayed item was 0-1000ms late whatever the load — measured on the house at 705ms mean on a rollershutter stop, which is 2-4% of a 26-second travel and accumulates in the position the motor node believes it is at. It now sleeps to the soonest deadline and is woken when a nearer one is scheduled, which measures 0.9ms end to end through Redis. A promoted timer also went to the back of the queue. It goes into a due lane of its own that `claim` reads first, so work that has waited out a deadline is not held up by work that is merely queued. Beside it, in the same code: seeding a message now bumps its version, so a re-put flow's synchronous nodes no longer wait forever on a value that is sitting in state; the consumer group drops the consumers of engines that are gone (138 had accumulated on this installation); and the cast that closes the long-standing `xclaim` mypy error. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -43,6 +43,68 @@ def test_a_delayed_item_stays_put_until_it_is_due():
|
||||
assert [i.node for i in queue.claim(1, 10)] == ["f.n"]
|
||||
|
||||
|
||||
def test_the_queue_says_when_the_soonest_delayed_item_is_due():
|
||||
"""What lets the engine sleep to a deadline rather than poll for it."""
|
||||
queue = MemoryWorkQueue()
|
||||
assert queue.next_due() is None
|
||||
|
||||
queue.add_delayed(WorkItem(kind="cascade", node="f.late", flow="f"), 500.0)
|
||||
queue.add_delayed(WorkItem(kind="cascade", node="f.soon", flow="f"), 100.0)
|
||||
assert queue.next_due() == 100.0
|
||||
|
||||
queue.move_due(200.0)
|
||||
assert queue.next_due() == 500.0
|
||||
|
||||
|
||||
def test_scheduling_says_a_deadline_moved():
|
||||
"""A delay scheduled for 200ms must cut short a sleep to the next second."""
|
||||
queue = MemoryWorkQueue()
|
||||
woken = threading.Event()
|
||||
queue.on_delayed = woken.set
|
||||
|
||||
queue.add_delayed(WorkItem(kind="cascade", node="f.n", flow="f"), time.time() + 0.2)
|
||||
|
||||
assert woken.is_set()
|
||||
|
||||
|
||||
def test_a_due_timer_is_claimed_before_the_backlog():
|
||||
"""Lateness is what a timer is measured by; queued work waits on no clock."""
|
||||
queue = MemoryWorkQueue()
|
||||
for i in range(3):
|
||||
queue.add(WorkItem(kind="cascade", node=f"f.queued{i}", flow="f"))
|
||||
queue.add_delayed(WorkItem(kind="cascade", node="f.timer", flow="f"), 100.0)
|
||||
|
||||
queue.move_due(200.0)
|
||||
|
||||
assert [i.node for i in queue.claim(10, 10)][0] == "f.timer"
|
||||
|
||||
|
||||
def test_a_delay_fires_without_waiting_out_the_poll():
|
||||
"""The whole point: a 200ms delay is 200ms late, not up to a second."""
|
||||
queue = MemoryWorkQueue()
|
||||
service = ExecutionService(queue)
|
||||
dispatched: list[float] = []
|
||||
service._dispatch = lambda item: dispatched.append(time.monotonic()) # type: ignore[method-assign]
|
||||
service.start()
|
||||
try:
|
||||
started = time.monotonic()
|
||||
queue.add_delayed(
|
||||
WorkItem(kind="cascade", node="f.n", flow="f"), time.time() + 0.2
|
||||
)
|
||||
# Claimed by the consumer thread, which is what calls _dispatch.
|
||||
deadline = time.monotonic() + 2.0
|
||||
while not dispatched and time.monotonic() < deadline:
|
||||
time.sleep(0.01)
|
||||
finally:
|
||||
service.stop()
|
||||
|
||||
assert dispatched, "the deferred item never ran"
|
||||
late = dispatched[0] - started - 0.2
|
||||
# A fixed one-second poll made this up to 1.0s; the budget is generous
|
||||
# because CI is not a real-time machine.
|
||||
assert late < 0.3, f"fired {late:.3f}s late"
|
||||
|
||||
|
||||
def test_parked_work_comes_back_oldest_first():
|
||||
queue = MemoryWorkQueue()
|
||||
for i in range(3):
|
||||
|
||||
@@ -72,6 +72,37 @@ def test_synchronous_node_waits_for_all_inputs_to_be_fresh():
|
||||
assert sorted(runs) == ["eager", "sync"]
|
||||
|
||||
|
||||
def test_a_seeded_value_counts_as_having_arrived():
|
||||
"""A re-put flow used to come back with its synchronous nodes wedged.
|
||||
|
||||
Seeding wrote the value and not its version, and version 0 reads as
|
||||
"never published" — so the node reported active and ok and never ran
|
||||
again, with the value it was waiting for sitting right there in state.
|
||||
"""
|
||||
runs: list[str] = []
|
||||
|
||||
def sync(a, b, params):
|
||||
runs.append("sync")
|
||||
return None
|
||||
|
||||
def sensor_b(params):
|
||||
return {"b": 2.0}
|
||||
|
||||
b = node("b", sensor_b, provides=[spec("b")])
|
||||
sync_node = node(
|
||||
"sync", sync, requires=[spec("a"), spec("b")], params={"synchronous": True}
|
||||
)
|
||||
|
||||
Pipeline(
|
||||
nodes=[b, sync_node],
|
||||
max_workers=1,
|
||||
initial_values={"f.a": 1.0},
|
||||
)
|
||||
|
||||
b.inject()
|
||||
assert runs == ["sync"]
|
||||
|
||||
|
||||
def test_increment_and_multi_get():
|
||||
state = MemoryState()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user