Files
DocsGPT/tests/api/answer/test_stream_heartbeat_ticker.py
T
Alex 574f96341e refactor: rename the application package to docsgpt
The backend import package is now docsgpt, the name it will carry on PyPI;
application was far too generic to install into anyone's site-packages.
git mv plus a mechanical rewrite of every import, dotted string and path
reference: 734 Python files, the compose files, Dockerfile, workflows, docs,
setup scripts, devcontainer, k8s manifests, vscode config, pytest and coverage
config, .gitignore. Behaviour is unchanged.

Kept for one release:
- A top-level application package whose meta-path finder resolves
  application.x.y to the already-imported docsgpt.x.y object, so old imports
  and entry points (celery -A application.app.celery,
  uvicorn application.asgi:asgi_app) keep working with a FutureWarning.
- Celery registers every application.* task name as an alias of its
  docsgpt.* task on start-up, so messages queued by the previous release still
  run. The redbeat key prefix moves to redbeat:docsgpt:v2: so schedule entries
  the previous release wrote are left unread instead of firing twice.

The backend image builds from the repository root (docker build -f
docsgpt/Dockerfile .) so it can ship the alias package; a root .dockerignore
allow-lists docsgpt/ and application/ and keeps caches, local data, .env
files, the sample index files and the Dockerfile out. Compose and the image
workflows point at the new context.
2026-09-07 10:20:43 +01:00

