Say how much work is waiting, not just how much is running
`RedisWorkQueue.stats` read XPENDING, which counts entries delivered to a consumer and not yet acknowledged — work in progress. Entries sitting in the stream undelivered were counted nowhere, so an engine hours behind reported itself idle: on the house, `pending: 4` while the group's lag was 1554. The group's own `lag` is the missing number. `backlog` now carries it on both queues (`len(_items)` in memory), leads the health tile, and a sustained one publishes `engine_degraded` from the timer thread — named with the flow most of the waiting work belongs to, sampled from the undelivered tail, since that is the actionable half. It is a summary problem rather than a /utils/health 503: a backlog should not restart the container. Also drops the keyspace `scan_iter` `stats()` did per poll to count parked items — it walked every state and idempotency key twice per ten seconds — for a set the park/unpark path maintains. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BpfSinyCBfjuieikyfMPbf
This commit is contained in:
@@ -78,6 +78,49 @@ def test_claimed_work_counts_as_in_flight_until_it_is_acknowledged():
|
||||
assert queue.stats()["pending"] == 0
|
||||
|
||||
|
||||
def test_work_waiting_to_be_claimed_is_the_backlog():
|
||||
"""`pending` is what is running; an engine hours behind reports it as idle."""
|
||||
queue = MemoryWorkQueue()
|
||||
queue.add(WorkItem(kind="cascade", node="f.n", flow="f"))
|
||||
|
||||
assert queue.stats()["backlog"] == 1
|
||||
assert queue.stats()["pending"] == 0
|
||||
|
||||
(item,) = queue.claim(1, 10)
|
||||
assert queue.stats()["backlog"] == 0
|
||||
assert queue.stats()["pending"] == 1
|
||||
|
||||
queue.ack(item)
|
||||
assert queue.stats()["backlog"] == 0
|
||||
|
||||
|
||||
def test_a_sustained_backlog_says_the_engine_is_behind():
|
||||
"""A flow enqueuing faster than the pool drains produced no signal at all."""
|
||||
events: list[dict] = []
|
||||
queue = MemoryWorkQueue()
|
||||
service = ExecutionService(queue)
|
||||
service._publish = events.append # type: ignore[method-assign]
|
||||
for _ in range(executor.BACKLOG_DEGRADED):
|
||||
queue.add(WorkItem(kind="cascade", node="f.n", flow="f"))
|
||||
|
||||
for _ in range(executor.BACKLOG_STRIKES - 1):
|
||||
service._check_backlog()
|
||||
assert events == []
|
||||
|
||||
service._check_backlog()
|
||||
assert [e["type"] for e in events] == ["engine_degraded"]
|
||||
assert service.stats()["behind"] is True
|
||||
|
||||
# Said once, not once every five seconds for as long as it lasts.
|
||||
service._check_backlog()
|
||||
assert len(events) == 1
|
||||
|
||||
# And a drained queue clears it, so the next backlog is announced again.
|
||||
queue.claim(executor.BACKLOG_DEGRADED, 10)
|
||||
service._check_backlog()
|
||||
assert service.stats()["behind"] is False
|
||||
|
||||
|
||||
def _pipeline_with_a_consumer() -> tuple[Pipeline, Node, MemoryState, list]:
|
||||
"""A source whose message a consumer records."""
|
||||
seen: list[float] = []
|
||||
@@ -383,6 +426,8 @@ def test_no_more_is_claimed_than_the_pool_can_run():
|
||||
assert stats["cascades_busy"] <= executor.MAX_CASCADES
|
||||
# And the journal entries of what is only waiting are still free.
|
||||
assert stats["pending"] <= executor.MAX_CASCADES
|
||||
# Waiting is not idle: the rest of the forty is the backlog.
|
||||
assert stats["backlog"] >= 30
|
||||
finally:
|
||||
release.set()
|
||||
service.stop()
|
||||
|
||||
Reference in New Issue
Block a user