feat(handshake): notificación push de matches del broker al cliente

El servidor empuja MatchEvent (Available | Lost) a los consumers cuando
sus inputs cambian de match — sea porque un productor llegó, porque
otro mejor lo desplazó, o porque desapareció.

Mecánica:

- Frame::MatchEvent con MatchEventKind { Available, Lost } y los datos
  del match (consumer_flow, producer_session/label/flow, ty, via, pinned).
- Server: SessionTxTable (Arc<Mutex<HashMap<SessionId, mpsc::Sender>>>)
  + LastMatches (último match conocido por consumer/input). En cada
  register/unregister, broadcast_match_diffs recomputa con el broker
  y emite SOLO los diffs respecto al estado anterior.
- Session::run_post_handshake usa tokio::select! para multiplexar
  read_frame del cliente y rx.recv() de su tx push.
- Cleanup ahora también limpia push_table y last_matches y dispara un
  broadcast (para notificar a quienes pierden el match).
- Client: VecDeque<MatchEvent> bufferea eventos que llegan mezclados
  con respuestas a Ping. API:
    - take_event() — non-blocking, drena buffer
    - await_event(timeout) — bloquea hasta evento o timeout
- ping() ahora drena MatchEvents intermedios hasta encontrar el Pong.

Capacity del canal push por sesión: 32 frames (try_send no-blocking;
si se llena, los eventos extra se descartan — se documenta como
ephemeral, el cliente puede re-consultar via brahman-status).

Test nuevo en brahman-handshake/tests/handshake.rs:
- match_event_pushed_on_producer_arrival: consumer espera, no recibe
  evento → llega productor → recibe Available → productor se va →
  recibe Lost.

Example nuevo: brahman-handshake/examples/subscriber.rs — cliente que
loguea cada MatchEvent en tiempo real. Útil para ver la dinámica del
broker. Pings cada 25s para keepalive.

Demo end-to-end verificada (4 eventos, 3 ya cubren el ciclo completo):

  T+0.3  alpha llega    → Available ← demo.alpha.out
  T+0.8  beta llega     → (sin evento: alpha gana por orden alfabético)
  T+1.3  alpha killed   → Available ← demo.beta.out (re-evaluación)
  T+1.8  beta killed    → Lost ← <none>

El broker emite diff: ningún evento cuando un nuevo productor llega
sin desplazar al ganador actual.

Tests: 28/28 (handshake integ 6→7). cargo check --workspace: 0 errores.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sergio
2026-05-08 15:43:41 +00:00
parent 70a7a0d46d
commit 8a83a26de0
6 changed files with 449 additions and 56 deletions
+48 -7
View File
@@ -1,13 +1,15 @@
//! Cliente de handshake. Conecta a un Unix socket y mantiene la sesión.
use std::collections::VecDeque;
use std::path::Path;
use std::time::Duration;
use brahman_card::{Card, CARD_SCHEMA_VERSION};
use thiserror::Error;
use tokio::net::UnixStream;
use crate::codec::{read_frame, write_frame};
use crate::messages::{Farewell, Frame, HandshakeError, Hello, HelloAck, Ping, SessionId};
use crate::messages::{Farewell, Frame, HandshakeError, Hello, HelloAck, MatchEvent, Ping, SessionId};
/// Errores del cliente.
#[derive(Debug, Error)]
@@ -29,12 +31,15 @@ pub enum ClientError {
}
/// Cliente conectado y autenticado. Tras `connect` ya completó el handshake
/// y tiene su `SessionId`.
/// y tiene su `SessionId`. Los `MatchEvent` recibidos durante operaciones
/// request/response se buferean en `pending_events` y se obtienen vía
/// [`Client::take_event`] o [`Client::await_event`].
#[derive(Debug)]
pub struct Client {
stream: UnixStream,
session: SessionId,
server_info: HelloAck,
pending_events: VecDeque<MatchEvent>,
}
impl Client {
@@ -60,11 +65,17 @@ impl Client {
Frame::Ping(_) => return Err(ClientError::UnexpectedFrame { got: "Ping" }),
Frame::Pong(_) => return Err(ClientError::UnexpectedFrame { got: "Pong" }),
Frame::Farewell(_) => return Err(ClientError::UnexpectedFrame { got: "Farewell" }),
Frame::MatchEvent(_) => {
return Err(ClientError::UnexpectedFrame {
got: "MatchEvent (pre-handshake)",
});
}
};
Ok(Self {
stream,
session: ack.session,
server_info: ack,
pending_events: VecDeque::new(),
})
}
@@ -78,7 +89,8 @@ impl Client {
&self.server_info
}
/// Envía un Ping y devuelve el timestamp del servidor.
/// Envía un Ping y devuelve el timestamp del servidor. Los frames
/// `MatchEvent` que lleguen mezclados se buferean en `pending_events`.
pub async fn ping(&mut self) -> Result<u64, ClientError> {
write_frame(
&mut self.stream,
@@ -87,10 +99,39 @@ impl Client {
}),
)
.await?;
match read_frame(&mut self.stream).await? {
Frame::Pong(p) => Ok(p.timestamp_ms),
Frame::Error(e) => Err(ClientError::Server(e)),
_ => Err(ClientError::UnexpectedFrame { got: "non-pong" }),
loop {
match read_frame(&mut self.stream).await? {
Frame::Pong(p) => return Ok(p.timestamp_ms),
Frame::MatchEvent(ev) => self.pending_events.push_back(ev),
Frame::Error(e) => return Err(ClientError::Server(e)),
_ => return Err(ClientError::UnexpectedFrame { got: "non-pong" }),
}
}
}
/// Saca un evento pendiente del buffer, sin bloquear ni leer del wire.
pub fn take_event(&mut self) -> Option<MatchEvent> {
self.pending_events.pop_front()
}
/// Espera un `MatchEvent` con timeout. Drena primero el buffer; si
/// está vacío, lee del wire hasta el timeout. Otros frames recibidos
/// (Pong huérfano, Error) cortan la espera con error.
pub async fn await_event(
&mut self,
timeout: Duration,
) -> Result<Option<MatchEvent>, ClientError> {
if let Some(ev) = self.pending_events.pop_front() {
return Ok(Some(ev));
}
match tokio::time::timeout(timeout, read_frame(&mut self.stream)).await {
Err(_) => Ok(None),
Ok(Err(e)) => Err(ClientError::Io(e)),
Ok(Ok(Frame::MatchEvent(ev))) => Ok(Some(ev)),
Ok(Ok(Frame::Error(e))) => Err(ClientError::Server(e)),
Ok(Ok(_)) => Err(ClientError::UnexpectedFrame {
got: "non-event en await_event",
}),
}
}