284 lines
10 KiB
Python
Raw 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.
"""The stream liveness heartbeat must be time-based, not output-based —
and it is also how a superseded stream learns to stop.
Regression cover for two production failures:
- a 20-minute agent tool loop force-failed by the reconciler at minute 6,
because the old heartbeat only stamped when a chunk flowed and a tool call
emits nothing while it runs;
- a stream whose row was deleted by the user's retry running four further
minutes and twelve further LLM rounds into a void.
"""
import time
from unittest.mock import MagicMock, patch
import pytest
from docsgpt.storage.db.repositories.conversations import HeartbeatState
def _reservation(message_id="11111111-1111-1111-1111-111111111111"):
return {"conversation_id": "22222222-2222-2222-2222-222222222222",
"message_id": message_id}
def _silent_then_answer(silence_seconds):
"""A generator that emits nothing for a while, then answers.
Models the real silent windows: a provider round emitting only tool-call
deltas, or the body of a ``read_webpage``/``code_executor`` call.
"""
def _gen(*args, **kwargs):
time.sleep(silence_seconds)
yield {"answer": "done"}
return _gen
def _chatty(chunks=200, gap=0.02):
"""A long multi-round stream that keeps emitting."""
def _gen(*args, **kwargs):
for i in range(chunks):
time.sleep(gap)
yield {"answer": f"chunk{i}"}
return _gen
def _service(state=HeartbeatState.STAMPED):
service = MagicMock()
service.save_user_question.return_value = _reservation()
service.heartbeat_message.return_value = True
service.heartbeat_message_state.return_value = state
return service
@pytest.mark.unit
class TestHeartbeatTicker:
def _run(self, flask_app, gen, service=None, interval=0.05):
from docsgpt.api.answer.routes import base as base_mod
from docsgpt.api.answer.routes.base import BaseAnswerResource
with flask_app.app_context():
resource = BaseAnswerResource()
resource.conversation_service = service or _service()
agent = MagicMock()
agent.gen.side_effect = gen
with patch.object(base_mod, "STREAM_HEARTBEAT_INTERVAL", interval):
out = list(
resource.complete_stream(
question="q",
agent=agent,
conversation_id=None,
user_api_key=None,
decoded_token={"sub": "u"},
should_persist=True,
)
)
return resource.conversation_service, out
def test_heartbeats_during_a_fully_silent_stream(
self, mock_mongo_db, flask_app
):
"""No chunks flow for 0.4 s; the ticker must still stamp."""
service, _ = self._run(flask_app, _silent_then_answer(0.4))
assert service.heartbeat_message_state.call_count > 2
def test_ticker_stops_when_the_stream_ends(self, mock_mongo_db, flask_app):
service, _ = self._run(flask_app, _silent_then_answer(0.1))
settled = service.heartbeat_message_state.call_count
time.sleep(0.3)
assert service.heartbeat_message_state.call_count == settled
def test_ticker_stops_on_client_abort(self, mock_mongo_db, flask_app):
from docsgpt.api.answer.routes import base as base_mod
from docsgpt.api.answer.routes.base import BaseAnswerResource
with flask_app.app_context():
resource = BaseAnswerResource()
service = _service()
resource.conversation_service = service
agent = MagicMock()
agent.gen.side_effect = _chatty()
with patch.object(base_mod, "STREAM_HEARTBEAT_INTERVAL", 0.05):
gen = resource.complete_stream(
question="q",
agent=agent,
conversation_id=None,
user_api_key=None,
decoded_token={"sub": "u"},
should_persist=True,
)
next(gen)
next(gen)
time.sleep(0.15)
gen.close()
settled = service.heartbeat_message_state.call_count
time.sleep(0.3)
assert service.heartbeat_message_state.call_count == settled
def test_no_ticker_without_a_reserved_row(self, mock_mongo_db, flask_app):
"""Headless/continuation rounds have no row to stamp."""
from docsgpt.api.answer.routes import base as base_mod
from docsgpt.api.answer.routes.base import BaseAnswerResource
with flask_app.app_context():
resource = BaseAnswerResource()
service = _service()
resource.conversation_service = service
agent = MagicMock()
agent.gen.side_effect = _silent_then_answer(0.2)
with patch.object(base_mod, "STREAM_HEARTBEAT_INTERVAL", 0.05):
list(
resource.complete_stream(
question="q",
agent=agent,
conversation_id=None,
user_api_key=None,
decoded_token={"sub": "u"},
should_persist=False,
)
)
service.heartbeat_message_state.assert_not_called()
def test_ticker_stops_when_row_goes_terminal(self, mock_mongo_db, flask_app):
"""A terminal row stops the ticker but must NOT cancel the stream."""
service = _service(HeartbeatState.TERMINAL)
service, out = self._run(
flask_app, _silent_then_answer(0.6), service=service,
)
# The ticker bailed on its first TERMINAL rather than stamping ~12
# times over 0.6 s of silence...
assert service.heartbeat_message_state.call_count == 1
# ...and the stream still finished and finalized, so a
# reconciler-swept row can still be reclaimed.
assert any('"type": "end"' in chunk for chunk in out)
service.finalize_message.assert_called_once()
@pytest.mark.unit
class TestSupersededStreamCancellation:
"""A deleted row must stop the work, not just quiet the logs."""
def _run_with_missing_row(self, flask_app, gen, interval=0.05):
from docsgpt.api.answer.routes import base as base_mod
from docsgpt.api.answer.routes.base import BaseAnswerResource
with flask_app.app_context():
resource = BaseAnswerResource()
service = _service(HeartbeatState.MISSING)
resource.conversation_service = service
agent = MagicMock()
agent.gen.side_effect = gen
with patch.object(base_mod, "STREAM_HEARTBEAT_INTERVAL", interval):
out = list(
resource.complete_stream(
question="q",
agent=agent,
conversation_id=None,
user_api_key=None,
decoded_token={"sub": "u"},
should_persist=True,
)
)
return service, out
def test_stream_stops_early_when_its_row_is_deleted(
self, mock_mongo_db, flask_app
):
"""The generator must not be drained to completion."""
service, out = self._run_with_missing_row(flask_app, _chatty(chunks=400))
# 400 chunks × 0.02 s ≈ 8 s if drained; cancellation lands far sooner.
assert len(out) < 200, f"stream was not cancelled early ({len(out)})"
def test_superseded_stream_does_not_persist(self, mock_mongo_db, flask_app):
"""Nothing to write — the row is gone. No finalize, no save."""
service, _ = self._run_with_missing_row(flask_app, _chatty(chunks=400))
service.finalize_message.assert_not_called()
service.save_conversation.assert_not_called()
def test_superseded_stream_emits_no_error_to_the_client(
self, mock_mongo_db, flask_app
):
"""It is not a failure: the user replaced this turn deliberately."""
_, out = self._run_with_missing_row(flask_app, _chatty(chunks=400))
assert not any('"type": "error"' in chunk for chunk in out)
def test_live_row_is_never_cancelled(self, mock_mongo_db, flask_app):
"""The common case must be untouched."""
from docsgpt.api.answer.routes import base as base_mod
from docsgpt.api.answer.routes.base import BaseAnswerResource
with flask_app.app_context():
resource = BaseAnswerResource()
service = _service(HeartbeatState.STAMPED)
resource.conversation_service = service
agent = MagicMock()
agent.gen.side_effect = _chatty(chunks=30)
with patch.object(base_mod, "STREAM_HEARTBEAT_INTERVAL", 0.05):
out = list(
resource.complete_stream(
question="q",
agent=agent,
conversation_id=None,
user_api_key=None,
decoded_token={"sub": "u"},
should_persist=True,
)
)
assert any('"type": "end"' in chunk for chunk in out)
service.finalize_message.assert_called_once()
def test_db_error_does_not_cancel_the_stream(self, mock_mongo_db, flask_app):
"""A transient blip must never be mistaken for a deleted row."""
from docsgpt.api.answer.routes import base as base_mod
from docsgpt.api.answer.routes.base import BaseAnswerResource
with flask_app.app_context():
resource = BaseAnswerResource()
service = _service()
service.heartbeat_message_state.side_effect = RuntimeError("pool")
resource.conversation_service = service
agent = MagicMock()
agent.gen.side_effect = _chatty(chunks=30)
with patch.object(base_mod, "STREAM_HEARTBEAT_INTERVAL", 0.05):
out = list(
resource.complete_stream(
question="q",
agent=agent,
conversation_id=None,
user_api_key=None,
decoded_token={"sub": "u"},
should_persist=True,
)
)
assert any('"type": "end"' in chunk for chunk in out)
service.finalize_message.assert_called_once()