Skip to content

Commit 2ce4da9

Browse files
feat(node): graceful shutdown + Prometheus metrics endpoint (#22)
* feat(db): versioned schema migrations with idempotent backfill Replace the inline &str SQL array that re-runs on every node startup with a versioned migration system. Each migration is recorded in a new schema_migrations table and applied at most once per node, inside a single transaction. For nodes upgrading from a pre-migration-versioning build, the canonical `repos` table is used as a signal to mark v1 as already applied without re-running its ~140 statements — so existing operators see zero behavior change on first restart after this commit. Closes the gap called out in docs/OSS-READINESS-AUDIT.md:78. - Adds Db::migration_status() returning applied (version, name, applied_at) - Adds 6 unit tests validating the static migration catalogue (versions strictly increasing, names distinct, bodies non-empty, v1 name locked to initial_schema) - cargo fmt + clippy -D warnings + cargo test --workspace all clean * fix(db): make legacy migration backfill safe and lock-guarded Address two robustness gaps in the v1 migration runner on the live network: - Remove the "repos table present => v1 complete" backfill short-circuit. It assumed every existing node already had the full current schema; a node that was behind would be marked v1-applied while still missing newer tables or columns. Every v1 statement is idempotent (CREATE TABLE/INDEX IF NOT EXISTS, ADD COLUMN IF NOT EXISTS), so legacy installs now simply run v1 once: existing objects are no-ops, missing ones get created. - Guard the whole runner with a Postgres advisory lock so two instances against the same database (blue/green or rolling deploy) can't race to apply the same migration and trip the schema_migrations primary key. The lock is released explicitly, and automatically if the connection drops. Also document that per-migration transactions preclude CREATE INDEX CONCURRENTLY in future migrations. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(node): graceful shutdown + Prometheus metrics endpoint Two operational-safety changes that the live network needs: 1) Graceful shutdown. A process-wide tokio::sync::watch::Sender<bool> in AppState is flipped on SIGINT (all platforms) or SIGTERM (Unix). Every long-lived loop now selects on the shutdown arm: * axum server (with_graceful_shutdown — drains in-flight) * libp2p swarm (drops Swarm, closes connections) * gossip task (exits between peer announces) * sync worker (exits between batches) * operator heartbeat loop * rate-limit cleanup loop * peer-count metrics poller * the deferred DID-record publish task New GITLAWB_SHUTDOWN_GRACE_SECS (default 30s) controls how long axum waits for in-flight requests before returning. 2) Prometheus /metrics endpoint. New metrics module exposes: * gitlawb_info{version, did} (constant gauge = 1) * gitlawb_pushes_total{repo} (counter) * gitlawb_fetches_total{repo} (counter) * gitlawb_auth_successes_total{route} (counter, helper) * gitlawb_auth_failures_total{route,reason} (counter, helper) * gitlawb_sync_queue_processed_total{status} (counter) * gitlawb_webhook_deliveries_total{result} (counter) * gitlawb_pack_size_bytes (histogram) * gitlawb_peers_connected (gauge) Opt-in via GITLAWB_METRICS_ADDR (e.g. 127.0.0.1:9091). The endpoint is on a separate listener so the public port stays unexposed; bind to localhost or a private interface. 3) Metric instrumentation hooks in api/repos.rs (push/fetch), webhooks.rs (delivery outcomes), and sync.rs (per-item status). The auth helpers are defined but not yet wired — that's a focused follow-up. Tests: * 3 new metrics::tests::* — encode returns valid prometheus text, record_* helpers are no-ops before init(), encode() errors if init() was never called. * cargo fmt + clippy -D warnings + cargo test --workspace all clean. Closes the metrics and graceful-shutdown gaps in docs/OSS-READINESS-AUDIT.md:131 and docs/MAINTAINER-ROADMAP.md:35. --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 927e4d0 commit 2ce4da9

12 files changed

Lines changed: 771 additions & 63 deletions

File tree

‎Cargo.lock‎

Lines changed: 15 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎crates/gitlawb-node/Cargo.toml‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,10 @@ aws-config = { version = "1", features = ["behavior-version-latest"] }
5151
async-compression = { version = "0.4", features = ["tokio", "zstd"] }
5252
tar = "0.4"
5353
zstd = "0.13"
54+
# Prometheus metrics. Used to expose a /metrics endpoint for ops/observability
55+
# on the opt-in GITLAWB_METRICS_ADDR listener. The crate is also the de-facto
56+
# exposition format encoder in the Rust ecosystem.
57+
prometheus = { version = "0.13", default-features = false }
5458
alloy = { version = "1", default-features = false, features = [
5559
"contract",
5660
"provider-http",

‎crates/gitlawb-node/src/api/repos.rs‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -366,7 +366,8 @@ pub async fn git_upload_pack(
366366
.acquire(&record.owner_did, &record.name)
367367
.await
368368
.map_err(|e| AppError::Git(e.to_string()))?;
369-
smart_http::upload_pack(&disk_path, body)
369+
let body_len = body.len();
370+
let resp = smart_http::upload_pack(&disk_path, body)
370371
.await
371372
.map_err(|e| {
372373
let msg = e.to_string();
@@ -377,7 +378,10 @@ pub async fn git_upload_pack(
377378
tracing::error!(repo = %name, err = %msg, "git-upload-pack failed");
378379
AppError::Git(msg)
379380
}
380-
})
381+
})?;
382+
crate::metrics::record_fetch(&format!("{owner}/{name}"));
383+
crate::metrics::observe_pack_size(body_len as f64);
384+
Ok(resp)
381385
}
382386

383387
/// POST /:owner/:repo.git/git-receive-pack (AUTH REQUIRED — enforced by middleware)
@@ -451,6 +455,7 @@ pub async fn git_receive_pack(
451455
})?;
452456
let disk_path = guard.path().to_path_buf();
453457
tracing::debug!(repo = %name, path = %disk_path.display(), "running git receive-pack");
458+
let body_len = body.len();
454459
let receive_result = smart_http::receive_pack(&disk_path, body).await;
455460

