Arquitectura del relay: crates, datos y pipeline de eventos
Arquitectura del relay: crates, datos y pipeline de eventos
En el capítulo anterior vimos qué es Buzz y por qué tiene sentido operar un relay propio. Este capítulo abre la caja: qué procesos corren, qué datos viven dónde, qué crates componen el binario y qué le pasa exactamente a un evento desde que entra por el WebSocket hasta que queda en Postgres.
Todo lo que sigue sale de ARCHITECTURE.md y del código del repositorio block/buzz; cuando la
documentación y el código no coinciden, lo digo y me quedo con el código. Este capítulo es de lectura,
no de teclado: la instalación viene en el
capítulo 3.
Vista aérea del sistema
Buzz es un monorepo Rust. El artefacto que operas es un binario, buzz-relay, un servidor Axum que
habla WebSocket y HTTP, y que depende de tres almacenes externos.
flowchart TD
subgraph clientes["Clientes"]
desk["App desktop<br/>Tauri y React"]
agent["Agentes IA<br/>via buzz-acp"]
cli["buzz-cli y scripts<br/>JSON in, JSON out"]
end
relay["buzz-relay<br/>Axum WS y REST<br/>NIP-01, NIP-42, media, git, workflows, audit"]
pg["Postgres 17<br/>eventos, canales, tokens,<br/>workflows, audit, FTS"]
redis["Redis 7<br/>pub/sub, presencia,<br/>typing, rate limits"]
s3["S3 o MinIO<br/>media Blossom y<br/>objetos git"]
desk -->|WebSocket| relay
agent -->|WS mas REST| relay
cli -->|WS mas REST| relay
relay --> pg
relay --> redis
relay --> s3
Tres afirmaciones explican casi todas las decisiones de diseño que vienen después:
- El relay es la única fuente de verdad. Todas las lecturas y escrituras pasan por él.
ARCHITECTURE.mdlo dice sin ambigüedad: no hay intercambio de eventos peer-to-peer, no hay gossip, no hay replicación. El relay aplica autenticación, verifica firmas, persiste, hace fan-out, indexa para búsqueda y dispara automatización. - Toda acción es un evento Nostr firmado. Un mensaje, una reacción, un paso de workflow, una
actualización de canvas: el mismo formato NIP-01, identificado por un entero
kind. - La comunidad se resuelve desde el host de la petición. En multi-tenant,
req.community = resolve_host(connection.host)se establece antes de que ningún handler pueda observar datos, y un host desconocido falla cerrado, nunca cae a una comunidad por defecto. Esto se profundiza en el capítulo 11.
Los tres almacenes
| Almacén | Imagen de referencia | Qué guarda |
|---|---|---|
| Postgres | postgres:17-alpine | Eventos, canales, membresías, tokens, workflows, log de auditoría y el índice de búsqueda full-text |
| Redis | redis:7-alpine | Fan-out pub/sub entre instancias, presencia con SET … EX 180, cuotas de rate limiting con un script Lua atómico |
| MinIO o S3 | minio/minio | Blobs de media Blossom y objetos git sobre object storage |
No son opcionales: el README del bundle de producción dice que el stack usa Postgres, Redis, MinIO y un volumen git porque hoy son dependencias reales, y que un “modo mínimo” queda para más adelante.
Mapa de crates
buzz-core es la base sin I/O. Todos los crates de servicio dependen de él, y buzz-relay es el
único que los importa y orquesta a todos.
flowchart TD
core["buzz-core<br/>cero I/O"]
core --> db["buzz-db"]
core --> auth["buzz-auth"]
core --> pubsub["buzz-pubsub"]
core --> search["buzz-search"]
core --> audit["buzz-audit"]
core --> wf["buzz-workflow"]
db --> relay["buzz-relay<br/>el servidor"]
auth --> relay
pubsub --> relay
search --> relay
audit --> relay
wf --> relay
El principio es explícito en ARCHITECTURE.md: los subsistemas están aislados entre sí.
buzz-workflow nunca llama a buzz-pubsub; buzz-search nunca llama a buzz-db. La coordinación
inter-subsistema ocurre solo a través del relay. En modo multi-comunidad el relay además posee la
propagación del TenantContext: los crates de servicio reciben entradas ya delimitadas por
comunidad, no derivan la tenencia por su cuenta desde tags controlados por el cliente.
Protocolo
| Crate | Responsabilidad | Lo que NO hace |
|---|---|---|
buzz-core | Tipos compartidos, verificación de eventos y matching de filtros. Define StoredEvent, CommunityId, TenantContext; las funciones filters_match, verify_event e is_private_ip; y el registro de kinds en crates/buzz-core/src/kind.rs. | No almacena eventos, no hace llamadas de red, no lanza tasks, no depende de ningún runtime async |
buzz-relay | El servidor Axum. Único crate que importa y orquesta todos los subsistemas. Aloja además el audio de huddle en src/audio/ y el servidor git smart HTTP. | ”No implementa lógica de negocio: delega al crate apropiado para cada operación” |
Que buzz-core sea cero I/O no es una aspiración: su Cargo.toml cierra la lista de dependencias con
un comentario que es una regla, # NO tokio, NO sqlx, NO redis, NO axum — zero I/O dependencies.
Del otro lado, los módulos de crates/buzz-relay/src/lib.rs dan la medida de la superficie del
servidor. Los públicos son api, audio, config, conformance, connection, error,
handlers, invite_token, mesh_boot, metrics, nip11, protocol, push_runtime, router,
state, storage_sweep, subscription, telemetry, tenant, tunnel, webhook_secret y
workflow_sink. Hay además uno privado, declarado como mod admission;: la admisión de
conexiones no forma parte de la API pública del crate.
Servicios
| Crate | Responsabilidad | Detalle operativo |
|---|---|---|
buzz-db | Todo el acceso a Postgres. Módulos event.rs, channel.rs, feed.rs, workflow.rs, partition.rs, dm.rs, reaction.rs, thread.rs, user.rs | Usa sqlx::query() en runtime, no las macros de compile-time. No hay directorio .sqlx/ |
buzz-auth | NIP-42, NIP-98, scopes y cuotas. Módulos access, nip42, nip98, nip98_replay, rate_limit, scope | El trait RateLimiter vive aquí; la implementación Redis vive en buzz-pubsub |
buzz-pubsub | Fan-out por Redis, presencia e indicadores de escritura. Módulos cache_invalidation, conn_control, nip98_replay, presence, publisher, rate_limiter, subscriber, topic | PubSubManager no es Clone: los llamadores usan Arc<PubSubManager> |
buzz-search | Solo el lado de consulta de la búsqueda full-text de Postgres | No aplica control de acceso: devuelve candidatos y el relay reautoriza cada hit |
buzz-audit | Log append-only encadenado con SHA-256, con una cadena por comunidad | No registra eventos AUTH ni efímeros |
buzz-workflow | Motor de automatización YAML-as-code, con workflows delimitados por canal | No resuelve templates recursivamente; no encola runs al llegar a capacidad |
buzz-media | Almacenamiento Blossom y S3. Módulos auth, bucket_index, storage, thumbnail, upload, upload_record, validation | Crate de librería sin dependencia de Axum: los handlers viven en buzz-relay |
Un detalle que sorprende: buzz-pubsub usa un pool deadpool-redis para PUBLISH, SET y ZADD,
pero mantiene además una conexión redis::aio::PubSub dedicada, fuera del pool, porque las
conexiones de un pool no pueden mantener estado PSUBSCRIBE. Esa conexión alimenta un
broadcast::channel(4096) y reconecta con backoff exponencial de 1 s a 30 s.
Superficie de agentes
| Crate | Para qué sirve |
|---|---|
buzz-cli | CLI agent-first, JSON de entrada y JSON de salida, diseñada para tool calls de LLM |
buzz-acp | Harness que hace de puente entre las @menciones del relay y agentes IA vía ACP sobre JSON-RPC. Soporta Goose, Codex y Claude Code |
buzz-agent | Agente ACP mínimo, no streaming, tool-calls-as-output |
buzz-dev-mcp | Servidor MCP de desarrollo: herramientas de shell y edición de archivos |
buzz-workflow | Motor de automatización YAML, compartido con la familia de servicios |
buzz-persona | Parser y loader de archivos de persona pack .persona.md |
buzz-acp es un binario independiente: se conecta al relay por WebSocket con NIP-42, descubre canales
por REST y encola eventos de @mención por canal. Como máximo hay un prompt en vuelo por canal, y los
eventos encolados se agrupan en un único session/prompt. El pool va de 1 a 32 subprocesos de agente
con ciclo de vida claim y return. No persiste estado.
Git y pairing
| Crate | Para qué sirve |
|---|---|
git-sign-nostr | Programa de firma de commits y tags de git con claves secp256k1 de Nostr, según el borrador NIP-GS |
git-credential-nostr | Credential helper de git que produce cabeceras de autenticación NIP-98 para el servidor git de Buzz |
buzz-pair-relay | Relay sidecar efímero para los handshakes de emparejamiento de dispositivos NIP-AB |
buzz-pairing-cli | CLI de pruebas de interoperabilidad de pairing NIP-AB |
Compartidos y tooling
| Crate | Para qué sirve |
|---|---|
buzz-sdk | Constructores tipados de eventos Nostr, usados por buzz-acp y buzz-cli |
buzz-media | Almacenamiento Blossom y S3 |
buzz-admin | CLI de operador: membresía del relay, generación de claves, migraciones |
buzz-test-client | Cliente de integración y suite E2E |
buzz-conformance | Esquema de traza runtime y verificador de replay contra la especificación TLA+ |
Dos notas sobre esta última familia. buzz-conformance trae una advertencia que el propio crate
escribe: no es una prueba; la conformidad de trazas solo verifica las ejecuciones que corriste. Y
buzz-admin es el crate que más vas a usar como operador: se despacha dentro de la imagen Docker del
relay, en /usr/local/bin/buzz-admin, y es el camino recomendado para gestionar membresía en
producción, tema del capítulo 8.
El monorepo tiene más crates, entre ellos buzz-push-gateway, buzz-relay-mesh, buzz-voice,
buzz-backend-kubernetes, buzz-ws-client y sprig. El listado completo vive en los Cargo.toml
de crates/.
Ciclo de vida de una conexión
ARCHITECTURE.md es tajante: “toda conexión WebSocket sigue esta secuencia exacta”.
sequenceDiagram
participant C as Cliente
participant R as buzz-relay
participant DB as Postgres
Note over R: Paso 0 — community binding desde el host
Note over R: Paso 1 — conn_semaphore.try_acquire_owned
R->>C: Paso 2 — AUTH challenge
C->>R: Paso 3 — AUTH con evento firmado
R->>R: Pending pasa a Authenticated o Failed
Note over R: Paso 4 — recv_loop, send_loop, heartbeat_loop
C->>R: EVENT, REQ o CLOSE
R->>DB: lectura o escritura
R->>C: OK, EVENT, EOSE o CLOSED
Note over R: Paso 5 — cleanup ordenado
Paso 0. Community binding. El servidor resuelve el TenantContext desde el host antes de que
ningún handler pueda observar datos de tenant. La URL manda. Los tags #h que envía el cliente siguen
siendo identificadores de canal, y deben resolver a un canal dentro de la comunidad derivada del
host: nunca seleccionan tenant.
Paso 1. Semáforo de conexión. state.conn_semaphore.try_acquire_owned(). Si el relay está al
tope de capacidad, la conexión se rechaza inmediatamente antes de leer un solo byte. El permiso se
sostiene durante toda la vida de la conexión y se libera en el cleanup. El tope se controla con
BUZZ_MAX_CONNECTIONS, cuyo valor por defecto es 10000.
Pasos 2 y 3. Desafío y autenticación. El relay envía de inmediato ["AUTH", "<challenge>"] con un
string aleatorio, y registra la conexión en el ConnectionManager después de enviarlo. El cliente
responde ["AUTH", <evento-firmado>] antes de poder enviar eventos o suscripciones: en éxito
ConnectionState.auth_state pasa de Pending a Authenticated(AuthContext), en fallo a Failed.
Los EVENT y REQ no autenticados se rechazan con ["CLOSED", ...] o con
["OK", ..., false, "auth-required: ..."]. El detalle está en el
capítulo 7.
Paso 4. Loops activos. Tres tareas concurrentes viven mientras dure la conexión:
recv_loop, inline: lee frames, parseaClientMessagey despacha a los handlers.send_loop, spawned: drena el canal mpsc y escribe frames al WebSocket.heartbeat_loop, spawned: envía un ping WebSocket cada 30 segundos. Tres pongs perdidos cortan la conexión. El intervalo está fijado encrates/buzz-relay/src/connection.rs.
Un CancellationToken coordina el apagado de los tres.
Clientes lentos: ConnectionState::send() usa try_send. Si el buffer de envío está lleno, sube un
contador de gracia; tras N eventos consecutivos de buffer lleno la conexión se cancela, y un envío
exitoso resetea el contador. El buffer se configura con BUZZ_SEND_BUFFER, por defecto 1000, y la
gracia con BUZZ_SLOW_CLIENT_GRACE_LIMIT, por defecto 15.
Paso 5. Cleanup. En cualquier desconexión, en este orden exacto:
cancel.cancel()señaliza a todos los loops.- Se espera con
awaitasend_loopyheartbeat_loop. sub_registry.remove_connection(conn_id)quita todas las suscripciones de los índices DashMap.conn_manager.deregister(conn_id)la quita del mapa de canales de envío.drop(permit)libera el slot del semáforo de conexiones.
Pipeline de un evento
Cuando llega ["EVENT", <event>], el handler de handlers/event.rs corre estos doce pasos en
orden.
flowchart TD
in["EVENT entrante"] --> a1["Pasos 1 a 3<br/>auth, pubkey match,<br/>rechazo de kind 22242"]
a1 --> a4{"Paso 4<br/>kind entre 20000 y 29999"}
a4 -->|si| eph["Sub-pipeline efimero"]
a4 -->|no| a5["Paso 5 — verify<br/>firma Schnorr y hash del ID"]
a5 --> a6["Paso 6 — membership"]
a6 --> a7["Paso 7 — DB insert<br/>ON CONFLICT DO NOTHING"]
a7 --> a8["Paso 8 — Redis publish"]
a8 --> a9["Paso 9 — fan-out"]
a9 --> ff["Pasos 10 a 12<br/>search, audit, workflow<br/>fire-and-forget"]
a9 --> ok["OK true al cliente"]
| Paso | Qué hace |
|---|---|
| 1. AUTH CHECK | ¿AuthState::Authenticated? ¿scope MessagesWrite? |
| 2. PUBKEY MATCH | event.pubkey debe coincidir con la pubkey del AuthContext |
| 3. KIND_AUTH REJECT | kind == 22242: los eventos AUTH nunca se almacenan |
| 4. EPHEMERAL ROUTE | Kinds 20000 a 29999 se desvían al sub-pipeline efímero |
| 5. VERIFY | spawn_blocking(verify_event): firma Schnorr más hash SHA-256 del ID |
| 6. MEMBERSHIP | Si hay channel_id en los tags, se comprueba membresía |
| 7. DB INSERT | db.insert_event con ON CONFLICT DO NOTHING, idempotente |
| 8. REDIS PUBLISH | pubsub.publish_event, solo si el evento está delimitado a un canal |
| 9. FAN-OUT | sub_registry.fan_out y luego conn_manager.send_to |
| 10. SEARCH INDEX | search_index_tx.send, cola acotada, no bloqueante |
| 11. AUDIT LOG | audit.log, tarea async independiente |
| 12. WORKFLOW TRIGGER | wf.on_event, tarea async, excluye kinds 46001 a 46012 |
Cuatro precisiones que importan al operar y al depurar:
- Los pasos 10 a 12 son fire-and-forget. Un fallo en indexado, auditoría o disparo de workflow no falla el envío del evento: si un evento aparece en el canal pero no en la búsqueda, eso es consistente con el diseño, no un error de ingesta.
- El
["OK", <id>, true, ""]se envía al final del pipeline, no apenas se inserta en la base. - El paso 9 excluye explícitamente las suscripciones globales de los eventos delimitados a un
canal. Una suscripción sin restricción de
channel_idno recibe eventos de canales privados, coincida o no el filtro.ARCHITECTURE.mdlo llama una frontera de seguridad deliberada. - Prevención de loops de workflow: los kinds de ejecución de workflow 46001 a 46012, los mensajes
firmados por el relay con tag
buzz:workflowyKIND_GIFT_WRAPestán excluidos de disparar workflows. Todo el resto de eventos almacenados, incluidos los kind 9, sí evalúan workflows.
Sub-pipeline efímero
Los kinds 20000 a 29999 se saltan almacenamiento, auditoría y búsqueda. Hay dos caminos:
flowchart TD
entra["Evento efimero<br/>kind 20000 a 29999"] --> tipo{"Es kind 20001<br/>presencia?"}
tipo -->|si| p1["1. VERIFY firma"]
p1 --> p2["2. REDIS PRESENCE<br/>set o clear segun el content"]
p2 --> p3["3. LOCAL FAN-OUT<br/>sin Redis PUBLISH"]
tipo -->|no| e1["1. VERIFY firma"]
e1 --> e2["2. MEMBERSHIP<br/>solo si es channel-scoped"]
e2 --> e3["3. MARK LOCAL<br/>dedup antes del round-trip"]
e3 --> e4["4. REDIS PUBLISH<br/>sin escritura en DB"]
e4 --> e5["5. LOCAL FAN-OUT"]
La presencia salta las verificaciones de membresía y usa fan-out solo local: es la excepción, y su fan-out multinodo sigue documentado como trabajo futuro. Los efímeros nunca se guardan en Postgres y nunca aparecen en consultas históricas REQ.
Semáforo de handlers
Además del semáforo por conexión hay un handler_semaphore que limita el procesamiento concurrente
de EVENT y REQ en todas las conexiones a la vez. Su capacidad se configura con
BUZZ_MAX_CONCURRENT_HANDLERS, por defecto 1024. Cuando satura, el rechazo es
rate-limited: too many concurrent requests. Los mensajes CLOSE no pasan por este semáforo.
Suscripciones y filtros NIP-01
El SubscriptionRegistry de subscription.rs está respaldado por DashMap y mantiene tres índices:
pub struct SubscriptionRegistry {
subs: DashMap<ConnId, HashMap<SubId, SubEntry>>,
channel_kind_index: DashMap<IndexKey, Vec<(ConnId, SubId)>>, // IndexKey = { channel_id, kind }
channel_wildcard_index: DashMap<Uuid, Vec<(ConnId, SubId)>>,
}
Cuando llega un evento, fan_out consulta los tres índices en orden.
| Tier | Índice | Clave | Caso de uso |
|---|---|---|---|
| 1 | channel_kind_index | (channel_id, kind) | Suscripciones con canal y kind explícitos: lookup O(1) |
| 2 | channel_wildcard_index | channel_id | Suscripciones con canal pero sin restricción de kinds |
| 3 | subs | escaneo lineal | Suscripciones globales sin channel_id |
El tier 3 solo se consulta para eventos que no están delimitados a un canal.
Dos casos borde de NIP-01 que hay que tener grabados
kinds: [], es decir un arreglo vacío explícito, significa “no coincidir con nada”, no un comodín. Esas suscripciones no se indexan ni en tier 1 ni en tier 2 y no reciben nunca eventos.kindsausente, sin el campo, significa “coincidir con todos los kinds”: se indexa en tier 2 si hay canal, o en tier 3 si es global.
Hay además un gotcha operativo documentado en AGENTS.md: las consultas al relay deben especificar
kinds, y omitirlas dispara el p-gate con un 403. Los REQ globales que puedan coincidir con kinds
p-gated —44100, 44101 y 1059— deben incluir un filtro #p donde todos los valores coincidan con
la pubkey autenticada; si no, el relay responde
restricted: p-gated events require #p matching your pubkey. El matching en sí lo hace
filters_match en buzz-core: OR entre filtros, AND dentro de cada filtro, con prefix-matching
NIP-01 sobre los IDs de evento.
Control de acceso en el handler REQ
La verificación de acceso ocurre antes de registrar la suscripción:
flowchart TD
r1["1. Parsear filtros y extraer channel_id"]
r1 --> r2["2. Cargar accessible_channel_ids<br/>para la pubkey de esta conexion"]
r2 --> r3{"3. channel_id esta<br/>en la lista?"}
r3 -->|no| deny["CLOSED<br/>restricted: not a channel member"]
r3 -->|si| r4["4. sub_registry.register<br/>conn_id, sub_id, filters, channel_id"]
El orden no es cosmético. Evita una carrera en la que un no-miembro reciba eventos vivos de un canal privado entre el registro de la suscripción y la verificación de acceso.
Consulta histórica, EOSE y límites
Tras registrar, el handler REQ consulta Postgres por los eventos almacenados que coincidan con los
filtros. Se envían como frames ["EVENT", sub_id, event] y luego ["EOSE", sub_id]. Todo lo que
llegue después del EOSE viaja por el camino de fan-out. El tope de página lo define
DEFAULT_MAX_PAGE_LIMIT en crates/buzz-db/src/event.rs, cuyo valor es 1000; ARCHITECTURE.md
todavía menciona 500 por filtro, y el código manda.
crates/buzz-relay/src/nip11.rs publica en el documento NIP-11: max_subscriptions 1024,
max_filters 10, max_limit igual a buzz_db::DEFAULT_MAX_PAGE_LIMIT, max_subid_length 256,
min_pow_difficulty ninguno, payment_required en false, y —esto importa— auth_required y
restricted_writes siempre en true.
El tope de 1024 suscripciones por conexión también está como constante en
crates/buzz-relay/src/handlers/req.rs. El tamaño máximo de frame se controla con
BUZZ_MAX_FRAME_BYTES; la constante DEFAULT_MAX_FRAME_BYTES de crates/buzz-relay/src/config.rs
vale 512 KiB. ARCHITECTURE.md documenta 65.536 bytes, cifra que ya no corresponde al código.
Rangos de kinds
El kind es el único switch de despacho del sistema. El relay enruta, almacena y hace fan-out según
él, y los clientes filtran suscripciones por él. Una feature nueva es un número de kind nuevo: cero
cambios rompientes para los clientes existentes.
| Rango | Significado |
|---|---|
| 0 a 9999 | Kinds estándar de Nostr, de NIP-01 en adelante |
| 10000 a 19999 | Eventos reemplazables, NIP-16 |
| 20000 a 29999 | Eventos efímeros: no se almacenan, no se auditan |
| 30000 a 39999 | Eventos reemplazables parametrizados |
| 40000 a 49999 | Kinds propios de Buzz |
Los kinds son u32 y el registro completo se exporta como ALL_KINDS: &[u32] desde
crates/buzz-core/src/kind.rs, la fuente de verdad del listado vigente. No te fíes de ninguna cifra
total que aparezca en la documentación: ARCHITECTURE.md se contradice a sí mismo sobre cuántos kinds
hay. Una muestra útil para orientarse:
| Kind | Constante | Qué es |
|---|---|---|
| 7 | KIND_REACTION | Reacción emoji, NIP-25 estándar |
| 9 | KIND_STREAM_MESSAGE | Mensaje de chat en un canal Stream, grupo NIP-29 |
| 1059 | KIND_GIFT_WRAP | DM gift-wrap NIP-17 |
| 9030 a 9032 | RELAY_ADMIN_* | Agregar miembro, quitar miembro, cambiar rol |
| 13534 | KIND_NIP43_MEMBERSHIP_LIST | Roster de membresía firmado por el relay |
| 20001 y 20002 | KIND_PRESENCE_UPDATE y KIND_TYPING_INDICATOR | Presencia y escritura, ambos efímeros |
| 22242 | KIND_AUTH | AUTH NIP-42, nunca almacenado |
| 27235 | KIND_HTTP_AUTH | NIP-98 HTTP Auth |
| 39000 a 39002 | KIND_NIP29_GROUP_* | Metadata, admins y miembros de grupo, firmados por el relay |
| 43001 a 43006 | KIND_JOB_* | Ciclo de vida de trabajos de agente |
| 45001 y 45003 | KIND_FORUM_POST y KIND_FORUM_COMMENT | Raíz y respuesta de hilo de foro |
| 46001 a 46012 | KIND_WORKFLOW_* | Eventos de ejecución de workflow |
Un error clásico de quien viene de otros relays Nostr: la metadata de canal en Buzz es kind 39000, no kind 41. El kind 41 existe en el registro pero Buzz no lo usa para eso.
Dónde vive cada dato
Tablas clave de Postgres
| Tabla | Contenido |
|---|---|
events | Todos los eventos almacenados; particionada mensualmente por rango sobre created_at |
channels | Canales con tipo, visibilidad, canvas y topic |
channel_members | Membresías con rol; borrado suave vía removed_at |
workflows, workflow_runs, workflow_approvals | Definiciones en JSON canónico, ejecuciones con traza y compuertas cuyo token se guarda como hash SHA-256 |
audit_log | Entradas de la cadena de auditoría, con cadena y head por comunidad |
delivery_log | Seguimiento de entregas, particionada |
Los tipos de canal son Stream, Forum, Dm y Workflow; los roles de miembro son Owner,
Admin, Member, Guest y Bot. El repositorio trae 28 archivos en migrations/, embebidos en el
binario mediante sqlx::migrate! y cubiertos en el
capítulo 13.
La búsqueda no es un servicio aparte
No hay Elasticsearch, ni Meilisearch, ni servicio de búsqueda dedicado, ni indexador fuera de banda.
La búsqueda corre sobre una columna generada de la propia tabla events:
search_tsv TSVECTOR GENERATED ALWAYS AS (
CASE WHEN kind IN (0, 9, 40002, 45001, 45003)
THEN to_tsvector('simple', content)
ELSE NULL::tsvector
END
) STORED;
CREATE INDEX idx_events_search_tsv ON events USING GIN (search_tsv);
Como la columna es GENERATED ALWAYS, cada escritura de fila es la actualización del índice: no
hay cola de reindexado ni ventana de consistencia. Y los kinds fuera de la lista producen NULL, así
que son inbuscables a nivel de almacenamiento, porque un tsvector nulo nunca coincide con @@. Más
en el capítulo 10.
Patrones de claves en Redis
Esta es una de las secciones donde ARCHITECTURE.md y el código no coinciden, y como avisé al
principio me quedo con el código. La tabla del documento lista tres patrones sin delimitar por
comunidad (buzz:channel:{uuid}, buzz:presence:{pubkey_hex}, buzz:typing:{channel_uuid}) y añade
como advertencia que un Redis compartido en multi-comunidad “debe” delimitar por comunidad. Dos
correcciones sobre eso.
Primera: el código ya delimita siempre, no es un “debe”. crates/buzz-pubsub/src/topic.rs
construye los nombres de canal pub/sub con EventTopicKey::redis_channel(), que solo tiene dos
ramas, y ambas incluyen el CommunityId:
EventTopic::Channel(channel_id) => {
format!("{BUZZ_PREFIX}:{}:channel:{channel_id}", self.community_id)
}
EventTopic::Global => format!("{BUZZ_PREFIX}:{}:global", self.community_id),
BUZZ_PREFIX es la constante "buzz". Y el encabezado de crates/buzz-pubsub/src/presence.rs
documenta la escritura literal: SET buzz:{community}:presence:{pubkey_hex} "online" EX 180.
Segunda: el sorted set de typing no existe en el código. No hay ningún ZADD, ningún
ZREMRANGEBYSCORE ni ninguna clave buzz:*:typing:* en todo el workspace; la única mención de ZADD
es un comentario de cabecera en crates/buzz-pubsub/src/lib.rs que enumera qué operaciones puede
hacer el pool. Los indicadores de escritura son eventos efímeros de kind 20002 y viajan por el
mismo canal pub/sub de siempre: la propia tabla de limitaciones de ARCHITECTURE.md lo confirma
cuando dice que “los indicadores de escritura (kind 20002) se entregan por fan-out local y por
pub/sub de Redis”. No hay estado de typing consultable en Redis, y por eso tampoco hay endpoint REST
para preguntar quién está escribiendo.
Los patrones que realmente existen son estos dos:
| Patrón real en el código | Tipo | TTL | Propósito |
|---|---|---|---|
buzz:{community}:channel:{uuid} | Canal Pub/Sub | — | Fan-out de eventos delimitados a un canal |
buzz:{community}:global | Canal Pub/Sub | — | Fan-out de eventos sin canal |
buzz:{community}:presence:{pubkey_hex} | String | 180 s | Estado online o away |
Consecuencia operativa: no tienes que prefijar nada a mano; el CommunityId va en la clave por
construcción, así que dos comunidades sobre un mismo Redis no pueden pisarse. Y si estás depurando
typing, no busques claves en Redis: mira el flujo de eventos efímeros.
Los 180 segundos de presencia son exactamente el triple del heartbeat de 60 segundos —hay un test que
lo fija, presence_ttl_is_three_one_minute_heartbeat_windows, con PRESENCE_TTL_SECS == 3 * 60—, de
modo que un solo latido perdido no produce flapping.
Fan-out multinodo
El bucle suscriptor se lanza en buzz-relay/src/main.rs y llena el canal broadcast. Una tarea
consumidora se suscribe con pubsub.subscribe_local(), llama a sub_registry.fan_out() por evento y
entrega a las conexiones WebSocket locales con conn_manager.send_to(). Está cableado de extremo a
extremo, y la deduplicación del eco local usa AppState.local_event_ids, una caché moka con clave
(CommunityId, [u8; 32]).
Limitaciones conocidas
ARCHITECTURE.md cierra con una sección de gaps verificados, con un encabezado que vale la pena
citar: “son gaps verificados en la implementación actual, no aspiraciones de diseño”. Estas son las
que siguen vigentes:
| Limitación | Qué significa para ti |
|---|---|
| Sin caché offline de consultas sqlx | Se usa sqlx::query() en runtime, no sqlx::query!(). No hay directorio .sqlx/ y las consultas no se validan en compilación |
| Sin endpoint REST dedicado de typing | Los indicadores de escritura llegan por fan-out y Redis, pero no hay endpoint para consultar quién está escribiendo. /api/presence devuelve solo online y away |
| Grabación y tracks de huddle no construidos | La voz, el ciclo de vida de sala y los eventos de join, leave y end funcionan. Grabación y publicación por track tienen kinds reservados y ningún productor |
| Compuertas de aprobación no cableadas de extremo a extremo | El ejecutor devuelve StepResult::Suspended y el relay tiene endpoints de grant y deny con CRUD, pero el motor intercepta antes de crear filas WaitingApproval: los runs que llegan a una aprobación se marcan como Failed |
| Acciones de workflow parcialmente stub | send_dm y set_channel_topic están en el esquema pero devuelven NotImplemented: un run que llega a una de ellas falla en ejecución |
Hay una limitación en esa lista que ya no aplica: la entrada que dice que no existe implementación
de rate limiting. crates/buzz-pubsub/src/rate_limiter.rs implementa RedisRateLimiter con un script
Lua atómico que combina INCR y EXPIRE, y el camino de admisión del relay lo usa tanto en WebSocket
como en el puente HTTP. Eso sí: es un limitador de ventana fija, y el propio código advierte que
permite hasta el doble de ráfaga en los bordes de la ventana.
El README agrega su propia tabla de estado: los clientes móviles, las compuertas de aprobación de workflow y los eventos de ciclo de vida de huddle están en construcción, mientras que la reputación web-of-trust entre relays, las notificaciones push y las “culture features” están en la columna de opiniones sin código. No planifiques sobre esa última columna.
Y una limitación estructural que no es un bug sino una decisión de diseño: sin replicación, sin gossip y sin intercambio P2P, cuando tu relay está caído tu comunidad está caída. De eso tratan el capítulo 12 y el capítulo 14.
Resumen
buzz-relayes un servidor Axum que depende de Postgres, Redis y object storage S3 o MinIO. Los tres son dependencias reales, no opcionales.buzz-corees la base sin I/O y suCargo.tomlprohíbe explícitamente tokio, sqlx, redis y axum.buzz-relayes el único crate que orquesta todos los subsistemas; los subsistemas están aislados entre sí. Las familias son protocolo, servicios, superficie de agentes, git y pairing, compartidos y tooling; como operador el que más usarás esbuzz-admin, que viaja dentro de la imagen Docker.- Una conexión sigue seis pasos exactos: community binding desde el host, semáforo, desafío NIP-42, autenticación, tres loops concurrentes con heartbeat de 30 segundos y un cleanup ordenado.
- Un evento recorre doce pasos. Los pasos 10 a 12 —búsqueda, auditoría y workflows— son
fire-and-forget: su fallo no rechaza el evento. El
OKse envía al final del pipeline, no tras el insert. - Los kinds 20000 a 29999 son efímeros: nunca tocan Postgres, nunca se auditan y nunca aparecen en consultas históricas REQ.
- El fan-out consulta tres índices: canal más kind, comodín de canal y global. Las suscripciones globales están excluidas por diseño de los eventos delimitados a canal: es una frontera de seguridad.
- En NIP-01,
kinds: []significa “no coincidir con nada”, no comodín; omitirkindsdispara el p-gate y devuelve 403. - El registro de kinds vive en
crates/buzz-core/src/kind.rsy es la única fuente de verdad. La metadata de canal es kind 39000, no kind 41. - La búsqueda es FTS de Postgres sobre una columna
GENERATED ALWAYS, sin servicio ni indexador aparte: cada escritura de fila es la actualización del índice. - Hay gaps verificados: sin caché offline de sqlx, sin endpoint REST de typing, sin grabación de
huddle, aprobaciones de workflow sin cablear de punta a punta y dos acciones de workflow que
devuelven
NotImplemented.
Siguiente: Tu primer relay: instalación desde el código fuente