Release v4928.1.4.2 stable
This commit is contained in:
592
app/integration_outbox_service.py
Normal file
592
app/integration_outbox_service.py
Normal file
@@ -0,0 +1,592 @@
|
||||
import json
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.db import engine
|
||||
|
||||
|
||||
def _json(value: Any) -> str:
|
||||
return json.dumps(value or {}, ensure_ascii=False)
|
||||
|
||||
|
||||
def create_outbox_item(
|
||||
*,
|
||||
business_event_id: str,
|
||||
target_system: str,
|
||||
action_type: str,
|
||||
payload: Dict[str, Any],
|
||||
idempotency_key: str,
|
||||
) -> Optional[str]:
|
||||
sql = text("""
|
||||
INSERT INTO integration_outbox (
|
||||
business_event_id,
|
||||
target_system,
|
||||
action_type,
|
||||
payload,
|
||||
status,
|
||||
retry_count,
|
||||
idempotency_key
|
||||
)
|
||||
VALUES (
|
||||
CAST(:business_event_id AS UUID),
|
||||
:target_system,
|
||||
:action_type,
|
||||
CAST(:payload AS JSONB),
|
||||
'pending',
|
||||
0,
|
||||
:idempotency_key
|
||||
)
|
||||
ON CONFLICT (idempotency_key) DO NOTHING
|
||||
RETURNING id::text
|
||||
""")
|
||||
|
||||
with engine.begin() as conn:
|
||||
row = conn.execute(sql, {
|
||||
"business_event_id": business_event_id,
|
||||
"target_system": target_system,
|
||||
"action_type": action_type,
|
||||
"payload": _json(payload),
|
||||
"idempotency_key": idempotency_key,
|
||||
}).fetchone()
|
||||
|
||||
return row[0] if row else None
|
||||
|
||||
|
||||
def build_outbox_specs(
|
||||
*,
|
||||
business_event_id: str,
|
||||
event_type: str,
|
||||
task: Dict[str, Any],
|
||||
payload: Dict[str, Any],
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Constrói ações de outbox para integrações externas ativas.
|
||||
|
||||
A integração CRM externa antiga foi removida. O pipeline comercial passa a viver no
|
||||
ClientFlow: oportunidades, produtos, financeiro e encomendas são entidades
|
||||
internas. Esta função já não cria novos itens para o CRM externo antigo.
|
||||
"""
|
||||
base_payload = {
|
||||
"business_event_id": business_event_id,
|
||||
"event_type": event_type,
|
||||
"task_id": task.get("id"),
|
||||
"conversation_id": task.get("conversation_id"),
|
||||
"contact_id": task.get("contact_id"),
|
||||
"action_code": task.get("action_code"),
|
||||
"route": task.get("route"),
|
||||
"action": task.get("action"),
|
||||
"note": task.get("note"),
|
||||
"event_payload": payload or {},
|
||||
}
|
||||
|
||||
if event_type == "invoice_sent":
|
||||
return [
|
||||
{
|
||||
"target_system": "chatwoot",
|
||||
"action_type": "add_private_note",
|
||||
"payload": {**base_payload, "note": "Fatura marcada como enviada no ClientFlow."},
|
||||
},
|
||||
{
|
||||
"target_system": "mautic",
|
||||
"action_type": "add_tag",
|
||||
"payload": {**base_payload, "tag": "invoice_sent"},
|
||||
},
|
||||
]
|
||||
|
||||
if event_type == "payment_confirmed":
|
||||
return [
|
||||
{
|
||||
"target_system": "mautic",
|
||||
"action_type": "add_tag",
|
||||
"payload": {**base_payload, "tag": "payment_confirmed"},
|
||||
},
|
||||
{
|
||||
"target_system": "mautic",
|
||||
"action_type": "remove_tag",
|
||||
"payload": {**base_payload, "tag": "proforma_unpaid"},
|
||||
},
|
||||
]
|
||||
|
||||
if event_type == "order_prepared":
|
||||
return [
|
||||
{
|
||||
"target_system": "chatwoot",
|
||||
"action_type": "add_private_note",
|
||||
"payload": {**base_payload, "note": "Encomenda preparada no ClientFlow."},
|
||||
},
|
||||
]
|
||||
|
||||
if event_type == "shipment_validated":
|
||||
return [
|
||||
{
|
||||
"target_system": "chatwoot",
|
||||
"action_type": "add_private_note",
|
||||
"payload": {**base_payload, "note": "Envio validado no ClientFlow."},
|
||||
},
|
||||
{
|
||||
"target_system": "mautic",
|
||||
"action_type": "add_tag",
|
||||
"payload": {**base_payload, "tag": "shipment_validated"},
|
||||
},
|
||||
]
|
||||
|
||||
return []
|
||||
|
||||
def create_outbox_for_business_event(
|
||||
*,
|
||||
business_event_id: str,
|
||||
event_type: str,
|
||||
task: Dict[str, Any],
|
||||
payload: Optional[Dict[str, Any]] = None,
|
||||
) -> List[str]:
|
||||
specs = build_outbox_specs(
|
||||
business_event_id=business_event_id,
|
||||
event_type=event_type,
|
||||
task=task,
|
||||
payload=payload or {},
|
||||
)
|
||||
|
||||
created_ids: List[str] = []
|
||||
|
||||
for spec in specs:
|
||||
idempotency_key = ":".join([
|
||||
"outbox",
|
||||
business_event_id,
|
||||
spec["target_system"],
|
||||
spec["action_type"],
|
||||
])
|
||||
|
||||
outbox_id = create_outbox_item(
|
||||
business_event_id=business_event_id,
|
||||
target_system=spec["target_system"],
|
||||
action_type=spec["action_type"],
|
||||
payload=spec["payload"],
|
||||
idempotency_key=idempotency_key,
|
||||
)
|
||||
|
||||
if outbox_id:
|
||||
created_ids.append(outbox_id)
|
||||
|
||||
return created_ids
|
||||
|
||||
|
||||
def list_outbox(
|
||||
*,
|
||||
status: Optional[str] = None,
|
||||
target_system: Optional[str] = None,
|
||||
limit: int = 100,
|
||||
) -> List[Dict[str, Any]]:
|
||||
where = []
|
||||
params: Dict[str, Any] = {"limit": limit}
|
||||
|
||||
if status:
|
||||
where.append("status = :status")
|
||||
params["status"] = status
|
||||
|
||||
if target_system:
|
||||
where.append("target_system = :target_system")
|
||||
params["target_system"] = target_system
|
||||
|
||||
where_sql = ""
|
||||
if where:
|
||||
where_sql = "WHERE " + " AND ".join(where)
|
||||
|
||||
sql = text(f"""
|
||||
SELECT
|
||||
id::text,
|
||||
business_event_id::text,
|
||||
target_system,
|
||||
action_type,
|
||||
payload,
|
||||
status,
|
||||
retry_count,
|
||||
idempotency_key,
|
||||
last_error,
|
||||
created_at,
|
||||
updated_at,
|
||||
sent_at,
|
||||
locked_at,
|
||||
lock_owner,
|
||||
ignored_at
|
||||
FROM integration_outbox
|
||||
{where_sql}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT :limit
|
||||
""")
|
||||
|
||||
with engine.begin() as conn:
|
||||
rows = conn.execute(sql, params).mappings().all()
|
||||
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
|
||||
def get_outbox_item(outbox_id: str) -> Optional[Dict[str, Any]]:
|
||||
sql = text("""
|
||||
SELECT
|
||||
id::text,
|
||||
business_event_id::text,
|
||||
target_system,
|
||||
action_type,
|
||||
payload,
|
||||
status,
|
||||
retry_count,
|
||||
idempotency_key,
|
||||
last_error,
|
||||
created_at,
|
||||
updated_at,
|
||||
sent_at,
|
||||
locked_at,
|
||||
lock_owner,
|
||||
ignored_at
|
||||
FROM integration_outbox
|
||||
WHERE id = CAST(:outbox_id AS UUID)
|
||||
LIMIT 1
|
||||
""")
|
||||
|
||||
with engine.begin() as conn:
|
||||
row = conn.execute(sql, {"outbox_id": outbox_id}).mappings().first()
|
||||
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def list_pending_outbox(
|
||||
limit: int = 50,
|
||||
target_system: Optional[str] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
return list_outbox(
|
||||
status="pending",
|
||||
target_system=target_system,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
|
||||
def claim_pending_outbox(
|
||||
*,
|
||||
limit: int = 50,
|
||||
target_system: Optional[str] = None,
|
||||
lock_owner: str = "worker",
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Claim pending outbox rows atomically for one worker.
|
||||
|
||||
This prevents two systemd timers/workers from processing the same pending
|
||||
integration item at the same time. PostgreSQL SKIP LOCKED lets concurrent
|
||||
workers take different rows without blocking each other.
|
||||
"""
|
||||
sql = text("""
|
||||
WITH picked AS (
|
||||
SELECT id
|
||||
FROM integration_outbox
|
||||
WHERE status = 'pending'
|
||||
AND (:target_system IS NULL OR target_system = :target_system)
|
||||
ORDER BY created_at ASC
|
||||
FOR UPDATE SKIP LOCKED
|
||||
LIMIT :limit
|
||||
)
|
||||
UPDATE integration_outbox io
|
||||
SET
|
||||
status = 'processing',
|
||||
locked_at = now(),
|
||||
lock_owner = :lock_owner,
|
||||
updated_at = now(),
|
||||
last_error = NULL
|
||||
FROM picked
|
||||
WHERE io.id = picked.id
|
||||
RETURNING
|
||||
io.id::text,
|
||||
io.business_event_id::text,
|
||||
io.target_system,
|
||||
io.action_type,
|
||||
io.payload,
|
||||
io.status,
|
||||
io.retry_count,
|
||||
io.idempotency_key,
|
||||
io.last_error,
|
||||
io.created_at,
|
||||
io.updated_at,
|
||||
io.sent_at,
|
||||
io.locked_at,
|
||||
io.lock_owner
|
||||
""")
|
||||
with engine.begin() as conn:
|
||||
rows = conn.execute(sql, {
|
||||
"limit": int(limit),
|
||||
"target_system": target_system,
|
||||
"lock_owner": str(lock_owner or "worker")[:120],
|
||||
}).mappings().all()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
|
||||
|
||||
def outbox_stale_minutes() -> int:
|
||||
"""Configured threshold for stuck processing rows."""
|
||||
raw = os.getenv("OUTBOX_STALE_PROCESSING_MINUTES", "30").strip()
|
||||
try:
|
||||
return max(1, int(raw))
|
||||
except ValueError:
|
||||
return 30
|
||||
|
||||
|
||||
def recover_stale_processing_outbox(
|
||||
*,
|
||||
stale_minutes: Optional[int] = None,
|
||||
mode: Optional[str] = None,
|
||||
limit: int = 100,
|
||||
actor: str = "system",
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Recover or expose outbox items left in processing too long.
|
||||
|
||||
Modes:
|
||||
- manual_only: mark rows as ``stale`` so the operator can decide;
|
||||
- mark_failed: mark rows as ``failed`` with a stale-processing reason;
|
||||
- retry_pending: return rows to ``pending`` so the worker retries them.
|
||||
"""
|
||||
stale_minutes = int(stale_minutes or outbox_stale_minutes())
|
||||
mode = str(mode or os.getenv("OUTBOX_STALE_RECOVERY_MODE", "manual_only")).strip().lower()
|
||||
if mode not in {"manual_only", "mark_failed", "retry_pending"}:
|
||||
mode = "manual_only"
|
||||
|
||||
if mode == "retry_pending":
|
||||
new_status = "pending"
|
||||
retry_sql = "retry_count = retry_count + 1,"
|
||||
error = f"Processing stale há mais de {stale_minutes} minutos; reposto para pending por {actor}."
|
||||
elif mode == "mark_failed":
|
||||
new_status = "failed"
|
||||
retry_sql = "retry_count = retry_count + 1,"
|
||||
error = f"Processing stale há mais de {stale_minutes} minutos; marcado failed por {actor}."
|
||||
else:
|
||||
new_status = "stale"
|
||||
retry_sql = ""
|
||||
error = f"Processing stale há mais de {stale_minutes} minutos; requer revisão manual."
|
||||
|
||||
sql = text(f"""
|
||||
WITH picked AS (
|
||||
SELECT id
|
||||
FROM integration_outbox
|
||||
WHERE status = 'processing'
|
||||
AND locked_at IS NOT NULL
|
||||
AND locked_at < now() - (:stale_minutes * interval '1 minute')
|
||||
ORDER BY locked_at ASC
|
||||
FOR UPDATE SKIP LOCKED
|
||||
LIMIT :limit
|
||||
)
|
||||
UPDATE integration_outbox io
|
||||
SET
|
||||
status = :new_status,
|
||||
{retry_sql}
|
||||
locked_at = NULL,
|
||||
lock_owner = NULL,
|
||||
last_error = :error,
|
||||
updated_at = now()
|
||||
FROM picked
|
||||
WHERE io.id = picked.id
|
||||
RETURNING
|
||||
io.id::text,
|
||||
io.business_event_id::text,
|
||||
io.target_system,
|
||||
io.action_type,
|
||||
io.payload,
|
||||
io.status,
|
||||
io.retry_count,
|
||||
io.idempotency_key,
|
||||
io.last_error,
|
||||
io.created_at,
|
||||
io.updated_at,
|
||||
io.sent_at,
|
||||
io.locked_at,
|
||||
io.lock_owner,
|
||||
io.ignored_at
|
||||
""")
|
||||
|
||||
with engine.begin() as conn:
|
||||
rows = conn.execute(sql, {
|
||||
"stale_minutes": stale_minutes,
|
||||
"limit": int(limit),
|
||||
"new_status": new_status,
|
||||
"error": error[:2000],
|
||||
}).mappings().all()
|
||||
|
||||
recovered = [dict(row) for row in rows]
|
||||
if recovered:
|
||||
try:
|
||||
from app.operator_audit_service import record_operator_action_best_effort
|
||||
for item in recovered:
|
||||
record_operator_action_best_effort(
|
||||
action="outbox_stale_recovered",
|
||||
entity_type="outbox",
|
||||
entity_id=item.get("id"),
|
||||
actor=actor,
|
||||
payload={
|
||||
"mode": mode,
|
||||
"stale_minutes": stale_minutes,
|
||||
"new_status": item.get("status"),
|
||||
"target_system": item.get("target_system"),
|
||||
"action_type": item.get("action_type"),
|
||||
},
|
||||
)
|
||||
except Exception as exc:
|
||||
print(f"ClientFlow stale outbox audit failed: {exc}", flush=True)
|
||||
|
||||
return recovered
|
||||
|
||||
def mark_outbox_sent(outbox_id: str) -> None:
|
||||
with engine.begin() as conn:
|
||||
conn.execute(text("""
|
||||
UPDATE integration_outbox
|
||||
SET
|
||||
status = 'sent',
|
||||
sent_at = now(),
|
||||
locked_at = NULL,
|
||||
lock_owner = NULL,
|
||||
updated_at = now(),
|
||||
last_error = NULL
|
||||
WHERE id = CAST(:outbox_id AS UUID)
|
||||
"""), {"outbox_id": outbox_id})
|
||||
|
||||
|
||||
def mark_outbox_failed(outbox_id: str, error: str) -> None:
|
||||
with engine.begin() as conn:
|
||||
conn.execute(text("""
|
||||
UPDATE integration_outbox
|
||||
SET
|
||||
status = 'failed',
|
||||
retry_count = retry_count + 1,
|
||||
locked_at = NULL,
|
||||
lock_owner = NULL,
|
||||
last_error = :error,
|
||||
updated_at = now()
|
||||
WHERE id = CAST(:outbox_id AS UUID)
|
||||
"""), {
|
||||
"outbox_id": outbox_id,
|
||||
"error": error[:2000],
|
||||
})
|
||||
|
||||
|
||||
def set_outbox_status(
|
||||
*,
|
||||
outbox_id: str,
|
||||
status: str,
|
||||
error: str | None = None,
|
||||
) -> None:
|
||||
"""Atualiza o estado de um item da outbox com semântica operacional clara."""
|
||||
allowed = {"pending", "processing", "sent", "failed", "blocked", "dry_run", "ignored", "cancelled", "stale"}
|
||||
if status not in allowed:
|
||||
raise ValueError(f"Estado inválido: {status}")
|
||||
|
||||
status_defaults = {
|
||||
"failed": "Marcado manualmente como failed.",
|
||||
"blocked": "Bloqueado por configuração ou pré-condição.",
|
||||
"dry_run": "Validado em OUTBOX_DRY_RUN=true; nenhuma integração real foi executada.",
|
||||
"ignored": "Ignorado manualmente.",
|
||||
"cancelled": "Cancelado manualmente.",
|
||||
"stale": "Processing preso; requer revisão ou reprocessamento manual.",
|
||||
}
|
||||
|
||||
if status == "sent":
|
||||
sql = text("""
|
||||
UPDATE integration_outbox
|
||||
SET
|
||||
status = 'sent',
|
||||
sent_at = COALESCE(sent_at, now()),
|
||||
locked_at = NULL,
|
||||
lock_owner = NULL,
|
||||
last_error = NULL,
|
||||
updated_at = now()
|
||||
WHERE id = CAST(:outbox_id AS UUID)
|
||||
""")
|
||||
params = {"outbox_id": outbox_id}
|
||||
|
||||
elif status == "pending":
|
||||
sql = text("""
|
||||
UPDATE integration_outbox
|
||||
SET
|
||||
status = 'pending',
|
||||
sent_at = NULL,
|
||||
locked_at = NULL,
|
||||
lock_owner = NULL,
|
||||
ignored_at = NULL,
|
||||
last_error = NULL,
|
||||
updated_at = now()
|
||||
WHERE id = CAST(:outbox_id AS UUID)
|
||||
""")
|
||||
params = {"outbox_id": outbox_id}
|
||||
|
||||
elif status == "processing":
|
||||
sql = text("""
|
||||
UPDATE integration_outbox
|
||||
SET
|
||||
status = 'processing',
|
||||
locked_at = now(),
|
||||
lock_owner = COALESCE(:error, 'worker'),
|
||||
updated_at = now()
|
||||
WHERE id = CAST(:outbox_id AS UUID)
|
||||
""")
|
||||
params = {"outbox_id": outbox_id, "error": error}
|
||||
|
||||
else:
|
||||
ignored_at_sql = "ignored_at = now()," if status == "ignored" else "ignored_at = ignored_at,"
|
||||
retry_sql = "retry_count = retry_count + 1," if status == "failed" else ""
|
||||
sql = text(f"""
|
||||
UPDATE integration_outbox
|
||||
SET
|
||||
status = :status,
|
||||
{retry_sql}
|
||||
sent_at = NULL,
|
||||
locked_at = NULL,
|
||||
lock_owner = NULL,
|
||||
{ignored_at_sql}
|
||||
last_error = :error,
|
||||
updated_at = now()
|
||||
WHERE id = CAST(:outbox_id AS UUID)
|
||||
""")
|
||||
params = {
|
||||
"outbox_id": outbox_id,
|
||||
"status": status,
|
||||
"error": (error or status_defaults.get(status) or "Estado atualizado.")[:2000],
|
||||
}
|
||||
|
||||
with engine.begin() as conn:
|
||||
conn.execute(sql, params)
|
||||
|
||||
|
||||
def mark_outbox_dry_run(outbox_id: str, message: str | None = None) -> None:
|
||||
set_outbox_status(
|
||||
outbox_id=outbox_id,
|
||||
status="dry_run",
|
||||
error=message or "OUTBOX_DRY_RUN=true; ação não executada na integração externa.",
|
||||
)
|
||||
|
||||
|
||||
def mark_outbox_blocked(outbox_id: str, message: str | None = None) -> None:
|
||||
set_outbox_status(
|
||||
outbox_id=outbox_id,
|
||||
status="blocked",
|
||||
error=message or "Integração desativada ou configuração incompleta.",
|
||||
)
|
||||
|
||||
def get_outbox_item(outbox_id: str) -> Optional[Dict[str, Any]]:
|
||||
sql = text("""
|
||||
SELECT
|
||||
id::text,
|
||||
business_event_id::text,
|
||||
target_system,
|
||||
action_type,
|
||||
payload,
|
||||
status,
|
||||
retry_count,
|
||||
idempotency_key,
|
||||
last_error,
|
||||
created_at,
|
||||
updated_at,
|
||||
sent_at,
|
||||
locked_at,
|
||||
lock_owner,
|
||||
ignored_at
|
||||
FROM integration_outbox
|
||||
WHERE id = CAST(:outbox_id AS UUID)
|
||||
""")
|
||||
|
||||
with engine.begin() as conn:
|
||||
row = conn.execute(sql, {"outbox_id": outbox_id}).mappings().first()
|
||||
|
||||
return dict(row) if row else None
|
||||
Reference in New Issue
Block a user