456461
// Always release the advisory lock — even on error — to prevent stale locks
@@ -465,6 +470,11 @@ pub async fn git_receive_pack(
465470
// Update the repo's updated_at timestamp after a successful push
466471
let _ = state.db.touch_repo(&record.id).await;
467472

473+
// Record the successful push for metrics. The body has already been
474+
// consumed by smart_http::receive_pack so we observe size up front.
475+
crate::metrics::record_push(&record.id);
476+
crate::metrics::observe_pack_size(body_len as f64);
477+
468478
// Record push event for trust score and issue a signed ref certificate
469479
let pusher_did = extract_did_from_auth(&headers);
470480
if let Some(ref did) = pusher_did {

‎crates/gitlawb-node/src/auth/mod.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -502,6 +502,7 @@ mod tests {
502502
machine_id: None,
503503
repo_store: crate::git::repo_store::RepoStore::for_testing(PathBuf::from("/tmp"), pool),
504504
rate_limiter: RateLimiter::new(100, Duration::from_secs(60)),
505+
shutdown_tx: tokio::sync::watch::channel(false).0,
505506
}
506507
}
507508

‎crates/gitlawb-node/src/config.rs‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,19 @@ pub struct Config {
131131
/// Default: 2 GB. Set lower on resource-constrained nodes.
132132
#[arg(long, env = "GITLAWB_MAX_PACK_BYTES", default_value_t = 2_147_483_648)]
133133
pub max_pack_bytes: usize,
134+
135+
/// Optional address to bind a Prometheus `/metrics` exposition endpoint on.
136+
/// Example: `127.0.0.1:9091`. Leave empty (default) to disable.
137+
/// Bind to localhost or a private interface — the metrics endpoint is
138+
/// unauthenticated.
139+
#[arg(long, env = "GITLAWB_METRICS_ADDR", default_value = "")]
140+
pub metrics_addr: String,
141+
142+
/// Maximum time to wait for in-flight requests to drain on shutdown, in
143+
/// seconds. After this elapses, the server returns 503 to anything still
144+
/// in flight and exits. Default: 30s.
145+
#[arg(long, env = "GITLAWB_SHUTDOWN_GRACE_SECS", default_value_t = 30)]
146+
pub shutdown_grace_secs: u64,
134147
}
135148

136149
impl Config {

0 commit comments

Comments
 (0)