🛡️ fix(concurrency): serializar chats e blindar conflitos de locacao
Serializa o processamento do Telegram por chat com workers dedicados e semaforo global, evitando que uma mensagem lenta bloqueie os demais atendimentos enquanto preserva a ordem dentro de cada conversa. Protege a abertura de locacao com row lock no veiculo e adiciona lock de slot para agendamento e remarcacao de revisao, reduzindo o risco de corrida em reservas simultaneas. Amplia a cobertura com testes para paralelismo no satellite do Telegram, lock da locacao e lock dos horarios de revisao.main
parent
c22672abda
commit
c8cff5fc3f
@ -0,0 +1,133 @@
|
||||
import unittest
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.db.mock_models import ReviewSchedule
|
||||
from app.services.domain import review_service
|
||||
|
||||
|
||||
class ReviewLockingQuery:
|
||||
def __init__(self, results=None):
|
||||
self.results = list(results or [])
|
||||
|
||||
def filter(self, *args, **kwargs):
|
||||
return self
|
||||
|
||||
def first(self):
|
||||
if self.results:
|
||||
return self.results.pop(0)
|
||||
return None
|
||||
|
||||
|
||||
class ReviewLockingSession:
|
||||
def __init__(self, *, query_results=None, lock_acquired=1):
|
||||
self.query_instance = ReviewLockingQuery(query_results)
|
||||
self.lock_acquired = lock_acquired
|
||||
self.execute_calls = []
|
||||
self.added = []
|
||||
self.committed = False
|
||||
self.closed = False
|
||||
self.refreshed = []
|
||||
|
||||
def query(self, model):
|
||||
if model is review_service.ReviewSchedule:
|
||||
return self.query_instance
|
||||
raise AssertionError(f"unexpected model query: {model}")
|
||||
|
||||
def execute(self, statement, params=None):
|
||||
sql_text = str(statement)
|
||||
self.execute_calls.append((sql_text, params))
|
||||
if "GET_LOCK" in sql_text:
|
||||
return SimpleNamespace(scalar=lambda: self.lock_acquired)
|
||||
if "RELEASE_LOCK" in sql_text:
|
||||
return SimpleNamespace(scalar=lambda: 1)
|
||||
raise AssertionError(f"unexpected execute call: {sql_text}")
|
||||
|
||||
def add(self, item):
|
||||
self.added.append(item)
|
||||
|
||||
def commit(self):
|
||||
self.committed = True
|
||||
|
||||
def refresh(self, item):
|
||||
self.refreshed.append(item)
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
|
||||
class ReviewServiceLockingTests(unittest.IsolatedAsyncioTestCase):
|
||||
def test_acquire_review_slot_lock_returns_conflict_when_slot_is_busy(self):
|
||||
session = ReviewLockingSession(lock_acquired=0)
|
||||
|
||||
with self.assertRaises(HTTPException) as ctx:
|
||||
review_service._acquire_review_slot_lock(
|
||||
session,
|
||||
requested_dt=datetime(2026, 3, 18, 9, 0),
|
||||
)
|
||||
|
||||
self.assertEqual(ctx.exception.status_code, 409)
|
||||
self.assertEqual(ctx.exception.detail["code"], "review_slot_busy")
|
||||
self.assertTrue(any("GET_LOCK" in sql for sql, _ in session.execute_calls))
|
||||
|
||||
async def test_agendar_revisao_uses_slot_lock_and_releases_after_success(self):
|
||||
session = ReviewLockingSession(query_results=[None, None])
|
||||
|
||||
with patch.object(review_service, "SessionMockLocal", return_value=session):
|
||||
result = await review_service.agendar_revisao(
|
||||
placa="ABC1234",
|
||||
data_hora="18/03/2026 09:00",
|
||||
modelo="Onix",
|
||||
ano=2022,
|
||||
km=15000,
|
||||
revisao_previa_concessionaria=False,
|
||||
user_id=7,
|
||||
)
|
||||
|
||||
self.assertTrue(any("GET_LOCK" in sql for sql, _ in session.execute_calls))
|
||||
self.assertTrue(any("RELEASE_LOCK" in sql for sql, _ in session.execute_calls))
|
||||
self.assertTrue(session.committed)
|
||||
self.assertEqual(len(session.added), 1)
|
||||
self.assertEqual(result["status"], "agendado")
|
||||
self.assertTrue(session.closed)
|
||||
|
||||
async def test_editar_data_revisao_releases_slot_lock_when_conflict_is_detected(self):
|
||||
current_schedule = ReviewSchedule(
|
||||
id=1,
|
||||
protocolo="REV-20260318-AAAA1111",
|
||||
user_id=7,
|
||||
placa="ABC1234",
|
||||
data_hora=datetime(2026, 3, 18, 9, 0),
|
||||
status="agendado",
|
||||
)
|
||||
conflicting_schedule = ReviewSchedule(
|
||||
id=2,
|
||||
protocolo="REV-20260319-BBBB2222",
|
||||
user_id=8,
|
||||
placa="XYZ9876",
|
||||
data_hora=datetime(2026, 3, 19, 10, 0),
|
||||
status="agendado",
|
||||
)
|
||||
session = ReviewLockingSession(query_results=[current_schedule, conflicting_schedule])
|
||||
|
||||
with patch.object(review_service, "SessionMockLocal", return_value=session):
|
||||
with self.assertRaises(HTTPException) as ctx:
|
||||
await review_service.editar_data_revisao(
|
||||
protocolo=current_schedule.protocolo,
|
||||
nova_data_hora="19/03/2026 10:00",
|
||||
user_id=7,
|
||||
)
|
||||
|
||||
self.assertTrue(any("GET_LOCK" in sql for sql, _ in session.execute_calls))
|
||||
self.assertTrue(any("RELEASE_LOCK" in sql for sql, _ in session.execute_calls))
|
||||
self.assertEqual(ctx.exception.status_code, 409)
|
||||
self.assertEqual(ctx.exception.detail["code"], "review_schedule_conflict")
|
||||
self.assertFalse(session.committed)
|
||||
self.assertTrue(session.closed)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loading…
Reference in New Issue