Skip to content
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ When an offer fails, dipper temporarily stops offering that indexer new work. Ho
| Your price was too high for the deployment | 1 day |
| You never replied to the offer | 1 day, on every deployment on that chain |
| A transient failure, including offers that never reached the chain | 5 minutes |
| The operator denylist | Until removed |
| The operator denylist | Until removed, and agreements you hold are cancelled |

Two safeguards keep this honest. If more than 50% of the DIPs-accepting indexers on a chain stop replying at once, dipper assumes the fault is on its side and suspends the network-wide exclusion until the situation recovers. And a liveness checker (disabled by default) watches accepted agreements for indexers that stop making progress; an abandoned agreement is cancelled on-chain and reassigned.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,14 @@ where
"unresponsive breaker tripped; skipping this chain's unresponsive exclusion"
);
} else {
context.indexer_denylist.extend(unresponsive);
// Sent as declined, which only stops new offers. IISA drops a group member on the
// denylist, which would cancel an agreement it still serves.
let declined = context.declined_indexers.entry(*deployment_id).or_default();
for indexer in unresponsive {
if !declined.contains(&indexer) {
declined.push(indexer);
}
}
}
}

Expand Down Expand Up @@ -964,6 +971,9 @@ mod lifecycle_event_tests {
rejects_with: Option<(u16, &'static str)>,
contexts: Arc<Mutex<Vec<SelectionContext>>>,
dips_ceilings: Arc<Mutex<Vec<PriceCeilings>>>,
/// The chain's DIPs-accepting pool, reported as just computed. Empty reports no
/// scores loaded, which leaves the unresponsive breaker inactive.
dips_pool: Vec<IndexerId>,
}

#[async_trait]
Expand All @@ -990,9 +1000,14 @@ mod lifecycle_event_tests {
ceilings: PriceCeilings,
) -> std::result::Result<dipper_iisa::DipsAcceptingSnapshot, SelectionError> {
self.dips_ceilings.lock().unwrap().push(ceilings);
let computed_at = (!self.dips_pool.is_empty()).then(|| {
time::OffsetDateTime::now_utc()
.format(&time::format_description::well_known::Rfc3339)
.unwrap()
});
Ok(dipper_iisa::DipsAcceptingSnapshot {
computed_at: None,
indexers: Vec::new(),
computed_at,
indexers: self.dips_pool.clone(),
})
}
}
Expand Down Expand Up @@ -1590,6 +1605,71 @@ mod lifecycle_event_tests {
);
}

#[tokio::test]
async fn sends_indexers_that_missed_an_offer_as_declined_not_denylisted() {
// IISA drops a group member on the denylist, which cancels its agreement here, so a
// missed offer only stops new offers, for members and outsiders alike.
let member = indexer_id(0x40);
let outsider = indexer_id(0x41);
let iisa = MockIisa::default();
let contexts = Arc::clone(&iisa.contexts);
let ctx = build_ctx(
MockRegistry {
active_agreements: vec![agreement(
member,
IndexingAgreementStatus::AcceptedOnChain,
)],
unresponsive: vec![member, outsider],
..Default::default()
},
iisa,
MockQueue::default(),
MockChainClient::default(),
CapturingEventsProducer::new(),
indexer_urls::Snapshot::new(),
);

handle(ctx, &test_message(1)).await.expect("handler ok");

let contexts = contexts.lock().unwrap();
assert_eq!(contexts.len(), 1, "IISA asked once: {contexts:?}");
assert_eq!(contexts[0].existing_indexers, vec![member]);
assert!(contexts[0].indexer_denylist.is_empty());
let declined: Vec<_> = contexts[0].declined_indexers.values().flatten().collect();
assert_eq!(declined, vec![&member, &outsider]);
}

#[tokio::test]
async fn holds_back_no_unresponsive_indexer_while_the_breaker_is_tripped() {
// 2 of the chain's 3 DIPs-accepting indexers stopped replying at once, which points at
// dipper rather than at them, so neither is held back from new offers.
let unresponsive = vec![indexer_id(0x50), indexer_id(0x51)];
let iisa = MockIisa {
dips_pool: vec![indexer_id(0x50), indexer_id(0x51), indexer_id(0x52)],
..Default::default()
};
let contexts = Arc::clone(&iisa.contexts);
let ctx = build_ctx(
MockRegistry {
unresponsive,
..Default::default()
},
iisa,
MockQueue::default(),
MockChainClient::default(),
CapturingEventsProducer::new(),
indexer_urls::Snapshot::new(),
);

handle(ctx, &test_message(1)).await.expect("handler ok");

let contexts = contexts.lock().unwrap();
assert_eq!(contexts.len(), 1, "IISA asked once: {contexts:?}");
assert!(contexts[0].indexer_denylist.is_empty());
let declined: Vec<_> = contexts[0].declined_indexers.values().flatten().collect();
assert!(declined.is_empty(), "{declined:?}");
}

#[tokio::test]
async fn retries_a_rejected_selection_after_60_seconds_and_says_it_was_rejected() {
// A rejection is usually fixed by a deploy, so the job must stay queued
Expand Down
8 changes: 5 additions & 3 deletions dipper-iisa/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,12 @@ pub struct SelectionContext {
/// Mapped to `blocklist` in the IISA request.
pub indexer_denylist: Vec<IndexerId>,

/// For each deployment, indexers that have recently declined agreements.
/// For each deployment, indexers to offer no new agreement for now: those that recently
/// declined or missed an offer on the deployment's chain, and those whose agreement on the
/// deployment dipper is still cancelling.
///
/// Used to avoid re-offering agreements to indexers that recently declined.
/// Key: Deployment ID, Value: List of indexer IDs that declined.
/// Unlike the denylist, IISA leaves an indexer already in the group in place.
/// Key: Deployment ID, Value: those indexers' IDs.
pub declined_indexers: HashMap<DeploymentId, Vec<IndexerId>>,

/// Chain ID of the deployment (e.g., "arbitrum-one").
Expand Down
Loading