Operations

Operations & Recovery Runbook Runbook de Operaciones & Recuperacion

How to operate, diagnose, and recover the data ingestion pipeline — the Hetzner Go ingest service, ClickHouse, the R2 write-ahead log, and the AppsFlyer Pull API. Every recovery action here is idempotent. Como operar, diagnosticar y recuperar el pipeline de ingesta de datos — el servicio Go en Hetzner, ClickHouse, el write-ahead log en R2, y la AppsFlyer Pull API. Toda accion de recuperacion aqui es idempotente.

1. Pipeline health check 1. Chequeo de salud del pipeline

Start every investigation here. The Go ingest service exposes a single JSON health endpoint. Hit it through the Cloudflare Tunnel from anywhere, or directly on the box. Empieza cada investigacion aqui. El servicio Go expone un solo endpoint JSON de salud. Llamalo a traves del Cloudflare Tunnel desde cualquier lado, o directo en el servidor.

# Proxied through the Cloudflare Tunnel (anywhere)
curl -s https://ingest.relo.mx/health | jq

# On the Hetzner box (bypasses the tunnel)
ssh root@178.156.195.219
curl -s http://localhost:4080/health | jq

Key fields to watch Campos clave a vigilar

The fields below tell you the health of every stage. Green vs red signals are noted inline. Los campos de abajo te dicen la salud de cada etapa. Las senales verde vs rojo van anotadas en linea.

Healthy baseline: buffer_events: 0, all dlq_depth zero, last_pull_at within the hour, last_pull_stats.errors: 0, failover.active: false, all four recovery.health_check flags true.Baseline sano: buffer_events: 0, todo dlq_depth en cero, last_pull_at dentro de la hora, last_pull_stats.errors: 0, failover.active: false, los cuatro flags de recovery.health_check en true.

2. How ingestion works (the safety nets) 2. Como funciona la ingesta (las redes de seguridad)

Three independent safety nets make the pipeline recoverable. Understanding them is what makes every action below safe to re-run. Tres redes de seguridad independientes hacen el pipeline recuperable. Entenderlas es lo que hace que cada accion de abajo sea segura para re-ejecutar.

3. Symptom: ClickHouse write outage / event gap 3. Sintoma: outage de escritura a ClickHouse / hueco de eventos

The signature of an outage is a stretch of time where events were accepted but never landed in ClickHouse. Detect it two ways: La firma de un outage es un periodo donde los eventos fueron aceptados pero nunca llegaron a ClickHouse. Detectalo de dos maneras:

# 1) Daily event counts — look for a dip vs neighbors
ssh root@178.156.195.219 "clickhouse-client -d relo -q \
  \"SELECT toDate(event_time) AS d, count() AS n
    FROM relo.events
    WHERE event_time >= now() - INTERVAL 14 DAY
    GROUP BY d ORDER BY d\""

# 2) Scan the logs for the overflow / replay signal
ssh root@178.156.195.219 "journalctl -u relo-ingest --since '2 days ago' \
  | grep -iE 'buffer overflow|WAL replay needed'"

4. Recovery A — re-pull AppsFlyer (MMP / AF data gaps) 4. Recuperacion A — re-jalar AppsFlyer (huecos de data MMP / AF)

Use this when the gap is in AppsFlyer-sourced data (purchases pulled from AF). Because the pull dedups by sale_hash, it is safe to re-run any window — you can't double-count. Usa esto cuando el hueco es de data originada en AppsFlyer (compras jaladas de AF). Como el pull deduplica por sale_hash, es seguro re-ejecutar cualquier ventana — no puedes duplicar conteos.

