diff --git a/README.md b/README.md index 9ba009bf8..893965ade 100644 --- a/README.md +++ b/README.md @@ -46,74 +46,196 @@ These steps should ensure a smooth transition to the latest version of `indexer- [Contributions guide](/contributing.md) -### Supported request and response format examples +## Supported request and response format examples +```bash +curl http://localhost:7600/ +``` +``` +Service is up and running ``` -✗ curl http://localhost:7300/ -Ready to roll! - -✗ curl http://localhost:7300/health -{"healthy":true} -✗ curl http://localhost:7300/version -{"version":"0.1.0","dependencies":{}} +```bash +curl http://localhost:7600/version +``` +```json +{ "version":"0.1.0", "dependencies": {..} } +``` -✗ curl http://localhost:7300/operator/info -{"publicKey":"0xacb05407d78129b5717bb51712d3e23a78a10929"} +```bash +curl http://localhost:7600/info +``` +```json +{ "publicKey": "0xacb05407d78129b5717bb51712d3e23a78a10929" } +``` # Subgraph queries -# Checks for receipts and authorization -✗ curl -X POST -H 'Content-Type: application/json' -H 'Authorization: Bearer token-for-graph-node-query-endpoint' --data '{"query": "{_meta{block{number}}}"}' http://localhost:7300/subgraphs/id/QmacQnSgia4iDPWHpeY6aWxesRFdb8o5DKZUx96zZqEWrB -"{\"data\":{\"_meta\":{\"block\":{\"number\":9425787}}}}" - +## Checks for receipts and authorization +```bash +curl -X POST \ + -H 'Content-Type: application/json' \ + -H 'Authorization: Bearer token-for-graph-node-query-endpoint' \ + --data '{"query": "{_meta{block{number}}}"}' \ + http://localhost:7600/subgraphs/id/QmacQnSgia4iDPWHpeY6aWxesRFdb8o5DKZUx96zZqEWrB +``` +```json +{ + "attestable": true, + "graphQLResponse": "{\"data\":{\"_meta\":{\"block\":{\"number\":10666745}}}}" +} +``` # Takes hex representation for subgraphs deployment id aside from IPFS hash representation -✗ curl -X POST -H 'Content-Type: application/json' -H 'Authorization: Bearer token-for-graph-node-query-endpoint' --data '{"query": "{_meta{block{number}}}"}' http://localhost:7300/subgraphs/id/0xb655ca6f49e73728a102219726ff678d61d8fb792874792e9f0d9887dc616600 -"{\"data\":{\"_meta\":{\"block\":{\"number\":9425787}}}}" +```bash +curl -X POST \ + -H 'Content-Type: application/json' \ + -H 'Authorization: Bearer token-for-graph-node-query-endpoint' \ + --data '{"query": "{_meta{block{number}}}"}' \ + http://localhost:7600/subgraphs/id/0xb655ca6f49e73728a102219726ff678d61d8fb792874792e9f0d9887dc616600 +``` +```json +{ + "attestable": true, + "graphQLResponse": "{\"data\":{\"_meta\":{\"block\":{\"number\":10666745}}}}" +} +``` # Free query auth token check failed -✗ curl -X POST -H 'Content-Type: application/json' -H 'Authorization: blah' --data '{"query": "{_meta{block{number}}}"}' http://localhost:7300/subgraphs/id/0xb655ca6f49e73728a102219726ff678d61d8fb792874792e9f0d9887dc616600 -"Invalid Tap-Receipt header provided"% - -# Subgraph health check -✗ curl http://localhost:7300/subgraphs/health/QmVhiE4nax9i86UBnBmQCYDzvjWuwHShYh7aspGPQhU5Sj -"Subgraph deployment is up to date"% +```bash +curl -X POST \ + -H 'Content-Type: application/json' \ + -H 'Authorization: blah' \ + --data '{"query": "{_meta{block{number}}}"}' \ + http://localhost:7600/subgraphs/id/0xb655ca6f49e73728a102219726ff678d61d8fb792874792e9f0d9887dc616600 +``` +```json +{ + "message":"No valid receipt or free query auth token provided" +} +``` +## Subgraph health check +```bash +curl http://localhost:7600/subgraphs/health/QmVhiE4nax9i86UBnBmQCYDzvjWuwHShYh7aspGPQhU5Sj +``` +```json +{ + "health": "healthy" +} +``` ## Unfound subgraph -✗ curl http://localhost:7300/subgraphs/health/QmacQnSgia4iDPWHpeY6aWxesRFdb8o5DKZUx96zZqEWrB -"Invalid indexing status"% +```bash +curl http://localhost:7600/subgraphs/health/QmacQnSgia4iDPWHpeY6aWxesRFdb8o5DKZUx96zZqEWrB +``` +```json +{ + "error": "Deployment not found" +} +``` # Network queries -# Checks for auth and configuration to serve-network-subgraph -✗ curl -X POST -H 'Content-Type: application/json' -H 'Authorization: token-for-network-subgraph' --data '{"query": "{_meta{block{number}}}"}' http://localhost:7300/network -"Not enabled or authorized query" +## Checks for auth and configuration to serve-network-subgraph + +```bash +curl -X POST \ + -H 'Content-Type: application/json' \ + -H 'Authorization: token-for-network-subgraph' \ + --data '{"query": "{_meta{block{number}}}"}' \ + http://localhost:7600/network +``` +```json +{ + "message":"No valid receipt or free query auth token provided" +} +``` # Indexing status resolver - Route supported root field queries to graph node status endpoint -✗ curl -X POST -H 'Content-Type: application/json' --data '{"query": "{blockHashFromNumber(network:\"goerli\", blockNumber: 9069120)}"}' http://localhost:7300/status -{"data":{"blockHashFromNumber":"e1e5472636db73ba5496aee098dc21310683c95eb30fc46f9ba6c36d8b28d58e"}}% - -# Indexing status resolver - -✗ curl -X POST -H 'Content-Type: application/json' --data '{"query": "{indexingStatuses {subgraph health} }"}' http://localhost:7300/status -{"data":{"indexingStatuses":[{"subgraph":"QmVhiE4nax9i86UBnBmQCYDzvjWuwHShYh7aspGPQhU5Sj","health":"healthy"},{"subgraph":"QmWVtsWk8Pqn3zY3czDjyoVreshRLmoz9jko3mQ4uvxQDj","health":"healthy"},{"subgraph":"QmacQnSgia4iDPWHpeY6aWxesRFdb8o5DKZUx96zZqEWrB","health":"healthy"}]}} - -# Indexing status resolver - Filter out the unsupported queries -✗ curl -X POST -H 'Content-Type: application/json' --data '{"query": "{_meta{block{number}}}"}' http://localhost:7300/status -{"errors":[{"locations":[{"line":1,"column":2}],"message":"Type `Query` has no field `_meta`"}]}% +```bash +curl -X POST \ + -H 'Content-Type: application/json' \ + --data '{"query": "{blockHashFromNumber(network:\"mainnet\", blockNumber: 21033)}"}' \ + http://localhost:7600/status +``` +```json +{ + "data": { + "blockHashFromNumber": "0x6d8daae97a562b1fff22162515452acdd817c3d3c5cde1497b7d9eb6666a957e" + } +} +``` -######## Cost server - read-only graphql query -curl -X GET -H 'Content-Type: application/json' --data '{"query": "{ costModel(deployment: \"Qmb5Ysp5oCUXhLA8NmxmYKDAX2nCMnh7Vvb5uffb9n5vss\") { deployment model variables }} "}' http://localhost:7300/cost +## Indexing status resolver - +```bash +curl -X POST \ + -H 'Content-Type: application/json' \ + --data '{"query": "{indexingStatuses {subgraph health}}"}' \ + http://localhost:7600/status +``` +```json +{ + "data": { + "indexingStatuses": [ + { + "subgraph": "QmVhiE4nax9i86UBnBmQCYDzvjWuwHShYh7aspGPQhU5Sj", + "health": "healthy" + }, + { + "subgraph": "QmWVtsWk8Pqn3zY3czDjyoVreshRLmoz9jko3mQ4uvxQDj", + "health": "healthy" + }, + { + "subgraph": "QmacQnSgia4iDPWHpeY6aWxesRFdb8o5DKZUx96zZqEWrB", + "health": "healthy" + } + ] + } +} +``` -curl -X GET -H 'Content-Type: application/json' --data '{"query": "{ costModel(deployment: \"Qmb5Ysp5oCUXhLA8NmxmYKDAX2nCMnh7Vvb5uffb9n5vss\") { deployment model variables }} "}' http://localhost:7300/cost -{"data":{"costModel":{"deployment":"0xbd499f7673ca32ef4a642207a8bebdd0fb03888cf2678b298438e3a1ae5206ea","model":"default => 0.00025;","variables":null}}}% +## Indexing status resolver - Filter out the unsupported queries +```bash +curl -X POST \ + -H 'Content-Type: application/json' \ + --data '{"query": "{_meta{block{number}}}"}' \ + http://localhost:7600/status +``` +```json +{ + "errors": [ + { + "locations": [ + { + "line": 1, + "column": 2 + } + ], + "message": "Type `Query` has no field `_meta`" + } + ] +} +``` -curl -X GET -H 'Content-Type: application/json' --data '{"query": "{ costModel(deployment: \"Qmb5Ysp5oCUXhLA8NmxmYKDAX2nCMnh7Vvb5uffb9n5vas\") { deployment model variables }} "}' http://localhost:7300/cost -{"data":{"costModel":null}}% +## Cost server - read-only graphql query -curl -X GET -H 'Content-Type: application/json' --data '{"query": "{ costModel(deployment: \"Qmb5Ysp5oCUXhLA8NmxmYKDAX2nCMnh7Vvb5uffb9n5vss\") { deployment odel variables }} "}' http://localhost:7300/cost -{"errors":[{"message":"Cannot query field \"odel\" on type \"CostModel\". Did you mean \"model\"?","locations":[{"line":1,"column":88}]}]}% +```bash +curl -X GET \ + -H 'Content-Type: application/json' \ + --data '{"query": "{ costModels(deployments: [\"Qmb5Ysp5oCUXhLA8NmxmYKDAX2nCMnh7Vvb5uffb9n5vss\"]) { deployment model variables }} "}' \ + http://localhost:7300/cost +``` +```json +{ + "data": { + "costModels": [ + { + "deployment": "0xbd499f7673ca32ef4a642207a8bebdd0fb03888cf2678b298438e3a1ae5206ea", + "model": "default => 0.00025;", + "variables": null + } + ] + } +} +``` -curl -X GET -H 'Content-Type: application/json' --data '{"query": "{ costModels(deployments: [\"Qmb5Ysp5oCUXhLA8NmxmYKDAX2nCMnh7Vvb5uffb9n5vss\"]) { deployment model variables }} "}' http://localhost:7300/cost -{"data":{"costModels":[{"deployment":"0xbd499f7673ca32ef4a642207a8bebdd0fb03888cf2678b298438e3a1ae5206ea","model":"default => 0.00025;","variables":null}]}}% -``` ## Dependency choices diff --git a/common/src/indexer_service/http/health.rs b/common/src/indexer_service/http/health.rs new file mode 100644 index 000000000..062e2406e --- /dev/null +++ b/common/src/indexer_service/http/health.rs @@ -0,0 +1,117 @@ +// Copyright 2023-, Edge & Node, GraphOps, and Semiotic Labs. +// SPDX-License-Identifier: Apache-2.0 + +use crate::subgraph_client::Query; +use axum::{ + extract::Path, + response::{IntoResponse, Response as AxumResponse}, + Extension, Json, +}; +use reqwest::StatusCode; +use serde::Deserialize; +use serde_json::json; +use thiserror::Error; + +use super::GraphNodeConfig; + +#[derive(Deserialize, Debug)] +struct Response { + data: SubgraphData, +} + +#[derive(Deserialize, Debug)] +#[allow(non_snake_case)] +struct SubgraphData { + indexingStatuses: Vec, +} + +#[derive(Deserialize, Debug)] +struct IndexingStatus { + health: Health, +} + +#[derive(Deserialize, Debug)] +#[allow(non_camel_case_types)] +enum Health { + healthy, + unhealthy, + failed, +} + +impl Health { + fn as_str(&self) -> &str { + match self { + Health::healthy => "healthy", + Health::unhealthy => "unhealthy", + Health::failed => "failed", + } + } +} + +#[derive(Debug, Error)] +pub enum CheckHealthError { + #[error("Graph node config not found")] + GraphNodeConfigNotFound, + #[error("Deployment not found")] + DeploymentNotFound, + #[error("Failed to process query")] + QueryForwardingError, +} + +impl IntoResponse for CheckHealthError { + fn into_response(self) -> AxumResponse { + let (status, error_message) = match &self { + CheckHealthError::GraphNodeConfigNotFound => { + (StatusCode::NOT_FOUND, "Graph node config not found") + } + CheckHealthError::DeploymentNotFound => (StatusCode::NOT_FOUND, "Deployment not found"), + CheckHealthError::QueryForwardingError => { + (StatusCode::INTERNAL_SERVER_ERROR, "Failed to process query") + } + }; + + let body = serde_json::json!({ + "error": error_message, + }); + + (status, Json(body)).into_response() + } +} + +pub async fn health( + Path(deployment_id): Path, + Extension(graph_node): Extension>, +) -> Result { + let url = if let Some(graph_node) = graph_node { + graph_node.status_url + } else { + return Err(CheckHealthError::GraphNodeConfigNotFound); + }; + + let body = Query::new_with_variables( + r#" + query indexingStatuses($ids: [String!]!) { + indexingStatuses(subgraphs: $ids) { + health + } + } + "#, + [("ids", json!([deployment_id]))], + ); + + let client = reqwest::Client::new(); + let response = client.post(url).json(&body).send().await; + let response = response.expect("Failed to get response"); + let response_json: Result = response.json().await; + + match response_json { + Ok(res) => { + if res.data.indexingStatuses.is_empty() { + return Err(CheckHealthError::DeploymentNotFound); + }; + let health_status = res.data.indexingStatuses[0].health.as_str(); + Ok(Json(json!({ "health": health_status }))) + } + Err(_) => Err(CheckHealthError::QueryForwardingError), + } +} diff --git a/common/src/indexer_service/http/indexer_service.rs b/common/src/indexer_service/http/indexer_service.rs index 06dce7642..318a345ad 100644 --- a/common/src/indexer_service/http/indexer_service.rs +++ b/common/src/indexer_service/http/indexer_service.rs @@ -36,6 +36,7 @@ use tracing::{info, info_span}; use crate::escrow_accounts::EscrowAccounts; use crate::escrow_accounts::EscrowAccountsError; +use crate::indexer_service::http::health::health; use crate::{ address::public_key, indexer_service::http::static_subgraph::static_subgraph_request_handler, @@ -352,6 +353,11 @@ impl IndexerService { .route("/info", get(operator_address)) .layer(misc_rate_limiter); + // Check subgraph Health + misc_routes = misc_routes + .route("/subgraph/health/:deployment_id", get(health)) + .route_layer(Extension(options.config.graph_node)); + // Rate limits by allowing bursts of 50 requests and requiring 20ms of // time between consecutive requests after that, effectively rate // limiting to 50 req/s. diff --git a/common/src/indexer_service/http/mod.rs b/common/src/indexer_service/http/mod.rs index 20f4df958..76f1ed713 100644 --- a/common/src/indexer_service/http/mod.rs +++ b/common/src/indexer_service/http/mod.rs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 mod config; +mod health; mod indexer_service; mod request_handler; mod static_subgraph; diff --git a/common/src/subgraph_client/client.rs b/common/src/subgraph_client/client.rs index b83f5df45..9a3e105b8 100644 --- a/common/src/subgraph_client/client.rs +++ b/common/src/subgraph_client/client.rs @@ -7,6 +7,7 @@ use axum::body::Bytes; use eventuals::Eventual; use graphql_client::GraphQLQuery; use reqwest::{header, Url}; +use serde::Serialize; use serde_json::{Map, Value}; use thegraph_core::DeploymentId; use thegraph_graphql_http::{ @@ -15,7 +16,7 @@ use thegraph_graphql_http::{ }; use tracing::warn; -#[derive(Clone)] +#[derive(Clone, Serialize)] pub struct Query { pub query: Document, pub variables: Map, diff --git a/service/src/routes/status.rs b/service/src/routes/status.rs index f49c51ce8..c4311290e 100644 --- a/service/src/routes/status.rs +++ b/service/src/routes/status.rs @@ -26,6 +26,7 @@ lazy_static::lazy_static! { "entityChangesInBlock", "blockData", "cachedEthereumCalls", + "blockHashFromNumber", "subgraphFeatures", "apiVersions", ].into_iter().collect();