Skip to content

Commit a8e2c48

Browse files
fix(relay): make readiness local and stop dropping sockets on DB errors
A reconnect burst exhausted the per-pod writer pools and two feedback loops turned that into a total outage. Readiness evaluated shared Postgres, Redis, and deletion-catalog health, so every replica went NotReady together and the burst had nowhere to land. The probe was also part of the load: the deletion-catalog check acquires the writer pool, so each pod spent writer connections against the exhausted pool every five seconds while failing. /_readiness now answers from local process lifecycle only — shutting_down is 503, anything else is 200 — and the dependency evaluation moves to /_status on the same private health listener, under a `dependencies` object carrying the fields the readiness body used to return. No startup state is added: the health listener binds only after the database, migrations, Redis, and pub/sub are up, so a process that can answer has booted. run_registered_community_connection collapsed Ok(false) and Err into "not active", so a writer-pool timeout in is_community_active read as confirmed archival and dropped the socket, which reconnected and re-checked. Only a confirmed Ok(false) cancels now; a lookup failure admits the socket with a structured warning and defers to the periodic revalidate_live_communities backstop. Writes are unaffected and remain fail-closed on their own per-event fence. Telemetry keeps its existing names: buzz_readiness_checks_total narrows to {ready, shutting_down}, the dependency families are now sampled by /_status, dependency gauges are dropped, and one new bounded counter, buzz_community_admission_checks_total{outcome}, counts the admission decision. The per-pod raw-series ceiling drops from 99 to 86. This deletes the readiness publication machinery — the mutex, probe generations, ProbeTicket/ProbeStart, finish_probe, finish_public_evaluation, and a second shutdown flag duplicating AppState::shutting_down. All of it existed to order concurrent async dependency evaluations against shutdown. Readiness is now a single atomic load, so the one ordering guarantee still worth keeping — a racing shutdown must win, and never leave a draining pod advertising a ready gauge — is a post-write re-read in record_readiness_probe rather than a generation-fenced mutex. Co-authored-by: Claude Code <noreply@anthropic.com> Redis had no startup gate at all. `deadpool_redis` pools dial lazily and PubSubManager::new only allocates channels, so "Redis pub/sub connected" was logged against a dead port and boot ran to completion. With readiness now answering from local lifecycle alone, such a pod bound its health listener and advertised ready for the rest of its life. state:: verify_redis_command_path acquires one connection from the command pool and issues PING before AppState is built, and therefore before the health listener binds, because binding is the one-way latch that makes a pod routable. No startup_ready flag is added for the same reason. Post-start Redis failures are unchanged: they are dependency failures and never move readiness. Postgres startup connection behavior is untouched. Signed-off-by: tornquist <tornquist@squareup.com>
1 parent e09f715 commit a8e2c48

9 files changed

Lines changed: 932 additions & 772 deletions

File tree

ARCHITECTURE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -625,7 +625,7 @@ pub enum AuthState { Pending { challenge: String }, Authenticated(AuthContext),
625625
| GET | `/.well-known/nostr.json` | NIP-05 identity |
626626
| GET | `/health` | Health check |
627627
| GET | `/_liveness` | Liveness probe |
628-
| GET | `/_readiness` | Readiness probe |
628+
| GET | `/_readiness` | Readiness probe — local process lifecycle only |
629629
| POST | `/events` | Submit a signed Nostr event over HTTP (same ingest path as WebSocket `EVENT`) |
630630
| POST | `/query` | Query Nostr events over HTTP with NIP-01 filters |
631631
| POST | `/count` | Count Nostr events over HTTP with NIP-45 filters |

crates/buzz-relay/src/main.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -454,7 +454,13 @@ async fn run_relay_main(boot: BootTracker) -> anyhow::Result<()> {
454454
cfg.create_pool(Some(deadpool_redis::Runtime::Tokio1))
455455
.map_err(|e| anyhow::anyhow!("Redis pool creation failed: {e}"))?
456456
};
457-
let redis_health_pool = redis_pool.clone(); // cheap Arc clone — shared with readiness handler
457+
let redis_health_pool = redis_pool.clone(); // cheap Arc clone — shared with AppState
458+
// One-time bootstrap gate, deliberately before AppState and therefore before
459+
// the health listener binds. Post-start Redis failures are dependency
460+
// failures and must never move readiness; never having connected at all is
461+
// a broken deployment, not a blip.
462+
buzz_relay::state::verify_redis_command_path(&redis_health_pool).await?;
463+
info!("Redis command path connected");
458464
let pubsub = Arc::new(
459465
PubSubManager::new(&config.redis_url, redis_pool)
460466
.await

crates/buzz-relay/src/metrics.rs

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,7 @@ pub fn try_install(port: u16, gauge_idle_timeout_secs: u64) -> Result<(), Metric
205205
metrics::set_global_recorder(recorder)
206206
.map_err(|_error| MetricsInstallError::RecorderConflict)?;
207207
describe_readiness_metrics();
208+
describe_community_admission_metrics();
208209
describe_db_pool_metrics();
209210
tokio::spawn(exporter);
210211
Ok(())
@@ -219,24 +220,37 @@ pub fn install(port: u16, gauge_idle_timeout_secs: u64) {
219220
.unwrap_or_else(|error| panic!("metrics exporter must install exactly once: {error}"));
220221
}
221222

222-
/// Register the frozen readiness metric descriptions with the active recorder.
223+
/// Register the frozen readiness and dependency-diagnostic metric descriptions.
224+
///
225+
/// The two `buzz_readiness_*` probe families describe local process lifecycle.
226+
/// The two dependency families keep their names for dashboard continuity but
227+
/// are sampled by the diagnostic `/_status` endpoint, not by the Kubernetes
228+
/// probe — a shared-dependency failure no longer deroutes the pod.
223229
pub(crate) fn describe_readiness_metrics() {
224230
metrics::describe_counter!(
225231
"buzz_readiness_checks_total",
226-
"Kubernetes health-listener readiness probes by terminal bounded reason"
232+
"Kubernetes health-listener readiness probes by lifecycle reason (ready, shutting_down)"
227233
);
228234
metrics::describe_counter!(
229235
"buzz_readiness_dependency_checks_total",
230-
"Completed readiness dependency attempts by dependency and bounded outcome"
236+
"Completed /_status dependency attempts by dependency and bounded outcome"
231237
);
232238
metrics::describe_histogram!(
233239
"buzz_readiness_check_duration_seconds",
234240
metrics::Unit::Seconds,
235-
"Completed readiness check duration without outcome label multiplication"
241+
"Completed /_status dependency check duration without outcome label multiplication"
236242
);
237243
metrics::describe_gauge!(
238244
"buzz_readiness_state",
239-
"Latest publishable readiness state by check, where 1 is ready and 0 is not ready"
245+
"Local readiness of this process, where 1 is ready and 0 is shutting down"
246+
);
247+
}
248+
249+
/// Register the bounded community-admission contract.
250+
pub(crate) fn describe_community_admission_metrics() {
251+
metrics::describe_counter!(
252+
"buzz_community_admission_checks_total",
253+
"Durable community-active checks at socket admission by bounded outcome"
240254
);
241255
}
242256

0 commit comments

Comments
 (0)