mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-16 00:42:28 -06:00
c757c22f55
Closes PR #484 review findings (Copilot): the soft-cap pattern in ``ChatSession.set_watch_runner``'s dispatch closure was a non-atomic two-call pair (``count_by_type`` then ``drop_oldest_by_type``) with two separate lock acquisitions. A concurrent drain on the worker thread (``USER_DRAIN`` / ``TOOL_DRAIN`` consuming ``"watch_triggered"`` entries via the ``"any"`` channel) could slip between the two calls, making the drop a no-op. The dispatch closure also discarded ``drop_oldest_by_type``'s return value and unconditionally logged ``dropped_oldest=True``, so a no-op drop got reported as a successful drop. * New ``NudgeQueue.cap_at_or_drop_oldest(nudge_type, max_depth, channel=None) -> bool`` does the count+drop in a single critical section. Returns the actual outcome. * Dispatch closure (``session.py:1410-1416``) now calls the helper and uses its return value to gate the WARNING log line, so the log is accurate when a drop did NOT happen. * ``drop_oldest_by_type``'s docstring no longer overstates the per-call lock as covering a count+drop pair — it points readers to ``cap_at_or_drop_oldest`` for that contract. 7 new tests in ``TestCapAtOrDropOldest`` cover: below-cap no-op, at-cap drop-oldest, above-cap drop-only-one (per-call), channel filter, other-type isolation, ``max_depth <= 0`` defensive no-op, no-match. 5708 non-live tests pass; ruff + mypy clean. The github-code-quality bot finding ("Statement has no effect" on ``_protocol.py:939``'s ``...`` body) is a false positive — every Protocol method in ``_protocol.py`` uses ``...`` as its body, which is the canonical Python Protocol pattern. Replacing with ``pass`` would diverge from the file's existing style. No code change.
464 lines
17 KiB
Python
464 lines
17 KiB
Python
"""Unit tests for :class:`NudgeQueue`."""
|
||
|
||
from __future__ import annotations
|
||
|
||
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"})
|
||
assert out == [("a", "1"), ("b", "2"), ("c", "3")]
|
||
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")]
|
||
assert len(q) == 1
|
||
# Now drain tool — gets the remaining entry.
|
||
out = q.drain(TOOL_DRAIN)
|
||
assert out == [("b", "y")]
|
||
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")]
|
||
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")]
|
||
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"), ("c", "3")]
|
||
# Tool drain follows insertion order on remaining.
|
||
assert q.drain({"tool"}) == [("b", "2"), ("d", "4")]
|
||
|
||
|
||
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
|
||
|
||
|
||
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 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 /
|
||
raising predicates drop the entry without delivery.
|
||
"""
|
||
|
||
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")]
|
||
|
||
def test_valid_until_false_drops_silently(self):
|
||
q = NudgeQueue()
|
||
q.enqueue("a", "1", "any", valid_until=lambda: False)
|
||
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
|
||
|
||
def test_valid_until_exception_drops_silently(self):
|
||
q = NudgeQueue()
|
||
|
||
def boom() -> bool:
|
||
raise RuntimeError("predicate crash")
|
||
|
||
q.enqueue("a", "1", "any", valid_until=boom)
|
||
out = q.drain({"any"})
|
||
assert out == []
|
||
# Crash-on-predicate is treated as "no longer valid" — drop, not propagate.
|
||
assert len(q) == 0
|
||
|
||
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")]
|
||
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")]
|
||
|
||
|
||
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:
|
||
observed.extend(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
|