Skip to content
This repository was archived by the owner on Sep 8, 2026. It is now read-only.

Commit 8004775

Browse files
authored
contributor-rewards: fail the tick instead of saving a snapshot with no leader schedule (#413)
## Summary of Changes * The scheduler no longer writes a snapshot it cannot use: a failed leader-schedule fetch propagates instead of becoming `leader_schedule: null`, and `create_epoch_snapshot` validates before saving. * A failed fetch was one `warn!`, then an unusable snapshot uploaded over the epoch's canonical S3 key; the tick then failed reading it back with `Missing leader schedule`, a symptom whose cause survived only in the log. #411 fixed this for the `snapshot` CLI command but not for the scheduler, which is the path that runs in production. * Scheduler failures now log the full cause chain (`{:#}`). Plain `{}` prints only the outermost context, so the newly propagated reason would still have been dropped. * **Security:** `EpochFinder`'s retry logs printed `reqwest`'s error verbatim, which includes the request URL. That URL carries the read endpoint's API key on mainnet-beta and journald ships to Loki, so those logs now strip it. This matters because the companion PR points mainnet-beta's reads at the keyed endpoint. * **Behavior change:** a `--dry-run` tick that previously marked the epoch processed with an unusable snapshot now fails and retries — nothing validated on that path, since `calculate_rewards` never ran. Both deployed environments run with dry-run off. ## Testing Verification * New `tests/test_snapshot_validate.rs` builds a `CompleteSnapshot` from the existing `testnet_snapshot.json` and `leader-schedule-epoch-89.json` fixtures: `validate()` is `Ok` on a complete snapshot, and names the right issue when the leader schedule is missing or empty.
1 parent 0c84bde commit 8004775

5 files changed

Lines changed: 191 additions & 60 deletions

File tree

crates/contributor-rewards/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
- fix(contributor-rewards): the scheduler no longer writes a snapshot it cannot use. A failed leader-schedule fetch was warned and discarded, so an unusable snapshot overwrote the epoch's canonical S3 key and the tick then failed reading it back with "Missing leader schedule". Both producers now propagate the fetch error, and the scheduler validates before saving, which also covers `--dry-run`, where nothing validated at all. Scheduler failures log the full cause chain, and every `EpochFinder` RPC error is stripped of its request URL, in the retry logs and in the error it propagates, since that URL carries the mainnet-beta read endpoint's API key into journald and Loki (malbeclabs/infra#2372)
1011
- fix(contributor-rewards): resolve the Solana epoch for a timestamp from real block times instead of dividing wall clock by a hardcoded 400ms slot duration. The old estimate drifted about 30k slots per day of lookback and picked the wrong epoch near a boundary, and no fixed constant survives the SIMD-0525 rollout. That epoch selects the leader schedule rewards are computed against, so the search now errors rather than returning a wrong answer: a backfill older than the endpoint's ledger retention fails on the `ingestor::demand` path instead of silently mis-estimating (malbeclabs/infra#2317)
1112
- fix(contributor-rewards): `snapshot` validates before writing. It warns and continues when the leader schedule cannot be fetched, but every consumer rejects a snapshot without one, so the command exited 0 having written an unusable file under the canonical name and a `snapshot` then `export-shapley` chain failed a step late. Pre-existing, but reachable now that resolving the Solana epoch depends on block-time reads (malbeclabs/infra#2317)
1213
- migrate to Solana 3.0: workspace `solana-*` crates and `solana-sdk` move to the 3.0 line, `solana-program-test` to 3.0.12, and the doublezero SDK git-deps repin from `client/v0.27.1` to the malbeclabs/doublezero#3830 merge revision (malbeclabs/infra#1853)

crates/contributor-rewards/src/cli/snapshot.rs

Lines changed: 9 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use std::path::PathBuf;
22

3-
use anyhow::{Result, bail};
3+
use anyhow::{Context, Result, bail};
44
use serde::{Deserialize, Serialize};
55
use tracing::{info, warn};
66

@@ -247,27 +247,18 @@ pub async fn create_snapshot(
247247
fetch_data.dz_internet = internet_data;
248248
}
249249

250-
// Try to get Solana epoch and leader schedule
251250
let mut epoch_finder = EpochFinder::new(
252251
fetcher.dz_rpc_client.clone(),
253252
fetcher.solana_read_client.clone(),
254253
);
255-
// fetch_leader_schedule resolves the Solana epoch itself and reports which
256-
// one it used, so taking the epoch from its result avoids running the
257-
// chain-verified epoch search twice over the same timestamp.
258-
let leader_schedule = match epoch_finder
254+
// Required: validate() below rejects a snapshot without a leader schedule.
255+
// fetch_leader_schedule resolves the Solana epoch itself and reports which one it
256+
// used, so taking the epoch from its result avoids running the chain-verified
257+
// epoch search twice over the same timestamp.
258+
let leader_schedule = epoch_finder
259259
.fetch_leader_schedule(fetch_epoch, fetch_data.start_us)
260260
.await
261-
{
262-
Ok(schedule) => Some(schedule),
263-
Err(e) => {
264-
warn!("Failed to get leader schedule: {}", e);
265-
None
266-
}
267-
};
268-
let solana_epoch = leader_schedule
269-
.as_ref()
270-
.map(|schedule| schedule.solana_epoch);
261+
.with_context(|| format!("Failed to fetch leader schedule for DZ epoch {fetch_epoch}"))?;
271262

272263
// Create metadata
273264
let metadata = SnapshotMetadata {
@@ -283,9 +274,9 @@ pub async fn create_snapshot(
283274
// Create complete snapshot
284275
let snapshot = CompleteSnapshot {
285276
dz_epoch: fetch_epoch,
286-
solana_epoch,
277+
solana_epoch: Some(leader_schedule.solana_epoch),
287278
fetch_data,
288-
leader_schedule,
279+
leader_schedule: Some(leader_schedule),
289280
metadata,
290281
};
291282

crates/contributor-rewards/src/ingestor/epoch.rs

Lines changed: 81 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,30 @@ fn is_settled_block_error(err: &SolanaClientError) -> bool {
142142
is_block_unavailable(err) || is_block_cleaned_up(err)
143143
}
144144

145+
// `reqwest::Error`, which `ClientErrorKind::Reqwest` is transparent over, prints the
146+
// request URL from both `Debug` and `Display`. On mainnet-beta that URL carries the
147+
// read endpoint's API key, and journald ships to Loki, so logging the error verbatim
148+
// publishes the key on every timeout and every 429.
149+
fn redacted(err: &SolanaClientError) -> String {
150+
let text = format!("{err:?}");
151+
152+
match err.kind() {
153+
ClientErrorKind::Reqwest(inner) => match inner.url() {
154+
Some(url) => text.replace(url.as_str(), "<redacted>"),
155+
None => text,
156+
},
157+
_ => text,
158+
}
159+
}
160+
161+
// Redacting only the retry logs is not enough: when the retry gives up, the client
162+
// error itself travels up the chain, and whoever prints that chain prints the URL.
163+
// Every RPC call in this module converts its error through here so the key cannot
164+
// leave, which means dropping the original as a source rather than wrapping it.
165+
fn redacted_error(err: SolanaClientError) -> anyhow::Error {
166+
anyhow::Error::msg(redacted(&err))
167+
}
168+
145169
/// Report whether a block at `first_block_slot` is close enough to `first_slot`,
146170
/// the first slot of an epoch, to date when that epoch began.
147171
///
@@ -264,11 +288,13 @@ impl EpochFinder {
264288
.retry(&ExponentialBuilder::default().with_jitter())
265289
.notify(|err: &SolanaClientError, dur: Duration| {
266290
info!(
267-
"retrying get_epoch_schedule error: {:?} with sleeping {:?}",
268-
err, dur
291+
"retrying get_epoch_schedule error: {} with sleeping {:?}",
292+
redacted(err),
293+
dur
269294
)
270295
})
271-
.await?;
296+
.await
297+
.map_err(redacted_error)?;
272298
self.dz_schedule = Some(schedule);
273299
}
274300

@@ -285,11 +311,13 @@ impl EpochFinder {
285311
.retry(&ExponentialBuilder::default().with_jitter())
286312
.notify(|err: &SolanaClientError, dur: Duration| {
287313
info!(
288-
"retrying get_epoch_schedule error: {:?} with sleeping {:?}",
289-
err, dur
314+
"retrying get_epoch_schedule error: {} with sleeping {:?}",
315+
redacted(err),
316+
dur
290317
)
291318
})
292-
.await?;
319+
.await
320+
.map_err(redacted_error)?;
293321
self.solana_schedule = Some(schedule);
294322
}
295323

@@ -311,18 +339,18 @@ impl EpochFinder {
311339
.when(|err: &SolanaClientError| !is_settled_block_error(err))
312340
.notify(|err: &SolanaClientError, dur: Duration| {
313341
info!(
314-
"retrying get_block_time error: {:?} with sleeping {:?}",
315-
err, dur
342+
"retrying get_block_time error: {} with sleeping {:?}",
343+
redacted(err),
344+
dur
316345
)
317346
})
318347
.await;
319348

320349
match block_time {
321350
Ok(block_time) => Ok(Some(block_time)),
322351
Err(err) if is_block_unavailable(&err) => Ok(None),
323-
Err(err) => {
324-
Err(err).with_context(|| format!("Failed to get block time for Solana slot {slot}"))
325-
}
352+
Err(err) => Err(redacted_error(err))
353+
.with_context(|| format!("Failed to get block time for Solana slot {slot}")),
326354
}
327355
}
328356

@@ -368,11 +396,13 @@ impl EpochFinder {
368396
.when(|err: &SolanaClientError| !is_settled_block_error(err))
369397
.notify(|err: &SolanaClientError, dur: Duration| {
370398
info!(
371-
"retrying get_blocks_with_limit error: {:?} with sleeping {:?}",
372-
err, dur
399+
"retrying get_blocks_with_limit error: {} with sleeping {:?}",
400+
redacted(err),
401+
dur
373402
)
374403
})
375404
.await
405+
.map_err(redacted_error)
376406
.with_context(|| format!("Failed to find the first block of Solana epoch {epoch}"))?
377407
.first()
378408
.copied()
@@ -458,9 +488,14 @@ impl EpochFinder {
458488
let current_slot = (|| async { self.solana_read_client.get_slot().await })
459489
.retry(&ExponentialBuilder::default().with_jitter())
460490
.notify(|err: &SolanaClientError, dur: Duration| {
461-
info!("retrying get_slot error: {:?} with sleeping {:?}", err, dur)
491+
info!(
492+
"retrying get_slot error: {} with sleeping {:?}",
493+
redacted(err),
494+
dur
495+
)
462496
})
463-
.await?;
497+
.await
498+
.map_err(redacted_error)?;
464499

465500
let current_time_us = Utc::now().timestamp_micros() as u64;
466501

@@ -576,11 +611,13 @@ impl EpochFinder {
576611
.retry(&ExponentialBuilder::default().with_jitter())
577612
.notify(|err: &SolanaClientError, dur: Duration| {
578613
info!(
579-
"retrying get_leader_schedule error: {:?} with sleeping {:?}",
580-
err, dur
614+
"retrying get_leader_schedule error: {} with sleeping {:?}",
615+
redacted(err),
616+
dur
581617
)
582618
})
583-
.await?
619+
.await
620+
.map_err(redacted_error)?
584621
.ok_or_else(|| anyhow!("No leader schedule found for Solana epoch {solana_epoch}"))?;
585622

586623
// Convert leader schedule to map of validator -> slot count
@@ -607,6 +644,32 @@ mod tests {
607644

608645
use super::*;
609646

647+
// Points a client at a closed local port so the call fails inside reqwest with the
648+
// URL attached, which is the shape that leaks the API key.
649+
#[tokio::test]
650+
async fn test_redaction_keeps_the_url_out_of_a_propagated_error() {
651+
let err = RpcClient::new("http://127.0.0.1:1/?api-key=SUPERSECRET".to_string())
652+
.get_slot()
653+
.await
654+
.expect_err("a closed port cannot answer get_slot");
655+
656+
// The redaction depends on both of these, so check them rather than letting a
657+
// reqwest change turn the assertions below into a vacuous pass.
658+
assert!(matches!(err.kind(), ClientErrorKind::Reqwest(_)), "{err:?}");
659+
let ClientErrorKind::Reqwest(inner) = err.kind() else {
660+
unreachable!()
661+
};
662+
assert!(inner.url().is_some(), "{err:?}");
663+
664+
assert!(format!("{err:?}").contains("SUPERSECRET"));
665+
assert!(!redacted(&err).contains("SUPERSECRET"));
666+
667+
// What the scheduler prints on failure, via `error!("...: {e:#}")`.
668+
let propagated = redacted_error(err);
669+
assert!(!format!("{propagated:#}").contains("SUPERSECRET"));
670+
assert!(!format!("{propagated:?}").contains("SUPERSECRET"));
671+
}
672+
610673
#[test]
611674
fn test_estimate_slot_from_timestamp() {
612675
let current_slot = 1000000;

crates/contributor-rewards/src/scheduler/worker.rs

Lines changed: 19 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ use std::{
88
time::{Duration, Instant},
99
};
1010

11-
use anyhow::{Result, anyhow, bail, ensure};
11+
use anyhow::{Context, Result, anyhow, bail, ensure};
1212
use backon::{ExponentialBuilder, Retryable};
1313
use chrono::Utc;
1414
use doublezero_program_tools::zero_copy;
@@ -147,7 +147,7 @@ impl ScheduleWorker {
147147
state.save(&self.state_file)?;
148148
}
149149
Err(e) => {
150-
error!("Failed to process rewards: {}", e);
150+
error!("Failed to process rewards: {e:#}");
151151
state.mark_failure();
152152
state.save(&self.state_file)?;
153153

@@ -418,10 +418,9 @@ impl ScheduleWorker {
418418
(location, path, temp_guard)
419419
}
420420
Err(e) => {
421-
error!(
422-
"Failed to create snapshot for epoch {}: {}",
423-
target_epoch, e
424-
);
421+
// `{:#}` prints the cause chain; plain `{}` prints only the outermost
422+
// context, dropping the reason the fetch failed.
423+
error!("Failed to create snapshot for epoch {target_epoch}: {e:#}");
425424
metrics::counter!(
426425
"doublezero_contributor_rewards_snapshot_failed",
427426
"reason" => "creation_error"
@@ -769,28 +768,19 @@ impl ScheduleWorker {
769768
);
770769
}
771770

772-
// Try to fetch leader schedule (optional - warn on failure)
771+
// Required: every consumer rejects a snapshot without a leader schedule.
773772
info!("Fetching leader schedule for epoch {}", epoch);
774-
let (solana_epoch, leader_schedule) = match EpochFinder::new(
773+
let leader_schedule = EpochFinder::new(
775774
fetcher.dz_rpc_client.clone(),
776775
fetcher.solana_read_client.clone(),
777776
)
778777
.fetch_leader_schedule(epoch, fetch_data.start_us)
779778
.await
780-
{
781-
Ok(schedule) => {
782-
info!(
783-
"Leader schedule fetched successfully for Solana epoch {}",
784-
schedule.solana_epoch
785-
);
786-
(Some(schedule.solana_epoch), Some(schedule))
787-
}
788-
Err(e) => {
789-
warn!("Failed to fetch leader schedule for epoch {}: {}", epoch, e);
790-
warn!("Snapshot will be created without leader schedule");
791-
(None, None)
792-
}
793-
};
779+
.with_context(|| format!("Failed to fetch leader schedule for DZ epoch {epoch}"))?;
780+
info!(
781+
"Leader schedule fetched successfully for Solana epoch {}",
782+
leader_schedule.solana_epoch
783+
);
794784

795785
// Create metadata
796786
let metadata = SnapshotMetadata {
@@ -806,12 +796,17 @@ impl ScheduleWorker {
806796
// Create complete snapshot
807797
let snapshot = CompleteSnapshot {
808798
dz_epoch: epoch,
809-
solana_epoch,
799+
solana_epoch: Some(leader_schedule.solana_epoch),
810800
fetch_data,
811-
leader_schedule,
801+
leader_schedule: Some(leader_schedule),
812802
metadata,
813803
};
814804

805+
// Validate before saving: storage.save writes the canonical per-epoch key, so
806+
// an incomplete snapshot overwrites a good one, and a dry run never reads it
807+
// back to find out.
808+
snapshot.validate()?;
809+
815810
// Save snapshot using storage abstraction (S3 or local file)
816811
info!(
817812
"Saving snapshot using {} storage",

0 commit comments

Comments
 (0)