Skip to content

Commit adafc0a

Browse files
authored
feat: health probes, batch lock, chunked worker, trigram search (#823)
* feat: add /healthz /readyz probes with Redis check and distributed batch lock (#721, #722) * feat: chunked disbursement executor and trigram autocomplete (#723, #725)
1 parent 11423e4 commit adafc0a

6 files changed

Lines changed: 286 additions & 13 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
-- #725: Enable pg_trgm extension and add GIN trigram index for
2+
-- fast prefix/similarity search on usernames.
3+
4+
CREATE EXTENSION IF NOT EXISTS pg_trgm;
5+
6+
-- GIN trigram index on lowercased username for ILIKE 'query%' and
7+
-- similarity() queries. This makes autocomplete searches scale to
8+
-- large user tables.
9+
CREATE INDEX IF NOT EXISTS idx_users_username_trgm
10+
ON users USING GIN (LOWER(username) gin_trgm_ops);

backend/src/api/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ pub fn user_routes_with_state(state: user::UserState) -> Router {
7474
)
7575
.route("/search", get(user::search_users))
7676
.route("/suggestions", get(user::suggest_usernames))
77+
.route("/autocomplete", get(user::autocomplete))
7778
.route("/friends", get(user::list_friends))
7879
.route("/friends/request", post(user::send_friend_request))
7980
.route("/friends/:id/accept", post(user::accept_friend_request))

backend/src/api/user.rs

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -407,6 +407,81 @@ pub async fn suggest_usernames(
407407
.into_response()
408408
}
409409

410+
/// GET /api/users/autocomplete?q=&limit=
411+
///
412+
/// Fast prefix-matching search endpoint backed by a pg_trgm GIN index (#725).
413+
/// Uses `ILIKE 'query%'` which the trigram index accelerates for large tables.
414+
/// Falls back to similarity scoring when the prefix index is insufficient.
415+
pub async fn autocomplete(
416+
State(pool): State<sqlx::PgPool>,
417+
axum::extract::Query(params): axum::extract::Query<SearchQuery>,
418+
) -> impl IntoResponse {
419+
let term = params.q.trim().to_lowercase();
420+
if term.is_empty() {
421+
return (
422+
StatusCode::BAD_REQUEST,
423+
Json(serde_json::json!({ "error": "q must not be empty" })),
424+
)
425+
.into_response();
426+
}
427+
if term.chars().count() > USERNAME_MAX_LEN {
428+
return (
429+
StatusCode::BAD_REQUEST,
430+
Json(serde_json::json!({
431+
"error": format!("q must be at most {} characters", USERNAME_MAX_LEN)
432+
})),
433+
)
434+
.into_response();
435+
}
436+
437+
let limit = params.limit.unwrap_or(10).clamp(1, 25);
438+
let pattern = format!("{}%", escape_like_pattern(&term));
439+
440+
// ILIKE 'query%' is served by the trigram GIN index (idx_users_username_trgm)
441+
// and by the btree text_pattern_ops index (idx_users_username_lower_pattern).
442+
let rows = match sqlx::query(
443+
r#"
444+
SELECT username, address, avatar_url,
445+
similarity(LOWER(username), $1) AS score
446+
FROM users
447+
WHERE LOWER(username) ILIKE $2 ESCAPE '\'
448+
ORDER BY score DESC, LOWER(username) ASC
449+
LIMIT $3
450+
"#,
451+
)
452+
.bind(&term)
453+
.bind(&pattern)
454+
.bind(limit)
455+
.fetch_all(&pool)
456+
.await
457+
{
458+
Ok(rows) => rows,
459+
Err(e) => {
460+
tracing::error!("Autocomplete query failed: {:?}", e);
461+
return (
462+
StatusCode::INTERNAL_SERVER_ERROR,
463+
Json(serde_json::json!({ "error": "Internal database error" })),
464+
)
465+
.into_response();
466+
}
467+
};
468+
469+
let results: Vec<UserSearchItem> = rows
470+
.into_iter()
471+
.map(|row| UserSearchItem {
472+
username: row.get("username"),
473+
address: row.get("address"),
474+
avatar_url: row.get("avatar_url"),
475+
})
476+
.collect();
477+
478+
Json(serde_json::json!({
479+
"query": term,
480+
"results": results,
481+
}))
482+
.into_response()
483+
}
484+
410485
pub async fn list_friends(State(pool): State<sqlx::PgPool>, auth: AuthUser) -> impl IntoResponse {
411486
let rows = match sqlx::query(
412487
r#"

backend/src/main.rs

Lines changed: 104 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ use axum::{
99
Json, Router,
1010
};
1111
use chrono::Utc;
12+
use redis;
1213
use serde::Serialize;
1314
use std::collections::HashMap;
1415
use std::net::SocketAddr;
@@ -32,6 +33,7 @@ use zaps_backend::services;
3233
struct HealthState {
3334
pool: sqlx::PgPool,
3435
stellar_rpc_url: String,
36+
redis_url: Option<String>,
3537
}
3638

3739
#[derive(Serialize)]
@@ -64,11 +66,20 @@ struct RpcHealth {
6466
error: Option<String>,
6567
}
6668

69+
#[derive(Serialize)]
70+
struct RedisHealth {
71+
status: &'static str,
72+
latency_ms: u64,
73+
#[serde(skip_serializing_if = "Option::is_none")]
74+
error: Option<String>,
75+
}
76+
6777
#[derive(Serialize)]
6878
struct HealthComponents {
6979
database: DbHealth,
7080
yield_db: YieldDbHealth,
7181
soroban_rpc: RpcHealth,
82+
redis: RedisHealth,
7283
}
7384

7485
#[derive(Serialize)]
@@ -227,15 +238,18 @@ async fn main() {
227238
let bridge_state =
228239
api::bridge::BridgeState::new(pool.clone(), config.allbridge_api_url.clone());
229240

230-
// Health check state: pool + Soroban RPC URL for live component probing.
241+
// Health check state: pool + Soroban RPC URL + Redis for live component probing.
231242
let health_state = HealthState {
232243
pool: pool.clone(),
233244
stellar_rpc_url: config.stellar_rpc_url.clone(),
245+
redis_url: config.redis_url.clone(),
234246
};
235247

236248
// Setup routes
237249
let public_routes = Router::new()
238250
.route("/health", get(health_check))
251+
.route("/healthz", get(liveness_probe))
252+
.route("/readyz", get(readiness_probe))
239253
.route("/api/v1/config", get(app_config))
240254
.with_state(health_state);
241255

@@ -390,21 +404,26 @@ async fn app_config() -> Json<AppConfigResponse> {
390404
// ── /health handler ───────────────────────────────────────────────────────────
391405

392406
async fn health_check(State(state): State<HealthState>) -> impl IntoResponse {
393-
// Run all three probes concurrently so latencies don't stack.
394-
let (db, yield_db, rpc) = tokio::join!(
407+
// Run all probes concurrently so latencies don't stack.
408+
let (db, yield_db, rpc, redis) = tokio::join!(
395409
probe_database(&state.pool),
396410
probe_yield_db(&state.pool),
397411
probe_soroban_rpc(&state.stellar_rpc_url),
412+
probe_redis(state.redis_url.as_deref()),
398413
);
399414

400-
let all_ok = db.status == "ok" && yield_db.status == "ok" && rpc.status == "ok";
415+
let all_ok = db.status == "ok"
416+
&& yield_db.status == "ok"
417+
&& rpc.status == "ok"
418+
&& redis.status == "ok";
401419

402420
let body = HealthResponse {
403421
status: if all_ok { "ok" } else { "degraded" },
404422
components: HealthComponents {
405423
database: db,
406424
yield_db,
407425
soroban_rpc: rpc,
426+
redis,
408427
},
409428
checked_at: Utc::now().to_rfc3339(),
410429
};
@@ -418,6 +437,46 @@ async fn health_check(State(state): State<HealthState>) -> impl IntoResponse {
418437
(code, Json(body))
419438
}
420439

440+
/// GET /healthz — lightweight liveness probe for Kubernetes.
441+
/// Returns 200 if the process is alive; does not check dependencies.
442+
async fn liveness_probe() -> impl IntoResponse {
443+
Json(serde_json::json!({ "status": "ok" }))
444+
}
445+
446+
/// GET /readyz — Kubernetes readiness probe.
447+
/// Verifies DB and Redis are reachable; returns HTTP 200 only when both pass.
448+
async fn readiness_probe(State(state): State<HealthState>) -> impl IntoResponse {
449+
let (db, redis) = tokio::join!(
450+
probe_database(&state.pool),
451+
probe_redis(state.redis_url.as_deref()),
452+
);
453+
454+
let db_ok = db.status == "ok";
455+
let redis_ok = redis.status == "ok" || state.redis_url.is_none();
456+
457+
let status = if db_ok && redis_ok { "ok" } else { "not ready" };
458+
let code = if db_ok && redis_ok {
459+
StatusCode::OK
460+
} else {
461+
StatusCode::SERVICE_UNAVAILABLE
462+
};
463+
464+
let mut body = serde_json::json!({
465+
"status": status,
466+
"db": db_ok,
467+
"redis": redis_ok,
468+
});
469+
470+
if let Some(e) = &db.error {
471+
body["db_error"] = serde_json::json!(e);
472+
}
473+
if let Some(e) = &redis.error {
474+
body["redis_error"] = serde_json::json!(e);
475+
}
476+
477+
(code, Json(body))
478+
}
479+
421480
// ── Component probes ──────────────────────────────────────────────────────────
422481

423482
/// Basic Postgres connectivity: a single round-trip to the DB pool.
@@ -537,3 +596,44 @@ async fn probe_soroban_rpc(rpc_url: &str) -> RpcHealth {
537596
},
538597
}
539598
}
599+
600+
/// Redis PING probe. Returns "ok" when Redis responds with PONG.
601+
async fn probe_redis(redis_url: Option<&str>) -> RedisHealth {
602+
let start = Instant::now();
603+
604+
let Some(url) = redis_url else {
605+
return RedisHealth {
606+
status: "skipped",
607+
latency_ms: 0,
608+
error: None,
609+
};
610+
};
611+
612+
let result: Result<(), String> = async {
613+
let client = redis::Client::open(url).map_err(|e| e.to_string())?;
614+
let mut conn = redis::tokio::aio::ConnectionManager::new(client)
615+
.await
616+
.map_err(|e| e.to_string())?;
617+
redis::cmd("PING")
618+
.query_async::<String>(&mut conn)
619+
.await
620+
.map_err(|e| e.to_string())?;
621+
Ok(())
622+
}
623+
.await;
624+
625+
let latency_ms = start.elapsed().as_millis() as u64;
626+
627+
match result {
628+
Ok(()) => RedisHealth {
629+
status: "ok",
630+
latency_ms,
631+
error: None,
632+
},
633+
Err(e) => RedisHealth {
634+
status: "error",
635+
latency_ms,
636+
error: Some(e),
637+
},
638+
}
639+
}

backend/src/services/disbursement_worker.rs

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,9 @@ const DEFAULT_MAX_ATTEMPTS: i32 = 3;
5050
const DEFAULT_CLAIM_SIZE: i64 = 100;
5151
/// A claim older than this is assumed to belong to a dead worker.
5252
const DEFAULT_LEASE_TIMEOUT_SECS: i64 = 900;
53+
/// Maximum items per sub-chunk when processing a batch claim.
54+
/// Prevents gas limit issues on-chain and keeps SDP submissions bounded.
55+
const CHUNK_SIZE: usize = 50;
5356

5457
pub struct DisbursementWorkerConfig {
5558
pub poll_interval: Duration,
@@ -193,25 +196,35 @@ async fn process_cycle(
193196
"Dispatching payout batch"
194197
);
195198

196-
// Sequential on purpose: SDP rate-limits per account, and parallel
197-
// submissions from one source account contend on the Stellar sequence
198-
// number.
199-
for recipient in recipients {
200-
let attempt = recipient.attempt_count + 1;
201-
let outcome = dispatch_one(&recipient, sdp_client).await;
199+
// Process recipients in sub-chunks of CHUNK_SIZE to prevent gas limit
200+
// issues on-chain and keep SDP submissions bounded. Each chunk is
201+
// submitted sequentially; recipients within a chunk are also sequential
202+
// (SDP rate-limits per account, and parallel submissions contend on
203+
// the Stellar sequence number).
204+
for (chunk_idx, chunk) in recipients.chunks(CHUNK_SIZE).enumerate() {
205+
tracing::debug!(
206+
batch_id = %batch_id,
207+
chunk = chunk_idx + 1,
208+
chunk_size = chunk.len(),
209+
"Processing chunk"
210+
);
211+
212+
for recipient in chunk {
213+
let attempt = recipient.attempt_count + 1;
214+
let outcome = dispatch_one(recipient, sdp_client).await;
202215

203216
match outcome {
204217
SdpOutcome::Submitted {
205218
payment_id: sdp_payment_id,
206219
tx_hash,
207220
} => {
208-
mark_submitted(pool, &recipient, sdp_payment_id.as_deref(), tx_hash.as_deref())
221+
mark_submitted(pool, recipient, sdp_payment_id.as_deref(), tx_hash.as_deref())
209222
.await?;
210223
log_dispatch(pool, batch_id, Some(recipient.id), attempt, "SUBMITTED", None, None)
211224
.await?;
212225
}
213226
SdpOutcome::Retryable(err) if attempt < config.max_attempts => {
214-
mark_retry(pool, &recipient, &err).await?;
227+
mark_retry(pool, recipient, &err).await?;
215228
log_dispatch(
216229
pool,
217230
batch_id,
@@ -226,7 +239,7 @@ async fn process_cycle(
226239
SdpOutcome::Retryable(err) | SdpOutcome::Permanent(err) => {
227240
// Either permanently bad, or out of retries. Fail this row only
228241
// — one dead recipient must not strand the rest of the batch.
229-
mark_failed(pool, &recipient, &err).await?;
242+
mark_failed(pool, recipient, &err).await?;
230243
log_dispatch(
231244
pool,
232245
batch_id,
@@ -240,6 +253,7 @@ async fn process_cycle(
240253
}
241254
}
242255
}
256+
} // end chunk loop
243257

244258
finalize_batch(pool, batch_id).await?;
245259
Ok(())

0 commit comments

Comments
 (0)