hotshot_query_service/
status.rs

1// Copyright (c) 2022 Espresso Systems (espressosys.com)
2// This file is part of the HotShot Query Service library.
3//
4// This program is free software: you can redistribute it and/or modify it under the terms of the GNU
5// General Public License as published by the Free Software Foundation, either version 3 of the
6// License, or (at your option) any later version.
7// This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
8// even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
9// General Public License for more details.
10// You should have received a copy of the GNU General Public License along with this program. If not,
11// see <https://www.gnu.org/licenses/>.
12
13//! Queries for node-specific state and uncommitted data.
14//!
15//! Unlike the [availability](crate::availability) and [node](crate::node) APIs, which deal only
16//! with committed data (albeit with different consistency properties), the status API offers a
17//! glimpse into internal consensus state and uncommitted data. Here you can find low-level
18//! information about a particular node, such as consensus and networking metrics.
19//!
20//! The status API is intended to be a lightweight way to inspect the activities and health of a
21//! consensus node. It is the only API that can be run without any persistent storage, and its
22//! memory overhead is also very low. As a consequence, it only serves two types of data:
23//! * snapshots of the state right now, with no way to query historical snapshots
24//! * summary statistics
25
26use std::{borrow::Cow, fmt::Display, path::PathBuf};
27
28use derive_more::From;
29use futures::FutureExt;
30use serde::{Deserialize, Serialize};
31use snafu::Snafu;
32use tide_disco::{api::ApiError, method::ReadState, Api, RequestError, StatusCode};
33use vbs::version::StaticVersionType;
34
35use crate::api::load_api;
36
37pub(crate) mod data_source;
38
39pub use data_source::*;
40
41#[derive(Default)]
42pub struct Options {
43    pub api_path: Option<PathBuf>,
44
45    /// Additional API specification files to merge with `status-api-path`.
46    ///
47    /// These optional files may contain route definitions for application-specific routes that have
48    /// been added as extensions to the basic status API.
49    pub extensions: Vec<toml::Value>,
50}
51
52#[derive(Clone, Debug, From, Snafu, Deserialize, Serialize)]
53pub enum Error {
54    Request { source: RequestError },
55    Internal { reason: String },
56}
57
58impl Error {
59    pub fn status(&self) -> StatusCode {
60        match self {
61            Self::Request { .. } => StatusCode::BAD_REQUEST,
62            Self::Internal { .. } => StatusCode::INTERNAL_SERVER_ERROR,
63        }
64    }
65}
66
67fn internal<M: Display>(msg: M) -> Error {
68    Error::Internal {
69        reason: msg.to_string(),
70    }
71}
72
73pub fn define_api<State, Ver: StaticVersionType + 'static>(
74    options: &Options,
75    _: Ver,
76    api_ver: semver::Version,
77) -> Result<Api<State, Error, Ver>, ApiError>
78where
79    State: 'static + Send + Sync + ReadState,
80    <State as ReadState>::State: Send + Sync + StatusDataSource,
81{
82    let mut api = load_api::<State, Error, Ver>(
83        options.api_path.as_ref(),
84        include_str!("../api/status.toml"),
85        options.extensions.clone(),
86    )?;
87    api.with_version(api_ver)
88        .get("block_height", |_, state| {
89            async { state.block_height().await.map_err(internal) }.boxed()
90        })?
91        .get("success_rate", |_, state| {
92            async { state.success_rate().await.map_err(internal) }.boxed()
93        })?
94        .get("time_since_last_decide", |_, state| {
95            async {
96                state
97                    .elapsed_time_since_last_decide()
98                    .await
99                    .map_err(internal)
100            }
101            .boxed()
102        })?
103        .metrics("metrics", |_, state| {
104            async { Ok(Cow::Borrowed(state.metrics())) }.boxed()
105        })?;
106    Ok(api)
107}
108
109#[cfg(test)]
110mod test {
111    use std::{str::FromStr, time::Duration};
112
113    use async_lock::RwLock;
114    use futures::FutureExt;
115    use portpicker::pick_unused_port;
116    use reqwest::redirect::Policy;
117    use surf_disco::Client;
118    use tempfile::TempDir;
119    use tide_disco::{App, Url};
120    use toml::toml;
121
122    use super::*;
123    use crate::{
124        data_source::ExtensibleDataSource,
125        task::BackgroundTask,
126        testing::{
127            consensus::{MockDataSource, MockNetwork},
128            mocks::{MockBase, MockVersions},
129            sleep,
130        },
131        ApiState, Error,
132    };
133
134    #[test_log::test(tokio::test(flavor = "multi_thread"))]
135    async fn test_api() {
136        // Create the consensus network.
137        let mut network = MockNetwork::<MockDataSource, MockVersions>::init().await;
138
139        // Start the web server.
140        let port = pick_unused_port().unwrap();
141        let mut app = App::<_, Error>::with_state(ApiState::from(network.data_source()));
142        app.register_module(
143            "status",
144            define_api(
145                &Default::default(),
146                MockBase::instance(),
147                "0.0.1".parse().unwrap(),
148            )
149            .unwrap(),
150        )
151        .unwrap();
152        network.spawn(
153            "server",
154            app.serve(format!("0.0.0.0:{port}"), MockBase::instance()),
155        );
156
157        // Start a client.
158        let url = Url::from_str(&format!("http://localhost:{port}/status")).unwrap();
159        let client = Client::<Error, MockBase>::new(url.clone());
160        assert!(client.connect(Some(Duration::from_secs(60))).await);
161
162        // The block height is initially zero.
163        assert_eq!(client.get::<u64>("block-height").send().await.unwrap(), 0);
164
165        // Test Prometheus export.
166        // Create `reqwest` client that allows redirects
167        let reqwest_client = reqwest::Client::builder()
168            .redirect(Policy::limited(5))
169            .build()
170            .unwrap();
171
172        // Ask for the Prometheus data
173        let res = reqwest_client
174            .get(format!("{url}/metrics"))
175            .send()
176            .await
177            .unwrap();
178
179        // Make sure it has the correct response code
180        assert_eq!(res.status(), StatusCode::OK);
181        let prometheus = res.text().await.unwrap();
182        let lines = prometheus.lines().collect::<Vec<_>>();
183        assert!(
184            lines.contains(&"consensus_current_view 0"),
185            "Missing consensus_current_view in metrics:\n{prometheus}"
186        );
187
188        // Start the validators and wait for the block to be finalized.
189        network.start().await;
190
191        // Check updated block height.
192        // being updated and the decide event being published. Retry this a few times until it
193        // succeeds.
194        while client.get::<u64>("block-height").send().await.unwrap() <= 1 {
195            tracing::info!("waiting for block height to update");
196            sleep(Duration::from_secs(1)).await;
197        }
198        let success_rate = client.get::<f64>("success-rate").send().await.unwrap();
199        // If metrics are populating correctly, we should get a finite number. If not, we might get
200        // NaN or infinity due to division by 0.
201        assert!(success_rate.is_finite(), "{success_rate}");
202        // We know at least some views have been successful, since we finalized a block.
203        assert!(success_rate > 0.0, "{success_rate}");
204
205        network.shut_down().await;
206    }
207
208    #[test_log::test(tokio::test(flavor = "multi_thread"))]
209    async fn test_extensions() {
210        let dir = TempDir::with_prefix("test_status_extensions").unwrap();
211        let data_source = ExtensibleDataSource::new(
212            MockDataSource::create(dir.path(), Default::default())
213                .await
214                .unwrap(),
215            0,
216        );
217
218        let extensions = toml! {
219            [route.post_ext]
220            PATH = ["/ext/:val"]
221            METHOD = "POST"
222            ":val" = "Integer"
223
224            [route.get_ext]
225            PATH = ["/ext"]
226            METHOD = "GET"
227        };
228
229        let mut api = define_api::<RwLock<ExtensibleDataSource<MockDataSource, u64>>, MockBase>(
230            &Options {
231                extensions: vec![extensions.into()],
232                ..Default::default()
233            },
234            MockBase::instance(),
235            "0.0.1".parse().unwrap(),
236        )
237        .unwrap();
238        api.get("get_ext", |_, state| {
239            async move { Ok(*state.as_ref()) }.boxed()
240        })
241        .unwrap()
242        .post("post_ext", |req, state| {
243            async move {
244                *state.as_mut() = req.integer_param("val")?;
245                Ok(())
246            }
247            .boxed()
248        })
249        .unwrap();
250
251        let mut app = App::<_, Error>::with_state(RwLock::new(data_source));
252        app.register_module("status", api).unwrap();
253
254        let port = pick_unused_port().unwrap();
255        let _server = BackgroundTask::spawn(
256            "server",
257            app.serve(format!("0.0.0.0:{port}"), MockBase::instance()),
258        );
259
260        let client = Client::<Error, MockBase>::new(
261            format!("http://localhost:{port}/status").parse().unwrap(),
262        );
263        assert!(client.connect(Some(Duration::from_secs(60))).await);
264
265        assert_eq!(client.get::<u64>("ext").send().await.unwrap(), 0);
266        client.post::<()>("ext/42").send().await.unwrap();
267        assert_eq!(client.get::<u64>("ext").send().await.unwrap(), 42);
268
269        // Ensure we can still access the built-in functionality.
270        assert_eq!(client.get::<u64>("block-height").send().await.unwrap(), 0);
271    }
272}