mirror of
https://github.com/imartinez/privateGPT.git
synced 2026-07-17 01:48:03 +00:00
* fix: avoid to block the loop * fix: blocks in expansion * fix: remove maximum concurrent users ... * fix: multiplexer * fix: readers * fix: more fixes ... * fix: impl * feat: tool scheduler * feat: add adaptative * feat: add chat worker * fix: max * feat: add chat/tools workers * fix: mypy * feat: add generic scheduler * fix: get result * feat: do serializable the tool executor * fix: tools * fix: config * fix: config * fix: args * fix: config * fix: serializer * Revert "fix: blocks in expansion" This reverts commita2110f94a8. * fix: unify all logic * feat: add ingestion scheduler * fix: settings * fix: config * feat: add arq worker to chat * fix: arq worker * fix: add nest * fix: mypy * fix: await * fix: script stress * fix: tokenizer * fix: chat scheduler * fix: mypy * fix: add async tokenizer * fix: improve condense * fix: tool scheduler * feat: add initial real async chat worker * fix: mypy * fix: do resumable local executor ... ... ... fix: revert usleess changes fix: remove parent chat job fix: refactor fix: loop ref: rename models fix: chat engine fix: mypy ... ... ... fix: fix deps * fix: tests * fix: tests * ... * fix: stream * fix: config * fix: scheduler * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Handle PGPT_WORKER_MODE=celery in health check worker status * fix: cancel * fix: arch * fix: test ingestion * fix: deserialization of chat messages * fix: broken results * fix: mypy * fix: test * fix: config * fix: remove arq tool worker * fix: output cls * fix: preserve early resumable tool callbacks * fix: preserve async tool result order * refactor: address worker PR review comments * fix: mypy * test: colocate ARQ chat enqueue coverage * fix: remove redis from tests * test: isolate chat mocks and cancellation timing * fix: tests (cherry picked from commit218b599c66) # Conflicts: # tests/server/chat/anthropic/test_anthropic_client.py # tests/server/chat/anthropic/test_langchain_anthropic.py # tests/server/chat/test_chat_knowledge_revamp.py # tests/server/chat/test_chat_routes.py # tests/server/chat/test_chat_routes_skills_integration.py * fix: tests (cherry picked from commitfc5ec0f72a) * fix: ruff * fix: test * fix: worker config (cherry picked from commit1371c275a1) * fix: principal * test: remove flaky chat cancellation assertion --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
704 lines
26 KiB
Python
704 lines
26 KiB
Python
"""Tests for StreamMultiplexer and the streaming readers.
|
|
|
|
The core strategy is **differential testing**: each scenario is run through the
|
|
direct path (StreamReader.stream_events) AND the multiplexed path
|
|
(StreamMultiplexer), and the two results must be identical. If they ever
|
|
diverge, the multiplexer is broken.
|
|
|
|
Section layout
|
|
--------------
|
|
1. Differential tests — direct == multiplexed for every scenario.
|
|
2. Multiplexer concurrency — late joiners, concurrent remove, multi-consumer.
|
|
3. Terminal detection — events in the terminal gap are drained.
|
|
4. Lifecycle — stop/restart leave no stale state.
|
|
5. Performance — worker does not busy-poll.
|
|
6. Backend block semantics — block_ms=None is non-blocking.
|
|
"""
|
|
|
|
import asyncio
|
|
import json
|
|
import random
|
|
from typing import Any
|
|
|
|
import pytest
|
|
from pydantic import BaseModel
|
|
|
|
from private_gpt.components.streaming.providers.in_memory_stream_service import (
|
|
InMemoryStreamService,
|
|
)
|
|
from private_gpt.components.streaming.providers.models import StreamStatus
|
|
from private_gpt.components.streaming.providers.stream_service import StreamService
|
|
from private_gpt.components.streaming.stream.stream_reader import (
|
|
DEFAULT_BLOCK_MS,
|
|
StreamConsumer,
|
|
StreamMultiplexer,
|
|
StreamReader,
|
|
)
|
|
|
|
STRESS_N = 500
|
|
|
|
|
|
# ──────────────────────────── helpers / models ────────────────────────────────
|
|
|
|
|
|
class SimpleEvent(BaseModel):
|
|
n: int
|
|
|
|
|
|
class SimpleEventHandler:
|
|
def serialize(self, event: BaseModel) -> str:
|
|
return event.model_dump_json()
|
|
|
|
def deserialize(self, data: str) -> BaseModel:
|
|
return SimpleEvent.model_validate_json(data)
|
|
|
|
async def get_current_status(self, event: BaseModel) -> StreamStatus | None:
|
|
return None
|
|
|
|
def error_event(self, correlation_id: str, error: Exception) -> BaseModel:
|
|
return SimpleEvent(n=-1)
|
|
|
|
|
|
class SimpleEventHandler2(SimpleEventHandler):
|
|
"""Distinct type so add_consumer creates a separate consumer."""
|
|
|
|
|
|
class MockStreamComponent:
|
|
def __init__(self, service: StreamService) -> None:
|
|
self.stream = service
|
|
|
|
|
|
async def collect_from_consumer(
|
|
consumer: StreamConsumer,
|
|
timeout: float = 10.0,
|
|
) -> list[BaseModel]:
|
|
"""Collect all events from a consumer's broadcast until it is closed."""
|
|
events: list[BaseModel] = []
|
|
|
|
async def drain() -> None:
|
|
async for event in consumer.broadcast.read_from(cursor=consumer.cursor):
|
|
events.append(event)
|
|
|
|
await asyncio.wait_for(drain(), timeout=timeout)
|
|
return events
|
|
|
|
|
|
async def run_direct(
|
|
stream_reader: StreamReader,
|
|
handler: SimpleEventHandler,
|
|
cid: str,
|
|
last_id: str = "0",
|
|
) -> list[BaseModel]:
|
|
received: list[BaseModel] = []
|
|
gen = await stream_reader.stream_events(
|
|
event_handler=handler,
|
|
correlation_id=cid,
|
|
last_id=last_id,
|
|
)
|
|
async for event in gen:
|
|
received.append(event)
|
|
return received
|
|
|
|
|
|
async def run_multiplexed(
|
|
multiplexer: StreamMultiplexer,
|
|
handler: SimpleEventHandler,
|
|
cid: str,
|
|
last_id: str = "0",
|
|
) -> list[BaseModel]:
|
|
consumer = await multiplexer.add_consumer(cid, handler, last_id=last_id)
|
|
return await collect_from_consumer(consumer, timeout=30.0)
|
|
|
|
|
|
async def push_n_and_complete(
|
|
service: StreamService,
|
|
cid: str,
|
|
n: int,
|
|
*,
|
|
burst: int = 10,
|
|
max_jitter: float = 0.0,
|
|
) -> None:
|
|
for i in range(n):
|
|
await service.push_event(cid, json.dumps({"n": i}))
|
|
if (i + 1) % burst == 0:
|
|
await asyncio.sleep(random.uniform(0, max_jitter))
|
|
await asyncio.sleep(random.uniform(0, max_jitter * 2))
|
|
await service.update_stream_status(cid, StreamStatus.COMPLETED)
|
|
|
|
|
|
def assert_ordered_0_to_n(events: list[BaseModel], n: int) -> None:
|
|
ns = [e.n for e in events] # type: ignore[attr-defined]
|
|
missing = sorted(set(range(n)) - set(ns))
|
|
duplicates = sorted({x for x in ns if ns.count(x) > 1})
|
|
assert not missing, f"missing {len(missing)} events: {missing[:10]}"
|
|
assert not duplicates, f"duplicates: {duplicates[:10]}"
|
|
assert ns == sorted(ns), "events arrived out of order"
|
|
assert len(ns) == n
|
|
|
|
|
|
# ──────────────────────────── fixtures ───────────────────────────────────────
|
|
|
|
|
|
@pytest.fixture
|
|
def service() -> StreamService:
|
|
return InMemoryStreamService()
|
|
|
|
|
|
@pytest.fixture
|
|
def stream_reader(service: StreamService) -> StreamReader:
|
|
return StreamReader(MockStreamComponent(service)) # type: ignore[arg-type]
|
|
|
|
|
|
@pytest.fixture
|
|
def multiplexer(stream_reader: StreamReader) -> StreamMultiplexer:
|
|
return StreamMultiplexer(stream_reader)
|
|
|
|
|
|
@pytest.fixture
|
|
def handler() -> SimpleEventHandler:
|
|
return SimpleEventHandler()
|
|
|
|
|
|
@pytest.fixture
|
|
def handler2() -> SimpleEventHandler2:
|
|
return SimpleEventHandler2()
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
# 1. Differential tests — direct path must equal multiplexed path
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
async def test_both_paths_prepopulated(
|
|
service: StreamService,
|
|
stream_reader: StreamReader,
|
|
multiplexer: StreamMultiplexer,
|
|
handler: SimpleEventHandler,
|
|
) -> None:
|
|
cid = await service.create_stream("test")
|
|
await service.push_event(cid, json.dumps({"n": 1}))
|
|
await service.push_event(cid, json.dumps({"n": 2}))
|
|
await service.push_event(cid, json.dumps({"n": 3}))
|
|
await service.update_stream_status(cid, StreamStatus.COMPLETED)
|
|
|
|
await multiplexer.start()
|
|
try:
|
|
mux = await run_multiplexed(multiplexer, handler, cid)
|
|
finally:
|
|
await multiplexer.stop()
|
|
direct = await run_direct(stream_reader, handler, cid)
|
|
|
|
assert [e.n for e in mux] == [e.n for e in direct] == [1, 2, 3] # type: ignore[attr-defined]
|
|
|
|
|
|
async def test_both_paths_empty_stream(
|
|
service: StreamService,
|
|
stream_reader: StreamReader,
|
|
multiplexer: StreamMultiplexer,
|
|
handler: SimpleEventHandler,
|
|
) -> None:
|
|
cid = await service.create_stream("test")
|
|
await service.update_stream_status(cid, StreamStatus.COMPLETED)
|
|
|
|
await multiplexer.start()
|
|
try:
|
|
mux = await run_multiplexed(multiplexer, handler, cid)
|
|
finally:
|
|
await multiplexer.stop()
|
|
direct = await run_direct(stream_reader, handler, cid)
|
|
|
|
assert mux == direct == []
|
|
|
|
|
|
async def test_both_paths_reconnect_from_mid(
|
|
service: StreamService,
|
|
stream_reader: StreamReader,
|
|
multiplexer: StreamMultiplexer,
|
|
handler: SimpleEventHandler,
|
|
) -> None:
|
|
"""A consumer that already has event n=1 (it knows last_id=e1_id) must
|
|
receive only events after that point — never a replay from "0"."""
|
|
cid = await service.create_stream("test")
|
|
e1_id = await service.push_event(cid, json.dumps({"n": 1}))
|
|
await service.push_event(cid, json.dumps({"n": 2}))
|
|
await service.push_event(cid, json.dumps({"n": 3}))
|
|
await service.update_stream_status(cid, StreamStatus.COMPLETED)
|
|
|
|
await multiplexer.start()
|
|
try:
|
|
mux = await run_multiplexed(multiplexer, handler, cid, last_id=e1_id)
|
|
finally:
|
|
await multiplexer.stop()
|
|
direct = await run_direct(stream_reader, handler, cid, last_id=e1_id)
|
|
|
|
assert [e.n for e in mux] == [e.n for e in direct] == [2, 3] # type: ignore[attr-defined]
|
|
|
|
|
|
async def test_both_paths_reconnect_from_tail(
|
|
service: StreamService,
|
|
stream_reader: StreamReader,
|
|
multiplexer: StreamMultiplexer,
|
|
handler: SimpleEventHandler,
|
|
) -> None:
|
|
"""Consumer is caught up to the last event; both paths return nothing."""
|
|
cid = await service.create_stream("test")
|
|
await service.push_event(cid, json.dumps({"n": 1}))
|
|
await service.push_event(cid, json.dumps({"n": 2}))
|
|
e3_id = await service.push_event(cid, json.dumps({"n": 3}))
|
|
await service.update_stream_status(cid, StreamStatus.COMPLETED)
|
|
|
|
await multiplexer.start()
|
|
try:
|
|
mux = await run_multiplexed(multiplexer, handler, cid, last_id=e3_id)
|
|
finally:
|
|
await multiplexer.stop()
|
|
direct = await run_direct(stream_reader, handler, cid, last_id=e3_id)
|
|
|
|
assert mux == direct == []
|
|
|
|
|
|
async def test_both_paths_live_producer(
|
|
service: StreamService,
|
|
stream_reader: StreamReader,
|
|
multiplexer: StreamMultiplexer,
|
|
handler: SimpleEventHandler,
|
|
) -> None:
|
|
"""Events arrive while both consumers are already blocking on the stream.
|
|
Both paths must receive every event in order."""
|
|
cid = await service.create_stream("test")
|
|
|
|
await multiplexer.start()
|
|
try:
|
|
mux_task = asyncio.create_task(run_multiplexed(multiplexer, handler, cid))
|
|
direct_task = asyncio.create_task(run_direct(stream_reader, handler, cid))
|
|
await asyncio.sleep(0.15)
|
|
await push_n_and_complete(service, cid, 100, burst=10, max_jitter=0.003)
|
|
mux = await asyncio.wait_for(mux_task, timeout=30.0)
|
|
direct = await asyncio.wait_for(direct_task, timeout=30.0)
|
|
finally:
|
|
await multiplexer.stop()
|
|
|
|
mux_ns = [e.n for e in mux] # type: ignore[attr-defined]
|
|
direct_ns = [e.n for e in direct] # type: ignore[attr-defined]
|
|
assert mux_ns == direct_ns
|
|
assert_ordered_0_to_n(mux, 100)
|
|
assert_ordered_0_to_n(direct, 100)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("burst", "max_jitter"),
|
|
[
|
|
(1, 0.0),
|
|
(10, 0.003),
|
|
(STRESS_N, 0.0),
|
|
],
|
|
ids=["one-at-a-time", "small-bursts", "single-burst"],
|
|
)
|
|
async def test_both_paths_stress(
|
|
service: StreamService,
|
|
stream_reader: StreamReader,
|
|
multiplexer: StreamMultiplexer,
|
|
handler: SimpleEventHandler,
|
|
burst: int,
|
|
max_jitter: float,
|
|
) -> None:
|
|
cid = await service.create_stream("test")
|
|
await push_n_and_complete(
|
|
service, cid, STRESS_N, burst=burst, max_jitter=max_jitter
|
|
)
|
|
|
|
await multiplexer.start()
|
|
try:
|
|
mux = await run_multiplexed(multiplexer, handler, cid)
|
|
finally:
|
|
await multiplexer.stop()
|
|
direct = await run_direct(stream_reader, handler, cid)
|
|
|
|
assert_ordered_0_to_n(mux, STRESS_N)
|
|
assert_ordered_0_to_n(direct, STRESS_N)
|
|
assert [e.n for e in mux] == [e.n for e in direct] # type: ignore[attr-defined]
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
# 2. Multiplexer concurrency
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
async def test_late_joiner_receives_inflight_batch(
|
|
service: StreamService,
|
|
stream_reader: StreamReader,
|
|
multiplexer: StreamMultiplexer,
|
|
handler: SimpleEventHandler,
|
|
handler2: SimpleEventHandler2,
|
|
) -> None:
|
|
"""A consumer that joins while the worker is blocked inside read_events()
|
|
must still receive the batch that the worker is about to dispatch."""
|
|
cid = await service.create_stream("test")
|
|
|
|
original_read = stream_reader.read_events
|
|
late_box: dict[str, Any] = {}
|
|
|
|
async def read_then_join(
|
|
event_handler: Any,
|
|
correlation_id: str,
|
|
last_id: str = "0",
|
|
count: int | None = None,
|
|
block_ms: int | None = None,
|
|
) -> tuple[list[BaseModel], str]:
|
|
result = await original_read(
|
|
event_handler=event_handler,
|
|
correlation_id=correlation_id,
|
|
last_id=last_id,
|
|
count=count,
|
|
block_ms=block_ms,
|
|
)
|
|
if result[0] and "c2" not in late_box:
|
|
late_box["c2"] = await multiplexer.add_consumer(cid, handler2)
|
|
return result
|
|
|
|
stream_reader.read_events = read_then_join # type: ignore[method-assign]
|
|
|
|
await multiplexer.start()
|
|
c1 = await multiplexer.add_consumer(cid, handler)
|
|
await service.push_event(cid, json.dumps({"n": 1}))
|
|
await service.update_stream_status(cid, StreamStatus.COMPLETED)
|
|
|
|
try:
|
|
recv1 = await collect_from_consumer(c1, timeout=10.0)
|
|
c2 = late_box["c2"]
|
|
recv2 = await collect_from_consumer(c2, timeout=10.0)
|
|
finally:
|
|
await multiplexer.stop()
|
|
stream_reader.read_events = original_read # type: ignore[method-assign]
|
|
|
|
assert 1 in [e.n for e in recv1] # type: ignore[attr-defined]
|
|
assert 1 in [e.n for e in recv2], "late joiner missed the in-flight batch" # type: ignore[attr-defined]
|
|
|
|
|
|
async def test_concurrent_remove_preserves_delivery(
|
|
service: StreamService,
|
|
stream_reader: StreamReader,
|
|
multiplexer: StreamMultiplexer,
|
|
handler: SimpleEventHandler,
|
|
handler2: SimpleEventHandler2,
|
|
) -> None:
|
|
"""If one consumer is removed while the worker is reading, the remaining
|
|
consumers still receive the batch."""
|
|
cid = await service.create_stream("test")
|
|
|
|
original_read = stream_reader.read_events
|
|
removed = False
|
|
|
|
async def read_then_remove(
|
|
event_handler: Any,
|
|
correlation_id: str,
|
|
last_id: str = "0",
|
|
count: int | None = None,
|
|
block_ms: int | None = None,
|
|
) -> tuple[list[BaseModel], str]:
|
|
result = await original_read(
|
|
event_handler=event_handler,
|
|
correlation_id=correlation_id,
|
|
last_id=last_id,
|
|
count=count,
|
|
block_ms=block_ms,
|
|
)
|
|
nonlocal removed
|
|
if result[0] and not removed:
|
|
await multiplexer.remove_consumer(c2)
|
|
removed = True
|
|
return result
|
|
|
|
stream_reader.read_events = read_then_remove # type: ignore[method-assign]
|
|
|
|
await multiplexer.start()
|
|
c1 = await multiplexer.add_consumer(cid, handler)
|
|
c2 = await multiplexer.add_consumer(cid, handler2)
|
|
await service.push_event(cid, json.dumps({"n": 1}))
|
|
await service.update_stream_status(cid, StreamStatus.COMPLETED)
|
|
|
|
try:
|
|
recv1 = await collect_from_consumer(c1, timeout=5.0)
|
|
finally:
|
|
await multiplexer.stop()
|
|
stream_reader.read_events = original_read # type: ignore[method-assign]
|
|
|
|
assert [e.n for e in recv1] == [1] # type: ignore[attr-defined]
|
|
|
|
|
|
async def test_multiple_consumers_all_receive(
|
|
service: StreamService,
|
|
stream_reader: StreamReader,
|
|
multiplexer: StreamMultiplexer,
|
|
handler: SimpleEventHandler,
|
|
handler2: SimpleEventHandler2,
|
|
) -> None:
|
|
"""Two distinct consumers on the same stream both receive every event."""
|
|
cid = await service.create_stream("test")
|
|
await service.push_event(cid, json.dumps({"n": 1}))
|
|
await service.push_event(cid, json.dumps({"n": 2}))
|
|
await service.update_stream_status(cid, StreamStatus.COMPLETED)
|
|
|
|
await multiplexer.start()
|
|
c1 = await multiplexer.add_consumer(cid, handler)
|
|
c2 = await multiplexer.add_consumer(cid, handler2)
|
|
try:
|
|
recv1 = await collect_from_consumer(c1, timeout=10.0)
|
|
recv2 = await collect_from_consumer(c2, timeout=10.0)
|
|
finally:
|
|
await multiplexer.stop()
|
|
|
|
assert [e.n for e in recv1] == [1, 2] # type: ignore[attr-defined]
|
|
assert [e.n for e in recv2] == [1, 2] # type: ignore[attr-defined]
|
|
|
|
|
|
async def test_late_joiner_gets_full_history(
|
|
service: StreamService,
|
|
stream_reader: StreamReader,
|
|
multiplexer: StreamMultiplexer,
|
|
handler: SimpleEventHandler,
|
|
handler2: SimpleEventHandler2,
|
|
) -> None:
|
|
"""A consumer that joins AFTER the first batch has already been dispatched
|
|
must still receive every event from the beginning (last_id='0').
|
|
|
|
This is the 'Delta without start' bug: if a content_block_start event is
|
|
in batch 0 and a second consumer only sees batch 1 onwards, the client
|
|
receives content_block_delta with no preceding start.
|
|
"""
|
|
cid = await service.create_stream("test")
|
|
|
|
# Patch read_events so that after the first successful dispatch we can
|
|
# confirm the broadcast already has batch 0, then add a second consumer.
|
|
original_read = stream_reader.read_events
|
|
second_consumer_box: dict[str, Any] = {}
|
|
dispatch_count = 0
|
|
|
|
async def read_after_first_dispatch(
|
|
event_handler: Any,
|
|
correlation_id: str,
|
|
last_id: str = "0",
|
|
count: int | None = None,
|
|
block_ms: int | None = None,
|
|
) -> tuple[list[BaseModel], str]:
|
|
nonlocal dispatch_count
|
|
result = await original_read(
|
|
event_handler=event_handler,
|
|
correlation_id=correlation_id,
|
|
last_id=last_id,
|
|
count=count,
|
|
block_ms=block_ms,
|
|
)
|
|
# After the first batch is returned (and will be dispatched), add c2.
|
|
# At this point broadcast._batches is still empty (dispatch hasn't run),
|
|
# but after dispatch it will have 1 batch — c2 must still see it.
|
|
if result[0] and dispatch_count == 0 and "c2" not in second_consumer_box:
|
|
dispatch_count += 1
|
|
# Wait one event-loop tick so _dispatch runs and pushes batch 0.
|
|
await asyncio.sleep(0)
|
|
second_consumer_box["c2"] = await multiplexer.add_consumer(
|
|
cid, handler2, last_id="0"
|
|
)
|
|
return result
|
|
|
|
stream_reader.read_events = read_after_first_dispatch # type: ignore[method-assign]
|
|
|
|
await multiplexer.start()
|
|
c1 = await multiplexer.add_consumer(cid, handler, last_id="0")
|
|
|
|
await service.push_event(cid, json.dumps({"n": 1}))
|
|
await service.push_event(cid, json.dumps({"n": 2}))
|
|
await service.update_stream_status(cid, StreamStatus.COMPLETED)
|
|
|
|
try:
|
|
recv1 = await collect_from_consumer(c1)
|
|
c2 = second_consumer_box["c2"]
|
|
recv2 = await collect_from_consumer(c2)
|
|
finally:
|
|
await multiplexer.stop()
|
|
stream_reader.read_events = original_read # type: ignore[method-assign]
|
|
|
|
assert [e.n for e in recv1] == [1, 2], "consumer 1 must receive all events" # type: ignore[attr-defined]
|
|
assert [e.n for e in recv2] == [1, 2], "late joiner must not miss batch 0" # type: ignore[attr-defined]
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
# 3. Terminal detection — events in the gap are drained
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
async def test_events_in_terminal_gap_drained(
|
|
service: StreamService,
|
|
stream_reader: StreamReader,
|
|
multiplexer: StreamMultiplexer,
|
|
handler: SimpleEventHandler,
|
|
) -> None:
|
|
"""Events published between the worker's empty read and the terminal-status
|
|
check must be drained and delivered, not dropped."""
|
|
cid = await service.create_stream("test")
|
|
|
|
original_check = stream_reader.check_terminal_status
|
|
|
|
async def injecting_check(
|
|
correlation_id: str,
|
|
event_handler: Any,
|
|
cached_events: Any,
|
|
) -> bool:
|
|
await service.push_event(correlation_id, json.dumps({"n": 1}))
|
|
await service.push_event(correlation_id, json.dumps({"n": 2}))
|
|
await service.update_stream_status(correlation_id, StreamStatus.COMPLETED)
|
|
return True
|
|
|
|
stream_reader.check_terminal_status = injecting_check # type: ignore[method-assign]
|
|
|
|
await multiplexer.start()
|
|
consumer = await multiplexer.add_consumer(cid, handler)
|
|
try:
|
|
received = await collect_from_consumer(consumer, timeout=10.0)
|
|
finally:
|
|
await multiplexer.stop()
|
|
stream_reader.check_terminal_status = original_check # type: ignore[method-assign]
|
|
|
|
assert [e.n for e in received] == [1, 2] # type: ignore[attr-defined]
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
# 4. Lifecycle — stop / restart leave no stale state
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
async def test_stop_clears_state_and_closes_consumers(
|
|
service: StreamService,
|
|
stream_reader: StreamReader,
|
|
multiplexer: StreamMultiplexer,
|
|
handler: SimpleEventHandler,
|
|
) -> None:
|
|
cid = await service.create_stream("test")
|
|
await service.push_event(cid, json.dumps({"n": 1}))
|
|
|
|
await multiplexer.start()
|
|
consumer = await multiplexer.add_consumer(cid, handler)
|
|
|
|
# Wait until the worker has pushed at least one batch to the broadcast.
|
|
async def get_one() -> None:
|
|
async for _ in consumer.broadcast.read_from(cursor=consumer.cursor):
|
|
break
|
|
|
|
await asyncio.wait_for(get_one(), timeout=5.0)
|
|
|
|
await multiplexer.stop()
|
|
|
|
assert not multiplexer.consumers, "consumers dict must be empty after stop()"
|
|
assert not multiplexer.states, "states dict must be empty after stop()"
|
|
assert consumer.broadcast.is_closed, "broadcast must be closed after stop()"
|
|
|
|
|
|
async def test_restart_after_stop(
|
|
service: StreamService,
|
|
stream_reader: StreamReader,
|
|
multiplexer: StreamMultiplexer,
|
|
handler: SimpleEventHandler,
|
|
) -> None:
|
|
cid = await service.create_stream("test")
|
|
await service.push_event(cid, json.dumps({"n": 1}))
|
|
await service.update_stream_status(cid, StreamStatus.COMPLETED)
|
|
|
|
await multiplexer.start()
|
|
await multiplexer.add_consumer(cid, handler)
|
|
await multiplexer.stop()
|
|
|
|
# Second stream on a fresh cid after restart.
|
|
cid2 = await service.create_stream("test")
|
|
await service.push_event(cid2, json.dumps({"n": 42}))
|
|
await service.update_stream_status(cid2, StreamStatus.COMPLETED)
|
|
|
|
await multiplexer.start()
|
|
try:
|
|
consumer = await multiplexer.add_consumer(cid2, handler)
|
|
received = await collect_from_consumer(consumer, timeout=10.0)
|
|
finally:
|
|
await multiplexer.stop()
|
|
|
|
assert [e.n for e in received] == [42] # type: ignore[attr-defined]
|
|
|
|
|
|
async def test_stop_idempotent(
|
|
multiplexer: StreamMultiplexer,
|
|
) -> None:
|
|
await multiplexer.start()
|
|
await multiplexer.stop()
|
|
await multiplexer.stop() # must not raise
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
# 5. Performance — worker does not busy-poll
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
async def test_no_busy_poll(
|
|
service: StreamService,
|
|
stream_reader: StreamReader,
|
|
multiplexer: StreamMultiplexer,
|
|
handler: SimpleEventHandler,
|
|
) -> None:
|
|
"""With block_ms=DEFAULT_BLOCK_MS the worker parks on the backend; it must
|
|
not spin. In 300 ms we expect at most one read call."""
|
|
cid = await service.create_stream("test")
|
|
|
|
read_count = 0
|
|
original_read = stream_reader.read_events
|
|
|
|
async def counting_read(
|
|
event_handler: Any,
|
|
correlation_id: str,
|
|
last_id: str = "0",
|
|
count: int | None = None,
|
|
block_ms: int | None = None,
|
|
) -> tuple[list[BaseModel], str]:
|
|
nonlocal read_count
|
|
read_count += 1
|
|
return await original_read(
|
|
event_handler=event_handler,
|
|
correlation_id=correlation_id,
|
|
last_id=last_id,
|
|
count=count,
|
|
block_ms=block_ms,
|
|
)
|
|
|
|
stream_reader.read_events = counting_read # type: ignore[method-assign]
|
|
|
|
await multiplexer.start()
|
|
await multiplexer.add_consumer(cid, handler)
|
|
await asyncio.sleep(0.3)
|
|
await multiplexer.stop()
|
|
stream_reader.read_events = original_read # type: ignore[method-assign]
|
|
|
|
assert read_count <= 1, (
|
|
f"Worker called read_events {read_count} times in 300 ms "
|
|
f"(expected ≤1 with block_ms={DEFAULT_BLOCK_MS})."
|
|
)
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
# 6. Backend block semantics — block_ms=None is non-blocking
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
async def test_block_ms_none_is_non_blocking(
|
|
service: StreamService,
|
|
stream_reader: StreamReader,
|
|
handler: SimpleEventHandler,
|
|
) -> None:
|
|
"""block_ms=None means non-blocking on both backends. This is what the
|
|
direct path's final drain relies on, and it must never hang."""
|
|
cid = await service.create_stream("test")
|
|
await service.push_event(cid, json.dumps({"n": 1}))
|
|
e1_id = await service.push_event(cid, json.dumps({"n": 2}))
|
|
|
|
# Read past e1; no events remain.
|
|
events, _ = await asyncio.wait_for(
|
|
stream_reader.read_events(handler, cid, e1_id, None, None),
|
|
timeout=2.0,
|
|
)
|
|
assert events == []
|