Files
turnstone/tests/test_nudge_queue.py
Patrick Buckley 45d95a2c1f feat(bash): opt-in background shells with delta output reader and kill tool (#817)
Restore 'start a dev server, use it in a later call' as an explicit opt-in
after #816 made bash reap its whole process group on return. The surface
mirrors the dominant coding-agent convention: bash(run_in_background=true)
returns a bash_N handle immediately; bash_output(id, filter?) returns only
output produced since the previous read plus status and exit code;
kill_shell(id) terminates the shell's whole process group.

- Per-session BackgroundShellRegistry: capped rolling line buffer with
  drop-oldest gap accounting, exit-order record pruning, owner scoping for
  task_agents (shells reaped when the agent finishes), liveness-guarded
  group kills (a stale pgid is never signalled), budgeted teardown joins.
- Exit notices ride a shared external-event rail (sanitize, soft cap,
  channel 'any', idle wake) now common to watch fires; a new 'quiet'
  NudgeQueue channel lets a user cancel defer pending notices without
  letting them re-wake the stopped workstream, and failed wake delivery
  re-queues external notices seq- and predicate-intact without re-arming
  the wake gate.
- The bash_output filter runs in a killable subprocess: sre holds the GIL
  for an entire search, so no in-process timeout can bound a hostile
  pattern. Scrubbed child env, pinned UTF-8 pipes, honest timeout-vs-
  helper-failure error taxonomy, per-line match window with explicit
  clipping notes; a failed filter never consumes the delta.
- run_in_background rides the bash intent-judge projection; bash_output is
  exempt from the repeat warning but still recorded so interleaved polls
  keep breaking other tools' streaks; all bash boolean args share one
  lenient coercion dialect.
- Shells survive generation cancel and die with the workstream: every
  teardown path funnels through ChatSession.close(); CLI exit and the
  server lifespan now close every loaded session, signal-first and
  Ctrl-C-safe, so nothing detached outlives a graceful shutdown.

(cherry picked from commit ab7d56e0ba)
2026-07-11 19:05:36 -07:00

655 lines
26 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Unit tests for :class:`NudgeQueue`."""
from __future__ import annotations
import logging
import threading
import pytest
from turnstone.core.nudge_queue import TOOL_DRAIN, USER_DRAIN, NudgeQueue
class TestEnqueueDrain:
def test_enqueue_drain_fifo_order(self):
q = NudgeQueue()
q.enqueue("a", "1", "user")
q.enqueue("b", "2", "tool")
q.enqueue("c", "3", "any")
# Drain everything regardless of channel — preserves insertion order.
out = q.drain({"user", "tool", "any"})
# Drain returns ``(nudge_type, text, metadata)``; producers
# without ``metadata`` see ``None`` in the third slot.
assert out == [("a", "1", None), ("b", "2", None), ("c", "3", None)]
assert len(q) == 0
def test_drain_filter_keeps_non_matching(self):
q = NudgeQueue()
q.enqueue("a", "x", "user")
q.enqueue("b", "y", "tool")
# Drain only user → tool entry stays.
out = q.drain(USER_DRAIN)
assert out == [("a", "x", None)]
assert len(q) == 1
# Now drain tool — gets the remaining entry.
out = q.drain(TOOL_DRAIN)
assert out == [("b", "y", None)]
assert len(q) == 0
def test_any_channel_drains_on_either_seam(self):
q = NudgeQueue()
q.enqueue("c", "z", "any")
# User-seam drain pulls "any".
assert q.drain(USER_DRAIN) == [("c", "z", None)]
assert len(q) == 0
# Re-enqueue and prove tool-seam also drains "any".
q.enqueue("d", "w", "any")
assert q.drain(TOOL_DRAIN) == [("d", "w", None)]
assert len(q) == 0
def test_drain_empty_filter_no_op(self):
q = NudgeQueue()
q.enqueue("a", "1", "user")
# Empty filter drains nothing.
assert q.drain(set()) == []
assert len(q) == 1
def test_drain_empty_queue_returns_empty_list(self):
q = NudgeQueue()
# Fast-path: no items → no kept-deque allocation, just `[]`.
assert q.drain(USER_DRAIN) == []
assert q.drain({"user", "tool", "any"}) == []
assert len(q) == 0
def test_drain_preserves_order_across_partial_drain(self):
q = NudgeQueue()
q.enqueue("a", "1", "user")
q.enqueue("b", "2", "tool")
q.enqueue("c", "3", "user")
q.enqueue("d", "4", "tool")
# Drain user — should get "a" then "c" in order; "b","d" stay.
assert q.drain({"user"}) == [("a", "1", None), ("c", "3", None)]
# Tool drain follows insertion order on remaining.
assert q.drain({"tool"}) == [("b", "2", None), ("d", "4", None)]
class TestLenAndClear:
def test_len_does_not_mutate(self):
q = NudgeQueue()
q.enqueue("a", "1", "user")
assert len(q) == 1
assert len(q) == 1 # second call still 1; not consumed
assert q.pending() == [("a", "1")]
def test_len_empty_is_zero(self):
q = NudgeQueue()
assert len(q) == 0
def test_clear_returns_count(self):
q = NudgeQueue()
assert q.clear() == 0
q.enqueue("a", "1", "user")
q.enqueue("b", "2", "tool")
q.enqueue("c", "3", "any")
assert q.clear() == 3
assert len(q) == 0
def test_clear_empty_returns_zero(self):
q = NudgeQueue()
assert q.clear() == 0
def test_clear_channels_drops_only_matching(self):
"""The abandoned-generation path drops advisory channels but must
preserve ``"any"``-channel external events (watch fires,
background-shell exits) in order."""
q = NudgeQueue()
q.enqueue("tool_error", "1", "tool")
q.enqueue("watch_triggered", "2", "any")
q.enqueue("correction", "3", "user")
q.enqueue("background_shell_exit", "4", "any")
assert q.clear_channels({"tool", "user"}) == 2
assert q.pending() == [("watch_triggered", "2"), ("background_shell_exit", "4")]
def test_clear_channels_empty_returns_zero(self):
q = NudgeQueue()
assert q.clear_channels({"tool", "user"}) == 0
def test_demote_channel_retags_preserving_order_and_metadata(self):
"""Cancel demotes 'any' → 'quiet': same entries, same order, same
metadata/valid_until — only wake eligibility changes."""
q = NudgeQueue()
q.enqueue("watch_triggered", "w", "any", metadata={"watch_name": "ci"})
q.enqueue("correction", "c", "user")
q.enqueue("background_shell_exit", "b", "any", valid_until=lambda: True)
assert q.demote_channel("any", "quiet") == 2
assert q.pending(channel="any") == []
assert q.pending(channel="quiet") == [
("watch_triggered", "w"),
("background_shell_exit", "b"),
]
# Metadata and valid_until ride the demotion; USER_DRAIN delivers.
from turnstone.core.nudge_queue import USER_DRAIN, WAKE_PENDING
assert not q.has_pending(WAKE_PENDING - {"user"}) # no 'any' left
drained = q.drain(USER_DRAIN)
assert [(t, x, m) for t, x, m in drained] == [
("watch_triggered", "w", {"watch_name": "ci"}),
("correction", "c", None),
("background_shell_exit", "b", None),
]
def test_cap_channel_none_sees_demoted_entries(self):
"""The watch soft cap counts across channels: entries a cancel
demoted to 'quiet' still occupy the budget, and drop-oldest evicts
the stalest regardless of channel."""
q = NudgeQueue()
q.enqueue("watch_triggered", "1", "any")
q.demote_channel("any", "quiet")
q.enqueue("watch_triggered", "2", "any")
assert q.cap_at_or_drop_oldest("watch_triggered", 2, channel=None) is True
assert q.pending() == [("watch_triggered", "2")]
def test_requeue_preserves_seq_for_chronology(self):
"""A failed delivery gives entries back with their ORIGINAL seq, so
a re-queued poll-4 still sorts before the poll-5 that arrived during
the failed attempt — counters never run backwards."""
q = NudgeQueue()
q.enqueue("watch_triggered", "poll-4", "any")
(drained_entry,) = q.drain_entries({"any"})
q.enqueue("watch_triggered", "poll-5", "any") # newer event lands
q.requeue(drained_entry, channel="quiet")
entries = q.drain_entries({"any", "quiet"})
entries.sort(key=lambda e: e.seq)
assert [e.text for e in entries] == ["poll-4", "poll-5"]
def test_requeue_positions_by_seq_for_fifo_drains(self):
"""Positioned insertion: plain (unsorted) drains also see the
re-queued older entry first."""
q = NudgeQueue()
q.enqueue("a", "old", "quiet")
(old_entry,) = q.drain_entries({"quiet"})
q.enqueue("b", "new", "quiet")
q.requeue(old_entry)
assert [text for _t, text in q.pending()] == ["old", "new"]
def test_requeue_preserves_valid_until_and_metadata(self):
alive = {"value": True}
q = NudgeQueue()
q.enqueue("n", "x", "any", valid_until=lambda: alive["value"], metadata={"k": 1})
(entry,) = q.drain_entries({"any"})
q.requeue(entry, channel="quiet")
alive["value"] = False
assert q.drain({"quiet"}) == [] # predicate survived the round-trip
def test_quiet_is_outside_the_wake_gate(self):
from turnstone.core.nudge_queue import TOOL_DRAIN, USER_DRAIN, WAKE_PENDING
q = NudgeQueue()
q.enqueue("background_shell_exit", "b", "quiet")
assert not q.has_pending(WAKE_PENDING)
assert q.has_pending(USER_DRAIN)
assert q.has_pending(TOOL_DRAIN)
class TestDropOldestByType:
def test_drop_oldest_by_type_removes_earliest_match(self):
"""Drop the FIRST entry of the matching type; later matches stay."""
q = NudgeQueue()
q.enqueue("other", "first", "any")
q.enqueue("target", "older", "any")
q.enqueue("target", "newer", "any")
# "older" is the earliest target — drop it.
assert q.drop_oldest_by_type("target") is True
assert q.pending() == [("other", "first"), ("target", "newer")]
def test_drop_oldest_by_type_no_match_returns_false(self):
"""Empty queue and unmatched-type cases both return False."""
q = NudgeQueue()
# Empty.
assert q.drop_oldest_by_type("target") is False
# Non-matching items only.
q.enqueue("other", "1", "any")
q.enqueue("other", "2", "tool")
assert q.drop_oldest_by_type("target") is False
# Queue is unaffected.
assert q.pending() == [("other", "1"), ("other", "2")]
def test_drop_oldest_by_type_only_drops_one(self):
"""Multiple matching entries → only the first is removed."""
q = NudgeQueue()
q.enqueue("target", "1", "any")
q.enqueue("target", "2", "any")
q.enqueue("target", "3", "any")
assert q.drop_oldest_by_type("target") is True
assert q.pending() == [("target", "2"), ("target", "3")]
def test_drop_oldest_by_type_channel_filter(self):
"""With ``channel`` set, drop walks only that channel. Pairs with
:meth:`count_by_type(..., channel=...)` so producer-side soft caps
operate on a consistent entry set.
"""
q = NudgeQueue()
q.enqueue("target", "user-1", "user")
q.enqueue("target", "any-1", "any")
q.enqueue("target", "any-2", "any")
# Drop the oldest "any"-channel target — leaves the user one
# untouched even though it's earlier in insertion order.
assert q.drop_oldest_by_type("target", channel="any") is True
assert q.pending() == [
("target", "user-1"),
("target", "any-2"),
]
# And a channel with no matches returns False without touching
# the queue.
assert q.drop_oldest_by_type("target", channel="tool") is False
assert q.pending() == [
("target", "user-1"),
("target", "any-2"),
]
class TestCapAtOrDropOldest:
def test_below_cap_no_drop(self):
q = NudgeQueue()
for i in range(3):
q.enqueue("target", f"t-{i}", "any")
# 3 entries, cap=5 → no drop.
assert q.cap_at_or_drop_oldest("target", 5, channel="any") is False
assert q.count_by_type("target") == 3
def test_at_cap_drops_oldest(self):
q = NudgeQueue()
for i in range(5):
q.enqueue("target", f"t-{i}", "any")
# 5 entries, cap=5 → drop the oldest ("t-0"), leaving 4.
assert q.cap_at_or_drop_oldest("target", 5, channel="any") is True
remaining = q.pending()
assert ("target", "t-0") not in remaining
assert len(remaining) == 4
assert remaining[0] == ("target", "t-1") # FIFO drop-oldest preserved
def test_above_cap_drops_only_one(self):
q = NudgeQueue()
for i in range(7):
q.enqueue("target", f"t-{i}", "any")
# 7 entries, cap=5 → drop only ONE per call (soft-cap regulates over time).
assert q.cap_at_or_drop_oldest("target", 5, channel="any") is True
assert q.count_by_type("target") == 6
def test_channel_filter_respected(self):
q = NudgeQueue()
for i in range(3):
q.enqueue("target", f"any-{i}", "any")
for i in range(3):
q.enqueue("target", f"user-{i}", "user")
# 3 "any"-channel entries; cap=3 on channel="any" → drop oldest "any" only.
assert q.cap_at_or_drop_oldest("target", 3, channel="any") is True
# User-channel entries untouched.
assert q.count_by_type("target", channel="user") == 3
assert q.count_by_type("target", channel="any") == 2
def test_other_types_ignored(self):
q = NudgeQueue()
for i in range(5):
q.enqueue("other", f"o-{i}", "any")
q.enqueue("target", "t-0", "any")
# Only one "target" entry; cap=1 on "target" → drop it. "other"
# entries are untouched even though queue holds 6 total.
assert q.cap_at_or_drop_oldest("target", 1, channel="any") is True
assert q.count_by_type("target") == 0
assert q.count_by_type("other") == 5
def test_zero_or_negative_cap_no_op(self):
q = NudgeQueue()
q.enqueue("target", "t-0", "any")
assert q.cap_at_or_drop_oldest("target", 0, channel="any") is False
assert q.cap_at_or_drop_oldest("target", -1, channel="any") is False
assert q.count_by_type("target") == 1
def test_no_match_returns_false(self):
q = NudgeQueue()
q.enqueue("other", "o-0", "any")
assert q.cap_at_or_drop_oldest("target", 1, channel="any") is False
assert q.count_by_type("other") == 1
class TestCountByType:
def test_count_by_type_no_channel(self):
"""Count across all channels with ``channel=None``."""
q = NudgeQueue()
q.enqueue("target", "1", "user")
q.enqueue("other", "x", "any")
q.enqueue("target", "2", "any")
q.enqueue("target", "3", "tool")
assert q.count_by_type("target") == 3
assert q.count_by_type("other") == 1
assert q.count_by_type("missing") == 0
def test_count_by_type_with_channel_filter(self):
"""Filter narrows the count to one channel — used by producer-side
soft caps that pair with ``drop_oldest_by_type(..., channel=...)``.
"""
q = NudgeQueue()
q.enqueue("target", "u-1", "user")
q.enqueue("target", "a-1", "any")
q.enqueue("target", "a-2", "any")
q.enqueue("target", "t-1", "tool")
assert q.count_by_type("target", channel="any") == 2
assert q.count_by_type("target", channel="user") == 1
assert q.count_by_type("target", channel="tool") == 1
def test_count_by_type_empty_queue(self):
q = NudgeQueue()
assert q.count_by_type("anything") == 0
assert q.count_by_type("anything", channel="any") == 0
class TestPending:
def test_pending_no_filter_returns_all_in_order(self):
q = NudgeQueue()
q.enqueue("a", "1", "user")
q.enqueue("b", "2", "tool")
q.enqueue("c", "3", "any")
# All three, in insertion order, as (nudge_type, text) tuples.
assert q.pending() == [("a", "1"), ("b", "2"), ("c", "3")]
def test_pending_channel_filter(self):
q = NudgeQueue()
q.enqueue("a", "1", "user")
q.enqueue("b", "2", "tool")
q.enqueue("c", "3", "user")
q.enqueue("d", "4", "any")
assert q.pending("user") == [("a", "1"), ("c", "3")]
assert q.pending("tool") == [("b", "2")]
assert q.pending("any") == [("d", "4")]
def test_pending_does_not_mutate(self):
q = NudgeQueue()
q.enqueue("a", "1", "user")
q.enqueue("b", "2", "tool")
# Two pending calls return same content; nothing consumed.
first = q.pending()
second = q.pending()
assert first == second
assert len(q) == 2
class TestMetadata:
"""Producer-supplied ``metadata`` rides alongside ``(type, text)`` on
drain. Today only ``watch_triggered`` populates it; the wire shape
accommodates future producers (e.g. structured tool_error context)
without another schema bump.
"""
def test_drain_returns_metadata_when_set(self):
q = NudgeQueue()
meta = {"watch_name": "w1", "command": "ls", "poll_count": 2}
q.enqueue("watch_triggered", "$ ls\nfile.txt", "any", metadata=meta)
out = q.drain({"any"})
assert out == [("watch_triggered", "$ ls\nfile.txt", meta)]
def test_drain_returns_none_when_metadata_unset(self):
q = NudgeQueue()
q.enqueue("idle_children", "kids", "any") # no metadata kwarg
out = q.drain({"any"})
assert out == [("idle_children", "kids", None)]
def test_pending_with_metadata_projects_third_field(self):
q = NudgeQueue()
q.enqueue("a", "1", "user")
q.enqueue("watch_triggered", "out", "any", metadata={"watch_name": "w"})
snapshot = q.pending_with_metadata()
assert snapshot == [
("a", "1", None),
("watch_triggered", "out", {"watch_name": "w"}),
]
# ``pending`` (without metadata) keeps the legacy 2-tuple shape.
assert q.pending() == [("a", "1"), ("watch_triggered", "out")]
def test_metadata_survives_partial_drain(self):
"""A ``user``-channel drain leaves an unaffected ``tool``-channel
entry — its metadata must still be present on the next drain."""
q = NudgeQueue()
q.enqueue("user_thing", "u", "user")
q.enqueue("watch_triggered", "w-out", "tool", metadata={"watch_name": "w1"})
# User drain doesn't touch the tool entry.
assert q.drain({"user"}) == [("user_thing", "u", None)]
# Tool drain still has the metadata.
assert q.drain({"tool"}) == [
("watch_triggered", "w-out", {"watch_name": "w1"}),
]
def test_metadata_with_valid_until_predicate(self):
"""Metadata + ``valid_until`` co-exist on the same entry; the
predicate gate runs as before, and on a True result the metadata
rides the drained tuple.
"""
q = NudgeQueue()
q.enqueue(
"watch_triggered",
"w-out",
"any",
valid_until=lambda: True,
metadata={"watch_name": "w1", "is_final": True},
)
out = q.drain({"any"})
assert out == [
("watch_triggered", "w-out", {"watch_name": "w1", "is_final": True}),
]
class TestHasPending:
def test_has_pending_returns_false_on_empty_queue(self):
q = NudgeQueue()
assert q.has_pending({"user", "any"}) is False
assert q.has_pending({"tool"}) is False
def test_has_pending_short_circuits_on_first_match(self):
q = NudgeQueue()
q.enqueue("a", "1", "tool")
q.enqueue("b", "2", "user")
# First entry doesn't match, second does — true after walking 2.
assert q.has_pending({"user"}) is True
def test_has_pending_returns_false_when_no_match(self):
q = NudgeQueue()
q.enqueue("a", "1", "tool")
q.enqueue("b", "2", "tool")
assert q.has_pending({"user", "any"}) is False
def test_has_pending_matches_any_channel(self):
q = NudgeQueue()
q.enqueue("a", "1", "any")
# USER_DRAIN-shaped filter pulls "any" entries.
assert q.has_pending(USER_DRAIN) is True
# TOOL_DRAIN-shaped filter also pulls "any" entries.
assert q.has_pending(TOOL_DRAIN) is True
def test_has_pending_does_not_mutate(self):
q = NudgeQueue()
q.enqueue("a", "1", "user")
q.enqueue("b", "2", "tool")
before = q.pending()
q.has_pending({"user"})
q.has_pending({"tool"})
q.has_pending(set())
assert q.pending() == before
class TestValidation:
def test_invalid_channel_raises(self):
q = NudgeQueue()
with pytest.raises(ValueError, match="channel"):
q.enqueue("a", "1", "wake") # type: ignore[arg-type]
with pytest.raises(ValueError):
q.enqueue("b", "2", "") # type: ignore[arg-type]
# Queue is unaffected by the failed enqueues.
assert len(q) == 0
def test_channel_is_required(self):
q = NudgeQueue()
# No default — caller MUST pick a seam consciously.
with pytest.raises(TypeError):
q.enqueue("a", "1") # type: ignore[call-arg]
class TestValidUntil:
"""``valid_until`` predicate: drain re-checks freshness. Falsy
predicates drop the entry without delivery and log at ``info``
(normal lifecycle outcome); raising predicates drop the entry and
log at ``warning`` with ``exc_info`` (misbehaving predicate).
"""
def test_valid_until_true_delivers(self):
q = NudgeQueue()
q.enqueue("a", "1", "any", valid_until=lambda: True)
out = q.drain({"any"})
assert out == [("a", "1", None)]
def test_valid_until_false_drops_with_info_log(self, caplog: pytest.LogCaptureFixture):
q = NudgeQueue()
q.enqueue("a", "1", "any", valid_until=lambda: False)
with caplog.at_level(logging.INFO, logger="turnstone.core.nudge_queue"):
out = q.drain({"any"})
assert out == []
# Already removed from queue (drain partition removes BEFORE
# predicate check — falsy doesn't return to queue).
assert len(q) == 0
# The drop emits a structured info record so a wiring
# regression (a predicate that always returns False) is still
# observable, without spamming ``warning`` for the routine
# lifecycle case where ``valid_until`` is doing its job.
# structlog renders the event name + extras into ``msg`` as a
# single rendered string, so substring-match like the
# ``watch_dispatch.queue_full`` assertion in
# tests/test_watch_dispatch.py.
drops = [r for r in caplog.records if "nudge_queue.predicate_dropped" in r.getMessage()]
assert len(drops) == 1
assert drops[0].levelno == logging.INFO
assert "predicate_false" in drops[0].getMessage()
assert "'nudge_type': 'a'" in drops[0].getMessage()
assert "'channel': 'any'" in drops[0].getMessage()
assert "'text_len': 1" in drops[0].getMessage()
def test_valid_until_exception_drops_with_warning(self, caplog: pytest.LogCaptureFixture):
q = NudgeQueue()
def boom() -> bool:
raise RuntimeError("predicate crash")
q.enqueue("a", "1", "any", valid_until=boom)
with caplog.at_level(logging.WARNING, logger="turnstone.core.nudge_queue"):
out = q.drain({"any"})
assert out == []
# Crash-on-predicate is treated as "no longer valid" — drop, not propagate.
assert len(q) == 0
# Stays at ``warning`` (with ``exc_info``) because a raising
# predicate is a bug, not a normal lifecycle outcome.
drops = [r for r in caplog.records if "nudge_queue.predicate_dropped" in r.getMessage()]
assert len(drops) == 1
assert drops[0].levelno == logging.WARNING
rendered = drops[0].getMessage()
assert "predicate_raised" in rendered
assert "RuntimeError" in rendered
assert "predicate crash" in rendered
def test_valid_until_evaluated_outside_lock(self):
"""The predicate may do non-trivial work (e.g. storage I/O)
without blocking other producers. Verify the predicate runs
outside the queue's internal lock by enqueueing from inside
the predicate — would deadlock if the lock was still held.
"""
q = NudgeQueue()
def reentrant() -> bool:
# If the lock is held during predicate eval, this enqueue
# would block forever (RLock would let it through, but the
# queue uses a plain Lock).
q.enqueue("inner", "from-predicate", "any", valid_until=lambda: True)
return True
q.enqueue("outer", "1", "any", valid_until=reentrant)
out = q.drain({"any"})
# Outer's predicate ran outside the lock, enqueued "inner";
# outer's True return delivered "outer". "inner" was enqueued
# AFTER the partition snapshot, so it stays in the queue.
assert out == [("outer", "1", None)]
assert q.pending() == [("inner", "from-predicate")]
def test_valid_until_only_evaluated_for_matching_channel(self):
"""A non-matching entry's predicate must NOT fire — that would
be wasted work (or worse, a side-effecting predicate would run
when the entry is supposed to stay queued).
"""
q = NudgeQueue()
calls = []
def track() -> bool:
calls.append(1)
return True
# Tool-channel entry; we drain user-channel. Predicate must not run.
q.enqueue("a", "1", "tool", valid_until=track)
q.drain({"user", "any"})
assert calls == []
# Entry stays queued.
assert q.pending("tool") == [("a", "1")]
def test_valid_until_default_none_always_delivers(self):
# No predicate → entry behaves identically to pre-PR-3 entries.
q = NudgeQueue()
q.enqueue("a", "1", "any") # no valid_until kwarg
assert q.drain({"any"}) == [("a", "1", None)]
class TestConcurrency:
def test_concurrent_enqueue_drain_no_loss(self):
"""16 producer threads × 64 nudges = 1024 total; one consumer
drains in a loop until producers finish + queue empty. Verify
every produced item is observed exactly once.
"""
q = NudgeQueue()
producers = 16
per_producer = 64
total = producers * per_producer
produced: set[tuple[str, str]] = set()
produced_lock = threading.Lock()
observed: list[tuple[str, str]] = []
observed_lock = threading.Lock()
done_event = threading.Event()
def produce(pid: int) -> None:
for i in range(per_producer):
key = (f"p{pid}", f"i{i}")
with produced_lock:
produced.add(key)
q.enqueue(key[0], key[1], "user")
def consume() -> None:
while not done_event.is_set() or len(q) > 0:
drained = q.drain({"user"})
if drained:
with observed_lock:
# Drop the trailing ``metadata`` slot — every
# entry here was enqueued without metadata, so
# the comparison set / count matches the produced
# ``(type, text)`` shape.
observed.extend((nt, txt) for nt, txt, _meta in drained)
consumer = threading.Thread(target=consume, daemon=True)
consumer.start()
threads = [threading.Thread(target=produce, args=(i,)) for i in range(producers)]
for t in threads:
t.start()
for t in threads:
t.join()
done_event.set()
consumer.join(timeout=5.0)
assert not consumer.is_alive(), "consumer didn't finish in time"
# Every produced key observed; no duplicates.
assert set(observed) == produced
assert len(observed) == total
assert len(q) == 0