Trigger a manual pull with POST /pull/trigger and an admin Bearer token (ADMIN_TOKEN). The JSON body sets the window in RFC3339 (from / to); omit the body to run the default hourly window. The pull runs in the background and returns 202 immediately — watch last_pull_at on /health for progress. Dispara un pull manual con POST /pull/trigger y un Bearer token de admin (ADMIN_TOKEN). El body JSON define la ventana en RFC3339 (from / to); omite el body para correr la ventana default por hora. El pull corre en segundo plano y regresa 202 de inmediato — vigila last_pull_at en /health para ver el progreso.

# Re-pull a narrow window (proxied through the tunnel)
curl -X POST https://ingest.relo.mx/pull/trigger \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"from":"2026-05-28T00:00:00Z","to":"2026-05-30T00:00:00Z"}'

# Response (202 Accepted):
# {"ok":true,"status":"started","note":"pull running in background — watch /health last_pull_at"}

On the Hetzner box, read ADMIN_TOKEN straight from the systemd environment so you never paste the secret: En el servidor Hetzner, lee ADMIN_TOKEN directo del environment de systemd para nunca pegar el secreto:

ssh root@178.156.195.219

# Pull ADMIN_TOKEN from the running unit's environment
ADMIN_TOKEN=$(systemctl show relo-ingest -p Environment \
  | tr ' ' '\n' | grep '^ADMIN_TOKEN=' | cut -d= -f2-)

curl -X POST http://localhost:4080/pull/trigger \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"from":"2026-05-28T00:00:00Z","to":"2026-05-30T00:00:00Z"}'

Pull narrow windows (2–3 days at a time). A huge range floods the ClickHouse write buffer in one shot — which is the very condition you're recovering from. Walk a long gap in several short pulls and watch buffer_events drain back to 0 between them.Jala ventanas estrechas (2–3 dias a la vez). Un rango enorme inunda el buffer de escritura de ClickHouse de golpe — que es justo la condicion de la que te estas recuperando. Recorre un hueco largo en varios pulls cortos y observa buffer_events bajar a 0 entre cada uno.

5. Recovery B — WAL replay (pixel / click events) 5. Recuperacion B — replay del WAL (eventos de pixel / click)

Use this when the gap is in pixel or click events that can't be re-pulled from AF. When the ClickHouse buffer overflows it drops events that are already in the R2 WAL — this endpoint reads them back from the WAL and re-inserts them into CH. Usa esto cuando el hueco es de eventos de pixel o click que no se pueden re-jalar de AF. Cuando el buffer de ClickHouse se desborda, descarta eventos que ya estan en el WAL de R2 — este endpoint los lee del WAL y los re-inserta en CH.

POST /admin/wal-replay?hours=N with an admin Bearer token. hours defaults to 6 and is capped at 48 (so a single call never floods the live buffer). It returns {files_scanned, events_replayed, errors}. Replay is idempotent — already-present events collapse via the ReplacingMergeTree, so over-shooting the window is harmless. POST /admin/wal-replay?hours=N con un Bearer token de admin. hours default es 6 y esta limitado a 48 (asi una sola llamada nunca inunda el buffer vivo). Regresa {files_scanned, events_replayed, errors}. El replay es idempotente — los eventos ya presentes se colapsan via el ReplacingMergeTree, asi que pasarte de la ventana es inofensivo.

# Replay the last 12 hours of the events WAL
curl -X POST "https://ingest.relo.mx/admin/wal-replay?hours=12" \
  -H "Authorization: Bearer $ADMIN_TOKEN"

# Response:
# {"ok":true,"hours":12,"files_scanned":48,"events_replayed":91204,"errors":[]}

WAL replay streams into the same CH buffer as live writes. Keep hours reasonable and let buffer_events drain to 0 before replaying again. The WAL only covers what was written to R2 — for AF data prefer Recovery A.El replay del WAL entra al mismo buffer de CH que las escrituras vivas. Manten hours razonable y deja que buffer_events baje a 0 antes de re-ejecutar. El WAL solo cubre lo que se escribio a R2 — para data de AF prefiere la Recuperacion A.

6. Verify recovery 6. Verifica la recuperacion

