Skip to content

Commit b31cf88

Browse files
committed
fix(node): implement reconciliation sweep as durability backstop (#218)
1 parent ad7c2b2 commit b31cf88

3 files changed

Lines changed: 322 additions & 2 deletions

File tree

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ mod operator;
1616
mod p2p;
1717
mod pinata;
1818
mod rate_limit;
19+
mod reconciliation;
1920
mod server;
2021
mod state;
2122
mod sync;
@@ -498,6 +499,19 @@ async fn main() -> Result<()> {
498499
info!("auto-sync worker started");
499500
}
500501

502+
// Periodic reconciliation sweep: re-derives pin/seal sets and fills gaps
503+
// so a dropped replication job never means data loss.
504+
{
505+
let db = state.db.clone();
506+
let config = Arc::clone(&state.config);
507+
let http_client = Arc::clone(&state.http_client);
508+
let node_keypair = Arc::clone(&state.node_keypair);
509+
let node_did = state.node_did.clone();
510+
let shutdown_rx = state.subscribe_shutdown();
511+
reconciliation::spawn(db, config, http_client, node_keypair, node_did, shutdown_rx);
512+
info!("reconciliation sweep worker started");
513+
}
514+
501515
// On-chain operator setup: verify stake + spawn heartbeat loop
502516
if !state.config.contract_node_staking.is_empty()
503517
&& !state.config.operator_private_key.is_empty()

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

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@
1515
//! `gitlawb_pack_size_bytes`
1616
//! * a single `gitlawb_info{version, did}` gauge = 1, for joins/dashboards
1717
//! * currently-connected peer count — `gitlawb_peers_connected`
18+
//! * reconciliation sweep gaps found and filled —
19+
//! `gitlawb_reconciliation_gaps_found_total` /
20+
//! `gitlawb_reconciliation_gaps_filled_total`
1821
//!
1922
//! All metrics live in a single process-wide registry initialized by
2023
//! [`init`]. Increment helpers (`record_push`, `record_auth_failure`, ...)
@@ -33,8 +36,8 @@
3336
use std::sync::OnceLock;
3437

3538
use prometheus::{
36-
Encoder, Histogram, HistogramOpts, IntCounterVec, IntGauge, IntGaugeVec, Opts, Registry,
37-
TextEncoder,
39+
Encoder, Histogram, HistogramOpts, IntCounter, IntCounterVec, IntGauge, IntGaugeVec, Opts,
40+
Registry, TextEncoder,
3841
};
3942

4043
/// The single, process-wide metrics registry. Initialized by [`init`].
@@ -51,6 +54,8 @@ static SYNC_PROCESSED: OnceLock<IntCounterVec> = OnceLock::new();
5154
static WEBHOOK_DELIVERIES: OnceLock<IntCounterVec> = OnceLock::new();
5255
static PACK_SIZE: OnceLock<Histogram> = OnceLock::new();
5356
static PEERS_CONNECTED: OnceLock<IntGauge> = OnceLock::new();
57+
static RECONCILIATION_GAPS_FOUND: OnceLock<IntCounter> = OnceLock::new();
58+
static RECONCILIATION_GAPS_FILLED: OnceLock<IntCounter> = OnceLock::new();
5459

5560
/// One-time initializer. Builds the registry, registers every metric,
5661
/// and sets the constant `gitlawb_info` gauge. Idempotent — calling
@@ -197,6 +202,30 @@ pub fn init(version: &str, node_did: &str) {
197202
.set(peers_connected)
198203
.expect("set PEERS_CONNECTED once");
199204

205+
let gaps_found = IntCounter::with_opts(Opts::new(
206+
"gitlawb_reconciliation_gaps_found_total",
207+
"Total reconciliation sweep gaps detected (objects that should be pinned but are not)",
208+
))
209+
.expect("gitlawb_reconciliation_gaps_found_total definition");
210+
registry
211+
.register(Box::new(gaps_found.clone()))
212+
.expect("register gitlawb_reconciliation_gaps_found_total");
213+
RECONCILIATION_GAPS_FOUND
214+
.set(gaps_found)
215+
.expect("set RECONCILIATION_GAPS_FOUND once");
216+
217+
let gaps_filled = IntCounter::with_opts(Opts::new(
218+
"gitlawb_reconciliation_gaps_filled_total",
219+
"Total reconciliation sweep gaps successfully filled (objects pinned by the sweep)",
220+
))
221+
.expect("gitlawb_reconciliation_gaps_filled_total definition");
222+
registry
223+
.register(Box::new(gaps_filled.clone()))
224+
.expect("register gitlawb_reconciliation_gaps_filled_total");
225+
RECONCILIATION_GAPS_FILLED
226+
.set(gaps_filled)
227+
.expect("set RECONCILIATION_GAPS_FILLED once");
228+
200229
REGISTRY
201230
.set(registry)
202231
.expect("set REGISTRY once (init must be called exactly once)");
@@ -264,6 +293,20 @@ pub fn set_peers_connected(count: i64) {
264293
}
265294
}
266295

296+
/// Record reconciliation sweep gaps found (objects that should be pinned but are not).
297+
pub fn record_reconciliation_gaps_found(count: u64) {
298+
if let Some(c) = RECONCILIATION_GAPS_FOUND.get() {
299+
c.inc_by(count);
300+
}
301+
}
302+
303+
/// Record reconciliation sweep gaps filled (objects successfully pinned by the sweep).
304+
pub fn record_reconciliation_gaps_filled(count: u64) {
305+
if let Some(c) = RECONCILIATION_GAPS_FILLED.get() {
306+
c.inc_by(count);
307+
}
308+
}
309+
267310
/// Encode the registry as the standard Prometheus text exposition format.
268311
/// Returns an error if `init` was never called.
269312
pub fn encode() -> Result<String, prometheus::Error> {
Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
use std::collections::HashSet;
2+
use std::path::PathBuf;
3+
use std::sync::Arc;
4+
use tokio::sync::watch;
5+
6+
use crate::config::Config;
7+
use crate::db::Db;
8+
9+
/// How often to run a sweep pass.
10+
const SWEEP_INTERVAL_SECS: u64 = 3600;
11+
12+
/// Maximum repos to process per pass — prevents the sweep from becoming
13+
/// the O(repos) amplification the admission-control work exists to prevent.
14+
const REPOS_PER_PASS: usize = 100;
15+
16+
/// Spawn the periodic reconciliation sweep background task.
17+
pub fn spawn(
18+
db: Arc<Db>,
19+
config: Arc<Config>,
20+
http_client: Arc<reqwest::Client>,
21+
node_keypair: Arc<gitlawb_core::identity::Keypair>,
22+
node_did: gitlawb_core::did::Did,
23+
mut shutdown_rx: watch::Receiver<bool>,
24+
) {
25+
tokio::spawn(async move {
26+
let node_seed = *node_keypair.to_seed();
27+
let mut cursor = 0usize;
28+
29+
loop {
30+
tokio::select! {
31+
_ = tokio::time::sleep(std::time::Duration::from_secs(SWEEP_INTERVAL_SECS)) => {
32+
let start = std::time::Instant::now();
33+
match run_pass(&db, &config, &http_client, &node_seed, &node_did, &mut cursor).await {
34+
Ok((count, gaps, filled)) => {
35+
tracing::info!(
36+
repos = count,
37+
gaps_found = gaps,
38+
gaps_filled = filled,
39+
elapsed_ms = start.elapsed().as_millis() as u64,
40+
"reconciliation sweep pass complete"
41+
);
42+
}
43+
Err(e) => {
44+
tracing::warn!(err = %e, "reconciliation sweep pass failed");
45+
}
46+
}
47+
}
48+
_ = shutdown_rx.changed() => {
49+
if *shutdown_rx.borrow() {
50+
tracing::info!("reconciliation sweep: shutdown signal received, exiting");
51+
return;
52+
}
53+
}
54+
}
55+
}
56+
});
57+
}
58+
59+
/// Run one sweep pass. Returns `(repos_scanned, gaps_found, gaps_filled)`.
60+
async fn run_pass(
61+
db: &Db,
62+
config: &Config,
63+
http_client: &reqwest::Client,
64+
node_seed: &[u8; 32],
65+
node_did: &gitlawb_core::did::Did,
66+
cursor: &mut usize,
67+
) -> anyhow::Result<(usize, usize, usize)> {
68+
let all = db.list_all_repos().await?;
69+
if all.is_empty() {
70+
*cursor = 0;
71+
return Ok((0, 0, 0));
72+
}
73+
74+
let start = *cursor;
75+
let end = (start + REPOS_PER_PASS).min(all.len());
76+
let batch = &all[start..end];
77+
*cursor = if end >= all.len() { 0 } else { end };
78+
79+
let mut total_gaps_found = 0usize;
80+
let mut total_gaps_filled = 0usize;
81+
82+
for repo in batch {
83+
let repo_slug = format!(
84+
"{}/{}",
85+
crate::db::normalize_owner_key(&repo.owner_did),
86+
repo.name
87+
);
88+
89+
let disk = PathBuf::from(&repo.disk_path);
90+
if !disk.exists() {
91+
tracing::warn!(repo = %repo_slug, "disk path missing, skipping");
92+
continue;
93+
}
94+
95+
let rules = match db.list_visibility_rules(&repo.id).await {
96+
Ok(r) => r,
97+
Err(e) => {
98+
tracing::warn!(repo = %repo_slug, err = %e, "visibility rules fetch failed, skipping");
99+
continue;
100+
}
101+
};
102+
103+
if !crate::visibility::listable_at_root(&rules, repo.is_public, &repo.owner_did, None) {
104+
continue;
105+
}
106+
107+
let has_path_scoped = crate::git::visibility_pack::has_path_scoped_rule(&rules);
108+
109+
let disk_clone = disk.clone();
110+
let owner_clone = repo.owner_did.clone();
111+
let rules_clone = rules.clone();
112+
let is_public = repo.is_public;
113+
let replicable = tokio::task::spawn_blocking(move || -> anyhow::Result<Vec<String>> {
114+
let all_objs = crate::git::push_delta::list_all_objects(&disk_clone)?;
115+
if all_objs.is_empty() {
116+
return Ok(Vec::new());
117+
}
118+
let allowed = if has_path_scoped {
119+
crate::git::visibility_pack::replicable_blob_set(
120+
&disk_clone,
121+
&rules_clone,
122+
is_public,
123+
&owner_clone,
124+
)?
125+
} else {
126+
HashSet::new()
127+
};
128+
let all_blobs = crate::git::push_delta::all_blob_oids(&disk_clone)?;
129+
Ok(crate::git::visibility_pack::replicable_objects_fail_closed(
130+
all_objs, &allowed, &all_blobs,
131+
))
132+
})
133+
.await;
134+
135+
let object_list = match replicable {
136+
Ok(Ok(list)) => list,
137+
Ok(Err(e)) => {
138+
tracing::warn!(repo = %repo_slug, err = %e, "full-scan failed, skipping");
139+
continue;
140+
}
141+
Err(e) => {
142+
tracing::warn!(repo = %repo_slug, err = %e, "full-scan task panicked, skipping");
143+
continue;
144+
}
145+
};
146+
147+
if object_list.is_empty() {
148+
continue;
149+
}
150+
151+
let mut repo_gaps = 0usize;
152+
for sha in &object_list {
153+
match db.is_pinned(sha).await {
154+
Ok(true) => {}
155+
_ => repo_gaps += 1,
156+
}
157+
}
158+
159+
if repo_gaps == 0 {
160+
continue;
161+
}
162+
163+
total_gaps_found += repo_gaps;
164+
165+
let pinned_ipfs =
166+
crate::ipfs_pin::pin_new_objects(&config.ipfs_api, &disk, object_list.clone(), db)
167+
.await;
168+
169+
let _pinned_pinata = crate::pinata::pin_new_objects(
170+
http_client,
171+
&config.pinata_upload_url,
172+
&config.pinata_jwt,
173+
&disk,
174+
object_list,
175+
db,
176+
)
177+
.await;
178+
179+
let repo_filled = pinned_ipfs.len();
180+
total_gaps_filled += repo_filled;
181+
182+
crate::metrics::record_reconciliation_gaps_found(repo_gaps as u64);
183+
crate::metrics::record_reconciliation_gaps_filled(repo_filled as u64);
184+
185+
if repo_filled > 0 {
186+
tracing::info!(
187+
repo = %repo_slug,
188+
gaps = repo_gaps,
189+
filled = repo_filled,
190+
"reconciliation sweep filled gaps"
191+
);
192+
}
193+
194+
if has_path_scoped && !config.ipfs_api.is_empty() {
195+
let p = disk.clone();
196+
let owner = repo.owner_did.clone();
197+
let r = rules.clone();
198+
let is_public_2 = repo.is_public;
199+
let recipients = tokio::task::spawn_blocking(move || {
200+
crate::git::visibility_pack::withheld_blob_recipients(&p, &r, is_public_2, &owner)
201+
})
202+
.await;
203+
204+
match recipients {
205+
Ok(Ok(rec)) if !rec.is_empty() => {
206+
let sealed = crate::encrypted_pin::encrypt_and_pin(
207+
&config.ipfs_api,
208+
&disk,
209+
db,
210+
&repo.id,
211+
node_seed,
212+
&rec,
213+
)
214+
.await;
215+
if !sealed.is_empty() && !config.irys_url.is_empty() {
216+
let owner_short = crate::db::normalize_owner_key(&repo.owner_did);
217+
let slug = format!("{}/{}", owner_short, repo.name);
218+
let ts = chrono::Utc::now().to_rfc3339();
219+
let node_did_str = node_did.to_string();
220+
let blobs: Vec<(String, String)> = sealed;
221+
let manifest = crate::arweave::EncryptedManifest {
222+
repo: &slug,
223+
owner_did: &repo.owner_did,
224+
node_did: &node_did_str,
225+
timestamp: &ts,
226+
blobs: &blobs,
227+
};
228+
if let Err(e) = crate::arweave::anchor_encrypted_manifest(
229+
http_client,
230+
&config.irys_url,
231+
&manifest,
232+
)
233+
.await
234+
{
235+
tracing::warn!(
236+
repo = %slug,
237+
err = %e,
238+
"encrypted manifest anchor failed (best-effort)"
239+
);
240+
}
241+
}
242+
}
243+
Ok(Ok(_)) => {}
244+
Ok(Err(e)) => {
245+
tracing::warn!(
246+
repo = %repo_slug,
247+
err = %e,
248+
"withheld_blob_recipients failed, skipping encrypted pin"
249+
);
250+
}
251+
Err(e) => {
252+
tracing::warn!(
253+
repo = %repo_slug,
254+
err = %e,
255+
"withheld_blob_recipients task panicked, skipping encrypted pin"
256+
);
257+
}
258+
}
259+
}
260+
}
261+
262+
Ok((batch.len(), total_gaps_found, total_gaps_filled))
263+
}

0 commit comments

Comments
 (0)