fix(data): a skipped NEAR height no longer stalls the intents scan
ci / gate (push) Successful in 4m14s
deploy / validate (push) Skipped
deploy / build (push) Successful in 8m5s
deploy / deploy-web (push) Successful in 5s
deploy / deploy-api (push) Successful in 16s

NEAR does not produce a block at every height, and 218,363,444 was one it
skipped. The backfill asked for its shard layout at the batch's first height,
got UNKNOWN_BLOCK from every endpoint, and asked the same question every
15 s from then on: production's indexed_from stopped at 218,363,445. The tip
side runs the same code and would have frozen the first time a batch began on
a skip. The layout now comes from the first height in the batch that exists.

The second half of it: neard sends JSON-RPC errors with HTTP 422 where
FastNEAR's proxy sent them with 200, and call_one checked the status before
reading the body, so the node's own "no such block" arrived as a transport
failure. chunk() could not see a skip as a skip, deep() never asked the
archive, and on a chain with several endpoints a node's valid answer would
have been failed over. parse_response reads the body whatever the status: a
JSON-RPC error is the node answering; a non-success status without one is the
host not answering, as before.

Checked against near.internal with the scan seeded to begin a backfill batch
on the skipped height: it walked past it with no warnings.

Closes #31

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015pkwBsR54NRmZyU4QVhvfj
This commit is contained in:
2026-10-03 21:48:48 +03:00
co-authored by Claude Opus 5.5
parent 40bc2f67d1
commit ef2afa8e4e
3 changed files with 105 additions and 32 deletions
+36 -10
View File
@@ -139,18 +139,44 @@ async fn scan(chain: Arc<ChainRuntime>, store: Store, near: NearClient, config:
// Per batch, not once: resharding renumbers shards, and a layout read
// at startup would follow the wrong one after it while every call
// kept succeeding.
let shard = match near.shard_layout(heights[0]).await {
Ok(layout) => core::shard_for(
&config.contract,
&layout.boundary_accounts,
&layout.shard_ids,
),
Err(e) => {
tracing::warn!(chain = %id, error = %e, "near shard layout unreadable");
tokio::time::sleep(RETRY).await;
continue;
//
// From the first height in the batch that exists. NEAR skips heights,
// and every endpoint answers UNKNOWN_BLOCK for one that was never
// produced — asking at the batch's first height alone stalled the scan
// for good the first time that height was a skipped one, because each
// retry asked the same question (blackbeard/observer#31).
let mut layout = None;
let mut failed = None;
for &height in &heights {
match near.shard_layout(height).await {
Ok(l) => {
layout = Some(l);
break;
}
Err(e) if blackbeard_data::near::unknown(&e) => continue,
Err(e) => {
failed = Some(e);
break;
}
}
}
if let Some(e) = failed {
tracing::warn!(chain = %id, error = %e, "near shard layout unreadable");
tokio::time::sleep(RETRY).await;
continue;
}
let Some(layout) = layout else {
// Every height in the batch is unknown everywhere. A whole batch of
// skips is implausible; say so and retry rather than claim them.
tracing::warn!(chain = %id, from = heights[0], "no height in the batch is known to any endpoint");
tokio::time::sleep(RETRY).await;
continue;
};
let shard = core::shard_for(
&config.contract,
&layout.boundary_accounts,
&layout.shard_ids,
);
let Some(shard) = shard else {
tracing::error!(chain = %id, contract = %config.contract, "contract maps to no shard");
tokio::time::sleep(RETRY).await;
+1 -1
View File
@@ -102,7 +102,7 @@ pub struct NearClient {
/// Said both for a height that was never produced — NEAR skips heights — and
/// for one the node has pruned. Only the endpoint's retention tells them apart,
/// which is why a live node saying it is followed by asking an archive.
fn unknown(e: &DataError) -> bool {
pub fn unknown(e: &DataError) -> bool {
matches!(e, DataError::Rpc { message, .. }
if message.contains("UNKNOWN_BLOCK")
|| message.contains("UNKNOWN_CHUNK")
+68 -21
View File
@@ -405,27 +405,10 @@ impl RpcClient {
/// unhealthy endpoint: pruning is a legitimate answer, and failing over on
/// it would walk the whole list asking a question none of them can answer.
async fn call_one(&self, url: &str, body: &Value, method: &str) -> Result<Value, DataError> {
let resp: Value = self
.http
.post(url)
.json(body)
.send()
.await?
.error_for_status()?
.json()
.await?;
if let Some(err) = resp.get("error") {
return Err(DataError::Rpc {
method: method.to_owned(),
message: err.to_string(),
});
}
resp.get("result")
.cloned()
.ok_or_else(|| DataError::Malformed {
method: method.to_owned(),
message: "response carried neither result nor error".into(),
})
let resp = self.http.post(url).json(body).send().await?;
let status = resp.status().as_u16();
let bytes = resp.bytes().await?;
parse_response(method, status, &bytes)
}
/// `system_health`.
@@ -916,8 +899,72 @@ async fn follow_once(ws_url: &str, sink: &mpsc::Sender<SeenHead>) -> Result<(),
Ok(())
}
/// Classify one HTTP response to a JSON-RPC call.
///
/// The body is read **whatever the status**. A JSON-RPC `error` object is the
/// node answering, and stays [`DataError::Rpc`] even when it arrives with a
/// 4xx — which is how neard sends them: `UNKNOWN_BLOCK` comes back as HTTP 422,
/// where FastNEAR's proxy sent the same body with a 200. Calling
/// `error_for_status()` first turned that answer into a transport failure, so a
/// skipped height could never be recognised as one, the archive was never
/// asked, and a multi-endpoint chain would have failed over on a node's
/// perfectly valid "no". blackbeard/observer#31.
///
/// A non-success status *without* a JSON-RPC body — a proxy's 502, an HTML
/// error page — is still the host not answering, and is
/// [`DataError::Malformed`], which `call` fails over on.
fn parse_response(method: &str, status: u16, body: &[u8]) -> Result<Value, DataError> {
let malformed = |message: String| DataError::Malformed {
method: method.to_owned(),
message,
};
let parsed: Option<Value> = serde_json::from_slice(body).ok();
if let Some(err) = parsed.as_ref().and_then(|v| v.get("error")) {
return Err(DataError::Rpc {
method: method.to_owned(),
message: err.to_string(),
});
}
if !(200..300).contains(&status) {
return Err(malformed(format!("HTTP {status} without a JSON-RPC error")));
}
let parsed = parsed.ok_or_else(|| malformed("response is not JSON".into()))?;
parsed
.get("result")
.cloned()
.ok_or_else(|| malformed("response carried neither result nor error".into()))
}
#[cfg(test)]
mod tests {
#[test]
fn a_json_rpc_error_is_the_node_answering_whatever_the_status() {
// neard's own body for a skipped height, as served with HTTP 422.
let body = br#"{"jsonrpc":"2.0","error":{"name":"HANDLER_ERROR","cause":{"info":{},"name":"UNKNOWN_BLOCK"},"code":-32000,"message":"Server error","data":"Block Not Found: BLOCK HEIGHT: 218363444"},"id":1}"#;
for status in [200, 422, 500] {
match parse_response("chunk", status, body) {
Err(DataError::Rpc { message, .. }) => assert!(message.contains("UNKNOWN_BLOCK")),
other => panic!("HTTP {status}: {other:?}"),
}
}
}
#[test]
fn a_failure_without_a_json_rpc_body_is_the_host_not_answering() {
assert!(matches!(
parse_response("status", 502, b"<html>Bad Gateway</html>"),
Err(DataError::Malformed { .. })
));
assert!(matches!(
parse_response("status", 200, br#"{"jsonrpc":"2.0","id":1}"#),
Err(DataError::Malformed { .. })
));
assert_eq!(
parse_response("status", 200, br#"{"jsonrpc":"2.0","result":7,"id":1}"#).unwrap(),
serde_json::json!(7)
);
}
use super::*;
/// The probe key is derived, not typed. A wrong key is absent everywhere,