Skip to content

Ola 0 · Sebas 0.1: conectar el guard de aceptación única - #13

Open
heysebitas wants to merge 2 commits into
feat/o0-0.6-motivos-rechazofrom
feat/o0-0.1-aceptacion-unica
Open

heysebitas wants to merge 2 commits into
feat/o0-0.6-motivos-rechazofrom
feat/o0-0.1-aceptacion-unica

Conversation

@heysebitas

Copy link
Copy Markdown
Collaborator

Tarea 0.1 · va sobre #12, mergear ese primero.

El problema

RoutingStore.respond() ya tenía idempotencia por requestKey, verificaba accepted_destination y bajo Postgres usa pg_advisory_xact_lock + select … for update. Estaba bien hecho y no lo llamaba nadie.

La ruta que acepta de verdad es POST /handshake/respond, que solo miraba el estado de ese handshake y nunca preguntaba si otra sede ya aceptó el caso. Lo que tapaba el hueco era que el fan-out es secuencial. El día que alguien active fan-out paralelo —la optimización obvia— dos hospitales preparan cama para el mismo paciente.

Qué trae

  • HandshakeService inyecta RoutingService y reserva el destino antes de escribir la aceptación. requestKey = handshakeId + decisión, para que el doble toque siga siendo idempotente.
  • Si el guard rechaza: aplicada: false + codigo, el handshake no se toca (sigue enviado y vence solo) y la sede perdedora no suma una aceptación que nunca ocurrió a su P(aceptación).
  • RespondResponse.codigo (opcional, espejado): el caso que importa no se ve en handshake.estado. Telegram y /hospital ahora dicen "otra sede ya aceptó este caso · no prepare cama" en vez de "ya estaba enviado".

Dos decisiones que quiero que miren

  1. No llamo RoutingService.respond() directo. Exige evidencia de despacho para ese destino, y la sede que acepta casi nunca es la Init sdd config #1 del ranking (el fan-out toca varias, el vigilante re-rutea). Encadenar la reserva a esa precondición dejaba la carrera abierta justo cuando hay varias sedes tocadas. Agregué aceptarDestino(): mismo store.respond —el guard es uno solo, no dupliqué nada—, adjunta evidencia si existe. respond() sigue exigiéndola y ahora delega ahí.
  2. RoutingResponseCommand.evidence pasó a opcional. Sin evidencia se reserva igual pero no se escribe fila de auditoría: los checks de pulso_routing_decision_audit exigen modelVersion/configVersion y rellenarlos sería falsificar el acta. El hueco queda declarado en el log.

Hecho cuando

  • Dos sedes aceptan el mismo caso → la segunda recibe aplicada: false
  • El doble toque sigue siendo idempotente
  • El webhook de Telegram no dice "aceptado" cuando no se aplicó
  • Test de concurrencia real, no secuencial

Verificado que el test detecta la ausencia del guard: desconectándolo fallan 4 de 14 tests de handshake; conectado pasan los 14. El de concurrencia pone dos procesarRespuesta en vuelo a la vez con frontera asíncrona en el store, así que ambas pasan el chequeo de estado antes de que ninguna reserve. La concurrencia entre procesos ya la cubre postgres-routing.store.spec.ts, que necesita PULSO_TEST_DATABASE_URL — se enciende en 1.7.

🤖 Generated with Claude Code

heysebitas and others added 2 commits August 22, 2026 20:04
`RoutingStore.respond()` ya tenia idempotencia por requestKey, verificaba
`accepted_destination` y bajo Postgres usa pg_advisory_xact_lock + select
for update. Estaba bien hecho y no lo llamaba nadie: la ruta que acepta de
verdad es POST /handshake/respond, que solo miraba el estado de ESE
handshake y nunca preguntaba si otra sede ya acepto el caso.

Lo que tapaba el hueco era que el fan-out es secuencial. El dia que alguien
active fan-out paralelo —la optimizacion obvia— dos hospitales preparan
cama para el mismo paciente.

  - HandshakeService inyecta RoutingService y reserva el destino ANTES de
    escribir la aceptacion. requestKey = handshakeId + decision, para que el
    doble toque siga siendo idempotente.
  - Si el guard rechaza: aplicada:false + codigo, el handshake NO se toca
    (sigue 'enviado' y vence solo) y la sede perdedora no suma una
    aceptacion que nunca ocurrio a su P(aceptacion).
  - RespondResponse.codigo (opcional, espejado): el caso que importa no se
    ve en handshake.estado. Telegram y /hospital ahora dicen "otra sede ya
    acepto este caso · no prepare cama" en vez de "ya estaba enviado".
  - RoutingService.aceptarDestino(): el guard sin la precondicion de
    evidencia, que pertenece a POST /dispatch. La sede que acepta no siempre
    es la #1 del ranking y el estado de ruteo vive en RAM hasta la 1.2;
    encadenar la reserva a esa precondicion dejaria la carrera abierta justo
    cuando hay varias sedes tocadas. `respond()` sigue exigiendola y ahora
    delega aqui: un solo camino hacia el store.
  - RoutingResponseCommand.evidence pasa a opcional. Sin evidencia se
    reserva igual pero no se escribe fila de auditoria: un renglon inventado
    es peor que un hueco, y el hueco queda en el log.

No se duplico ni una linea del guard. Verificado que los tests nuevos fallan
al desconectarlo (4 de 14) y pasan al conectarlo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…cia entre procesos

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant