Skip to content

Commit 5c771ec

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

3 files changed

Lines changed: 330 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: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
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,
131+
&allowed,
132+
&all_blobs,
133+
))
134+
})
135+
.await;
136+
137+
let object_list = match replicable {
138+
Ok(Ok(list)) => list,
139+
Ok(Err(e)) => {
140+
tracing::warn!(repo = %repo_slug, err = %e, "full-scan failed, skipping");
141+
continue;
142+
}
143+
Err(e) => {
144+
tracing::warn!(repo = %repo_slug, err = %e, "full-scan task panicked, skipping");
145+
continue;
146+
}
147+
};
148+
149+
if object_list.is_empty() {
150+
continue;
151+
}
152+
153+
let mut repo_gaps = 0usize;
154+
for sha in &object_list {
155+
match db.is_pinned(sha).await {
156+
Ok(true) => {}
157+
_ => repo_gaps += 1,
158+
}
159+
}
160+
161+
if repo_gaps == 0 {
162+
continue;
163+
}
164+
165+
total_gaps_found += repo_gaps;
166+
167+
let pinned_ipfs = crate::ipfs_pin::pin_new_objects(
168+
&config.ipfs_api,
169+
&disk,
170+
object_list.clone(),
171+
db,
172+
)
173+
.await;
174+
175+
let _pinned_pinata = crate::pinata::pin_new_objects(
176+
http_client,
177+
&config.pinata_upload_url,
178+
&config.pinata_jwt,
179+
&disk,
180+
object_list,
181+
db,
182+
)
183+
.await;
184+
185+
let repo_filled = pinned_ipfs.len();
186+
total_gaps_filled += repo_filled;
187+
188+
crate::metrics::record_reconciliation_gaps_found(repo_gaps as u64);
189+
crate::metrics::record_reconciliation_gaps_filled(repo_filled as u64);
190+
191+
if repo_filled > 0 {
192+
tracing::info!(
193+
repo = %repo_slug,
194+
gaps = repo_gaps,
195+
filled = repo_filled,
196+
"reconciliation sweep filled gaps"
197+
);
198+
}
199+
200+
if has_path_scoped && !config.ipfs_api.is_empty() {
201+
let p = disk.clone();
202+
let owner = repo.owner_did.clone();
203+
let r = rules.clone();
204+
let is_public_2 = repo.is_public;
205+
let recipients = tokio::task::spawn_blocking(move || {
206+
crate::git::visibility_pack::withheld_blob_recipients(
207+
&p, &r, is_public_2, &owner,
208+
)
209+
})
210+
.await;
211+
212+
match recipients {
213+
Ok(Ok(rec)) if !rec.is_empty() => {
214+
let sealed = crate::encrypted_pin::encrypt_and_pin(
215+
&config.ipfs_api,
216+
&disk,
217+
db,
218+
&repo.id,
219+
node_seed,
220+
&rec,
221+
)
222+
.await;
223+
if !sealed.is_empty() && !config.irys_url.is_empty() {
224+
let owner_short = crate::db::normalize_owner_key(&repo.owner_did);
225+
let slug = format!("{}/{}", owner_short, repo.name);
226+
let ts = chrono::Utc::now().to_rfc3339();
227+
let node_did_str = node_did.to_string();
228+
let blobs: Vec<(String, String)> = sealed;
229+
let manifest = crate::arweave::EncryptedManifest {
230+
repo: &slug,
231+
owner_did: &repo.owner_did,
232+
node_did: &node_did_str,
233+
timestamp: &ts,
234+
blobs: &blobs,
235+
};
236+
if let Err(e) = crate::arweave::anchor_encrypted_manifest(
237+
http_client,
238+
&config.irys_url,
239+
&manifest,
240+
)
241+
.await
242+
{
243+
tracing::warn!(
244+
repo = %slug,
245+
err = %e,
246+
"encrypted manifest anchor failed (best-effort)"
247+
);
248+
}
249+
}
250+
}
251+
Ok(Ok(_)) => {}
252+
Ok(Err(e)) => {
253+
tracing::warn!(
254+
repo = %repo_slug,
255+
err = %e,
256+
"withheld_blob_recipients failed, skipping encrypted pin"
257+
);
258+
}
259+
Err(e) => {
260+
tracing::warn!(
261+
repo = %repo_slug,
262+
err = %e,
263+
"withheld_blob_recipients task panicked, skipping encrypted pin"
264+
);
265+
}
266+
}
267+
}
268+
}
269+
270+
Ok((batch.len(), total_gaps_found, total_gaps_filled))
271+
}

0 commit comments

Comments
 (0)