Confirm the gap is filled and the pipeline is back to baseline. Re-run the daily-count query and check that the dipped day now matches its neighbors. Then re-check /health. Confirma que el hueco se lleno y que el pipeline volvio al baseline. Re-ejecuta la consulta de conteo diario y verifica que el dia que cayo ahora coincide con sus vecinos. Luego re-revisa /health.

# Re-query daily counts — the previously-dipped day should now match
ssh root@178.156.195.219 "clickhouse-client -d relo -q \
  \"SELECT toDate(event_time) AS d, count() AS n
    FROM relo.events
    WHERE event_time >= now() - INTERVAL 14 DAY
    GROUP BY d ORDER BY d\""

# Confirm the pipeline is back to baseline
curl -s https://ingest.relo.mx/health | jq '{buffer_events, dlq_depth, last_pull_at, last_pull_stats}'

Recovery is complete when buffer_events is back to 0, all dlq_depth entries are zero, and the daily counts have no remaining dip. La recuperacion termina cuando buffer_events volvio a 0, todas las entradas de dlq_depth estan en cero, y los conteos diarios ya no tienen ningun hueco.

7. Backbone deploy 7. Deploy del backbone

Build a Linux binary, copy it up, swap it in with a .bak backup, restart, and verify health. The backup makes a rollback a one-line copy if anything looks wrong after restart. Compila un binario Linux, subelo, intercambialo con un respaldo .bak, reinicia, y verifica la salud. El respaldo hace que un rollback sea una copia de una linea si algo se ve mal despues del reinicio.

# 1) Build the linux/amd64 binary (from the repo root)
cd backbone && GOOS=linux GOARCH=amd64 go build -o relo-ingest-new ./cmd/ingest/

# 2) Copy it to the box
scp relo-ingest-new root@178.156.195.219:/opt/relo/relo-ingest-new

# 3) Swap (with a .bak backup), restart, then verify
ssh root@178.156.195.219 "systemctl stop relo-ingest \
  && cp /opt/relo-ingest/relo-ingest /opt/relo-ingest/relo-ingest.bak \
  && cp /opt/relo/relo-ingest-new /opt/relo-ingest/relo-ingest \
  && chmod +x /opt/relo-ingest/relo-ingest \
  && systemctl start relo-ingest"

# 4) Verify it came back up
curl -s https://ingest.relo.mx/health | jq '.status'
ssh root@178.156.195.219 "journalctl -u relo-ingest -f"

Rollback: if /health doesn't come back clean, restore the backup — systemctl stop relo-ingest && cp /opt/relo-ingest/relo-ingest.bak /opt/relo-ingest/relo-ingest && systemctl start relo-ingest.Rollback: si /health no regresa limpio, restaura el respaldo — systemctl stop relo-ingest && cp /opt/relo-ingest/relo-ingest.bak /opt/relo-ingest/relo-ingest && systemctl start relo-ingest.

8. Escalation note 8. Nota de escalacion

For AppsFlyer-sourced gaps, also reconcile against the AppsFlyer dashboard as the external source of truth. The Pull API has a limited lookback window — very old gaps may not be fully re-servable by the API even though a re-pull runs cleanly. If a re-pull reports zero rows for a date you know had sales, the data has aged out of AF's servable range; export the window from the AppsFlyer dashboard and reconcile manually instead of assuming the pull failed. Para huecos originados en AppsFlyer, reconcilia tambien contra el dashboard de AppsFlyer como fuente de verdad externa. La Pull API tiene una ventana de lookback limitada — huecos muy viejos pueden no ser re-servibles por completo via la API aunque el re-pull corra limpio. Si un re-pull reporta cero filas para una fecha que sabes que tuvo ventas, la data ya salio del rango servible de AF; exporta la ventana desde el dashboard de AppsFlyer y reconcilia manualmente en lugar de asumir que el pull fallo.