mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 03:01:21 +02:00
feat(db): warn when the connection pool can outgrow the PostgreSQL server fix(mqtt): an unusable layer_num must not drop the printer connection test: patch settings.base_dir via monkeypatch so it unwinds on error test: restore the config module after reloading it
145 lines
4.8 KiB
Python
145 lines
4.8 KiB
Python
"""A dispatch claim must not survive the dispatch that held it (#2615, #2702).
|
|
|
|
``dispatching_at`` holds a queue row out of the selection query for the duration
|
|
of an upload. Clearing it is best-effort, and the observed failure was narrow:
|
|
PostgreSQL refused a connection for a second or two at exactly the moment
|
|
dispatch ended, the single clear attempt failed, and the row stayed invisible to
|
|
the scheduler until the process restarted.
|
|
|
|
Two independent recoveries, tested here: the clear retries, and a later tick
|
|
releases any claim with no dispatch behind it.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
|
|
@pytest.fixture
|
|
def scheduler():
|
|
from backend.app.services.print_scheduler import PrintScheduler
|
|
|
|
return PrintScheduler()
|
|
|
|
|
|
def _session(fail_times: int) -> MagicMock:
|
|
"""A session whose execute() fails `fail_times` times, then succeeds."""
|
|
db = MagicMock()
|
|
calls = {"n": 0}
|
|
|
|
async def execute(*_a, **_k):
|
|
calls["n"] += 1
|
|
if calls["n"] <= fail_times:
|
|
raise RuntimeError("remaining connection slots are reserved for roles with the SUPERUSER attribute")
|
|
return MagicMock(rowcount=1)
|
|
|
|
db.execute = AsyncMock(side_effect=execute)
|
|
db.commit = AsyncMock()
|
|
db.rollback = AsyncMock()
|
|
db._calls = calls
|
|
return db
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# The retry
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.unit
|
|
async def test_a_transient_failure_is_retried_and_the_claim_clears(scheduler):
|
|
"""The reported case: one failed attempt used to wedge the row."""
|
|
db = _session(fail_times=1)
|
|
|
|
with patch("backend.app.services.print_scheduler.asyncio.sleep", new=AsyncMock()):
|
|
await scheduler._clear_dispatch_claim(db, 597)
|
|
|
|
assert db._calls["n"] == 2
|
|
assert db.commit.await_count == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.unit
|
|
async def test_the_session_is_rolled_back_between_attempts(scheduler):
|
|
"""A failed write leaves the session needing a rollback before reuse."""
|
|
db = _session(fail_times=1)
|
|
|
|
with patch("backend.app.services.print_scheduler.asyncio.sleep", new=AsyncMock()):
|
|
await scheduler._clear_dispatch_claim(db, 597)
|
|
|
|
assert db.rollback.await_count == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.unit
|
|
async def test_retries_are_bounded_and_never_raise(scheduler):
|
|
"""Dispatch's outcome must not be masked by this cleanup failing."""
|
|
db = _session(fail_times=99)
|
|
|
|
with patch("backend.app.services.print_scheduler.asyncio.sleep", new=AsyncMock()):
|
|
await scheduler._clear_dispatch_claim(db, 597) # must not raise
|
|
|
|
assert db._calls["n"] == 3
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.unit
|
|
async def test_no_retry_when_the_first_attempt_works(scheduler):
|
|
"""The happy path must not pay for the retry."""
|
|
db = _session(fail_times=0)
|
|
|
|
await scheduler._clear_dispatch_claim(db, 597)
|
|
|
|
assert db._calls["n"] == 1
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# The quiet-tick sweep
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.unit
|
|
async def test_the_sweep_does_nothing_while_an_upload_is_in_flight(scheduler):
|
|
"""An in-flight dispatch owns its claim — clearing it would let a second
|
|
dispatch pick up the same row mid-upload, which is what #2615 prevents."""
|
|
scheduler._inflight[597] = (MagicMock(), 1)
|
|
|
|
with patch("backend.app.services.print_scheduler.async_session") as sess:
|
|
await scheduler._clear_stale_dispatch_claims()
|
|
|
|
sess.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.unit
|
|
async def test_the_sweep_releases_a_claim_with_nothing_in_flight(scheduler):
|
|
"""`_inflight` is populated before the coroutine claims its row, and pruned
|
|
after its `finally` — so "claim present, nothing in flight" is orphaned."""
|
|
db = MagicMock()
|
|
db.execute = AsyncMock(return_value=MagicMock(rowcount=1))
|
|
db.commit = AsyncMock()
|
|
ctx = MagicMock()
|
|
ctx.__aenter__ = AsyncMock(return_value=db)
|
|
ctx.__aexit__ = AsyncMock(return_value=False)
|
|
|
|
with patch("backend.app.services.print_scheduler.async_session", return_value=ctx):
|
|
await scheduler._clear_stale_dispatch_claims()
|
|
|
|
assert db.execute.await_count == 1
|
|
assert db.commit.await_count == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.unit
|
|
async def test_the_sweep_survives_a_database_that_is_still_down(scheduler):
|
|
"""It runs every tick; a failure must not break the scheduler loop."""
|
|
ctx = MagicMock()
|
|
ctx.__aenter__ = AsyncMock(side_effect=RuntimeError("still refusing connections"))
|
|
ctx.__aexit__ = AsyncMock(return_value=False)
|
|
|
|
with patch("backend.app.services.print_scheduler.async_session", return_value=ctx):
|
|
await scheduler._clear_stale_dispatch_claims() # must not raise
|