from datetime import timedelta from pathlib import Path import pytest from fastapi import HTTPException from sqlalchemy import create_engine, select, update from sqlalchemy.orm import sessionmaker from app.application.events import EventDispatchService from app.core.utils.time import utc_now from app.modules.audit.constants import AuditAction from app.modules.audit.models import AuditLog from app.modules.events.constants import EventStatus from app.modules.events.models import DomainEvent from app.modules.events.services import EventService def test_stale_worker_is_fenced_after_expired_lease_is_reclaimed( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: engine = create_engine(f"sqlite:///{tmp_path / 'event-fencing.db'}") with engine.begin() as connection: connection.exec_driver_sql("PRAGMA journal_mode=WAL") DomainEvent.__table__.create(engine) AuditLog.__table__.create(engine) factory = sessionmaker(bind=engine, expire_on_commit=False) try: with factory() as db: event = EventService(db).emit( event_type="test.fencing", source="pytest", aggregate_type="test", aggregate_id="fencing", idempotency_key="test-event-fencing", ) event_id = event.event_id second_result: dict[str, DomainEvent] = {} with factory() as first_db: first_worker = EventDispatchService(first_db) def fail_after_lease_is_reclaimed(record: DomainEvent) -> None: first_db.add( AuditLog( actor="worker-one", action="stale-worker-side-effect", target_id=record.event_id, ) ) with factory() as second_db: second_db.execute( update(DomainEvent) .where(DomainEvent.event_id == record.event_id) .values(locked_until=utc_now() - timedelta(seconds=1)) ) second_db.commit() second_worker = EventDispatchService(second_db) monkeypatch.setattr( second_worker, "_handle_event", lambda claimed: None, ) second_result["event"] = second_worker.dispatch_event( record.event_id, worker_id="worker-two", ) raise RuntimeError("worker one failed after losing its lease") monkeypatch.setattr( first_worker, "_handle_event", fail_after_lease_is_reclaimed, ) with pytest.raises(HTTPException) as exc_info: first_worker.dispatch_event(event_id, worker_id="worker-one") assert exc_info.value.status_code == 409 assert second_result["event"].status == EventStatus.PROCESSED with factory() as db: stored = db.execute( select(DomainEvent).where(DomainEvent.event_id == event_id) ).scalar_one() audit_actions = list( db.execute( select(AuditLog.action).order_by(AuditLog.id.asc()) ).scalars() ) assert stored.status == EventStatus.PROCESSED assert stored.attempts == 2 assert stored.last_error is None assert stored.locked_by is None assert stored.locked_until is None assert audit_actions == [AuditAction.EVENT_DISPATCH] assert "stale-worker-side-effect" not in audit_actions finally: engine.dispose()