Skip to content

Commit 2eaabff

Browse files
committed
fix(node): shed an over-budget gossip author before the peer lookup (#325)
The author brake sat below `db.peer_exists`, so a signed author already past its budget still paid a Postgres round trip before being refused. That is a brake behind the work it bounds: the lookup is done for a request that was never going to be admitted, and a signed author can drive it at the pre-parse source rate, further with peer-id rotation. The brake now answers first, and the answer is a READ rather than the charge. `check` inserts a window for a key it has never seen, so probing with it here would let a flood of self-minted signed DIDs occupy the bounded author map before `peer_exists` could refuse them, and legitimate new authors would be shed once it filled. That would have made the remedy worse than the finding. `is_over_budget` allocates nothing, reports an unseen key as within budget, and leaves the charge itself below the lookup, unmoved. The only behaviour that changes is WHEN an already-over-budget author is refused. Two guards, both proven load-bearing by execution. Removing the early shed makes `an_over_budget_author_is_shed_without_a_peer_lookup` red on the call counter, which observes the lookup not happening rather than restating the ordering. Swapping the read for `check` makes `the_early_author_check_does_not_track_an_unseen_did` red on the map size, which is the must-not that keeps the brake from becoming its own memory-fill surface. Found by a cross-family refute pass on the pushed head.
1 parent c003f65 commit 2eaabff

2 files changed

Lines changed: 146 additions & 0 deletions

File tree

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

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -652,6 +652,23 @@ pub(crate) async fn ingest_ref_update(
652652
// peer cannot be impersonated: claiming a registered DID now requires the
653653
// key behind it.
654654
//
655+
// Shed an author that is ALREADY over budget before paying for the lookup.
656+
// The brake belongs in front of the work it bounds: an over-budget author is
657+
// going to be refused whatever the peer table says, so the round trip is work
658+
// done for a request that was never going to be admitted, and a signed author
659+
// can drive it at the pre-parse source rate.
660+
//
661+
// Deliberately a READ, not the charge. `check` inserts a window for a key it
662+
// has never seen, so probing with it here would let a flood of self-minted
663+
// signed DIDs occupy the bounded author map before `peer_exists` had a chance
664+
// to refuse them, turning a brake into a memory-fill surface. `is_over_budget`
665+
// allocates nothing and an unseen DID reads as within budget, so the only
666+
// behaviour that changes is WHEN an already-over-budget author is refused.
667+
// The charge itself stays below the lookup, unmoved.
668+
if verified && limiters.author.is_over_budget(&event.node_did).await {
669+
return IngestOutcome::AuthorRateLimited(event.node_did.clone());
670+
}
671+
655672
// Keyed lookup, not `list_peers`: this runs on every event that survives
656673
// the parse, and scanning the whole table per event makes ingest cost grow
657674
// with the peer count.
@@ -2684,6 +2701,100 @@ mod tests {
26842701
);
26852702
}
26862703

2704+
/// The brake must sit in front of the work it bounds, not behind it.
2705+
///
2706+
/// An author already over its budget is going to be shed no matter what the
2707+
/// peer lookup says, so paying a Postgres round trip first is work done for a
2708+
/// request that was never going to be admitted. A signed author can drive that
2709+
/// at the pre-parse source rate, and with peer-id rotation past it.
2710+
///
2711+
/// The counter is what makes this a real assertion rather than a restatement
2712+
/// of the code: it observes the lookup NOT happening, which reading the
2713+
/// ordering cannot do.
2714+
#[sqlx::test]
2715+
async fn an_over_budget_author_is_shed_without_a_peer_lookup(pool: PgPool) {
2716+
let db = ingest_db(&pool).await;
2717+
let limiters = IngestLimiters::new();
2718+
let author = Keypair::generate();
2719+
seed_peer(&pool, &author.did().to_string()).await;
2720+
2721+
// A distinct signed event per iteration: identical bytes are a replay and
2722+
// would be dropped by that guard instead of reaching the budget.
2723+
let signed_nth = |n: usize| {
2724+
let mut e = event_for(&author);
2725+
e.ref_name = format!("refs/heads/b{n}");
2726+
sign_ref_update(&author, &mut e).unwrap();
2727+
e
2728+
};
2729+
for i in 0..GOSSIP_AUTHOR_MAX_EVENTS {
2730+
let outcome = ingest_ref_update(
2731+
&db,
2732+
&limiters,
2733+
true,
2734+
true,
2735+
&bytes_of(&signed_nth(i)),
2736+
&PeerId::random(),
2737+
)
2738+
.await;
2739+
assert!(
2740+
matches!(outcome, IngestOutcome::Accepted),
2741+
"event {i} is inside the budget, got {outcome:?}"
2742+
);
2743+
}
2744+
2745+
reset_peer_exists_calls();
2746+
let outcome = ingest_ref_update(
2747+
&db,
2748+
&limiters,
2749+
true,
2750+
true,
2751+
&bytes_of(&signed_nth(GOSSIP_AUTHOR_MAX_EVENTS)),
2752+
&PeerId::random(),
2753+
)
2754+
.await;
2755+
assert!(
2756+
matches!(outcome, IngestOutcome::AuthorRateLimited(_)),
2757+
"the over-budget author must be shed, got {outcome:?}"
2758+
);
2759+
assert_eq!(
2760+
peer_exists_calls(),
2761+
0,
2762+
"an over-budget author must be shed BEFORE the peer lookup; the brake \
2763+
belongs in front of the work it bounds"
2764+
);
2765+
}
2766+
2767+
/// The must-not that keeps the early shed from becoming a key-farming axis.
2768+
///
2769+
/// The early check is a READ. An author the limiter has never seen must not
2770+
/// gain a map entry from it, or a flood of self-minted signed DIDs could fill
2771+
/// the bounded author map without ever registering a peer, and legitimate new
2772+
/// authors would then be shed once it was full.
2773+
#[sqlx::test]
2774+
async fn the_early_author_check_does_not_track_an_unseen_did(pool: PgPool) {
2775+
let db = ingest_db(&pool).await;
2776+
let limiters = IngestLimiters::new();
2777+
let stranger = Keypair::generate();
2778+
// Deliberately NOT seeded: an unregistered DID must be refused by the peer
2779+
// lookup, and must leave no trace in the author limiter on the way.
2780+
let mut e = event_for(&stranger);
2781+
sign_ref_update(&stranger, &mut e).unwrap();
2782+
2783+
let before = limiters.author.tracked_keys().await;
2784+
let outcome =
2785+
ingest_ref_update(&db, &limiters, true, true, &bytes_of(&e), &PeerId::random()).await;
2786+
assert!(
2787+
matches!(outcome, IngestOutcome::Rejected(_)),
2788+
"an unregistered DID is refused by the peer lookup, got {outcome:?}"
2789+
);
2790+
assert_eq!(
2791+
limiters.author.tracked_keys().await,
2792+
before,
2793+
"the early check must not allocate a map entry for a DID it has never \
2794+
seen, or the shed becomes its own memory-fill surface"
2795+
);
2796+
}
2797+
26872798
/// Exhaust one edge's unsigned budget and hand back the source that was
26882799
/// spent, so the callers below can assert what happens next.
26892800
async fn spend_unsigned_budget(db: &Db, limiters: &IngestLimiters, claim: &[u8]) -> PeerId {

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

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,41 @@ impl RateLimiter {
121121
true
122122
}
123123

124+
/// Is this key ALREADY over its budget, answered without touching the map?
125+
///
126+
/// Exists so a caller can shed an over-budget key before doing expensive work
127+
/// on its behalf, which is the brake-placement rule: the brake belongs in
128+
/// front of the work it bounds, not behind it.
129+
///
130+
/// Read-only is the load-bearing part, not an optimisation. `check` inserts a
131+
/// window for a key it has never seen, so using it as an early probe would let
132+
/// a flood of unseen keys occupy the bounded map before any other gate had a
133+
/// chance to refuse them. This allocates nothing and inserts nothing: an
134+
/// untracked key is reported as within budget, and the real charge still
135+
/// happens at the `check` call site further down.
136+
///
137+
/// Expired timestamps are counted out rather than pruned, since pruning would
138+
/// need the write lock this is deliberately avoiding. The count is therefore
139+
/// exact for the decision it drives.
140+
pub(crate) async fn is_over_budget(&self, key: &str) -> bool {
141+
if self.max_requests == 0 {
142+
return false;
143+
}
144+
let now = Instant::now();
145+
let state = self.state.lock().await;
146+
match state.get(key) {
147+
None => false,
148+
Some(window) => {
149+
let live = window
150+
.timestamps
151+
.iter()
152+
.filter(|t| now.duration_since(**t) < self.window)
153+
.count();
154+
live >= self.max_requests
155+
}
156+
}
157+
}
158+
124159
/// Number of keys currently tracked. Tests use it to observe what a sweep
125160
/// reclaimed; there is no production reader.
126161
#[cfg(test)]

0 commit comments

Comments
 (0)