Skip to main content

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, path::PathBuf};
27
28use futures::FutureExt;
29use tide_disco::{Api, api::ApiError, method::ReadState};
30use vbs::version::StaticVersionType;
31
32use crate::api::load_api;
33
34pub(crate) mod data_source;
35
36pub use data_source::*;
37pub use hotshot_query_service_types::status::Error;
38
39#[derive(Default)]
40pub struct Options {
41    pub api_path: Option<PathBuf>,
42
43    /// Additional API specification files to merge with `status-api-path`.
44    ///
45    /// These optional files may contain route definitions for application-specific routes that have
46    /// been added as extensions to the basic status API.
47    pub extensions: Vec<toml::Value>,
48}
49
50pub fn define_api<State, Ver: StaticVersionType + 'static>(
51    options: &Options,
52    _: Ver,
53    api_ver: semver::Version,
54) -> Result<Api<State, Error, Ver>, ApiError>
55where
56    State: 'static + Send + Sync + ReadState,
57    <State as ReadState>::State: Send + Sync + StatusDataSource,
58{
59    let mut api = load_api::<State, Error, Ver>(
60        options.api_path.as_ref(),
61        include_str!("../api/status.toml"),
62        options.extensions.clone(),
63    )?;
64    api.with_version(api_ver)
65        .get("block_height", |_, state| {
66            async { state.block_height().await.map_err(Error::internal) }.boxed()
67        })?
68        .get("success_rate", |_, state| {
69            async { state.success_rate().await.map_err(Error::internal) }.boxed()
70        })?
71        .get("time_since_last_decide", |_, state| {
72            async {
73                state
74                    .elapsed_time_since_last_decide()
75                    .await
76                    .map_err(Error::internal)
77            }
78            .boxed()
79        })?
80        .metrics("metrics", |_, state| {
81            async { Ok(Cow::Borrowed(state.metrics())) }.boxed()
82        })?;
83    Ok(api)
84}
85
86#[cfg(test)]
87mod test {
88    use std::{str::FromStr, time::Duration};
89
90    use async_lock::RwLock;
91    use futures::FutureExt;
92    use reqwest::redirect::Policy;
93    use surf_disco::Client;
94    use tempfile::TempDir;
95    use test_utils::reserve_tcp_port;
96    use tide_disco::{App, StatusCode, Url};
97    use toml::toml;
98
99    use super::*;
100    use crate::{
101        ApiState, Error,
102        data_source::ExtensibleDataSource,
103        task::BackgroundTask,
104        testing::{
105            consensus::{MockDataSource, MockNetwork},
106            mocks::MockBase,
107            sleep,
108        },
109    };
110
111    #[test_log::test(tokio::test(flavor = "multi_thread"))]
112    async fn test_api() {
113        // Create the consensus network.
114        let mut network = MockNetwork::<MockDataSource>::init().await;
115
116        // Start the web server.
117        let port = reserve_tcp_port().unwrap();
118        let mut app = App::<_, Error>::with_state(ApiState::from(network.data_source()));
119        app.register_module(
120            "status",
121            define_api(
122                &Default::default(),
123                MockBase::instance(),
124                "0.0.1".parse().unwrap(),
125            )
126            .unwrap(),
127        )
128        .unwrap();
129        network.spawn(
130            "server",
131            app.serve(format!("0.0.0.0:{port}"), MockBase::instance()),
132        );
133
134        // Start a client.
135        let url = Url::from_str(&format!("http://localhost:{port}/status")).unwrap();
136        let client = Client::<Error, MockBase>::new(url.clone());
137        assert!(client.connect(Some(Duration::from_secs(60))).await);
138
139        // The block height is initially zero.
140        assert_eq!(client.get::<u64>("block-height").send().await.unwrap(), 0);
141
142        // Test Prometheus export.
143        // Create `reqwest` client that allows redirects
144        let reqwest_client = reqwest::Client::builder()
145            .redirect(Policy::limited(5))
146            .build()
147            .unwrap();
148
149        // Ask for the Prometheus data
150        let res = reqwest_client
151            .get(format!("{url}/metrics"))
152            .send()
153            .await
154            .unwrap();
155
156        // Make sure it has the correct response code
157        assert_eq!(res.status(), StatusCode::OK);
158        let prometheus = res.text().await.unwrap();
159        let lines = prometheus.lines().collect::<Vec<_>>();
160        assert!(
161            lines.contains(&"consensus_current_view 0"),
162            "Missing consensus_current_view in metrics:\n{prometheus}"
163        );
164
165        // Start the validators and wait for the block to be finalized.
166        network.start().await;
167
168        // Check updated block height.
169        // being updated and the decide event being published. Retry this a few times until it
170        // succeeds.
171        while client.get::<u64>("block-height").send().await.unwrap() <= 1 {
172            tracing::info!("waiting for block height to update");
173            sleep(Duration::from_secs(1)).await;
174        }
175        let success_rate = client.get::<f64>("success-rate").send().await.unwrap();
176        // If metrics are populating correctly, we should get a finite number. If not, we might get
177        // NaN or infinity due to division by 0.
178        assert!(success_rate.is_finite(), "{success_rate}");
179        // We know at least some views have been successful, since we finalized a block.
180        assert!(success_rate > 0.0, "{success_rate}");
181
182        network.shut_down().await;
183    }
184
185    #[test_log::test(tokio::test(flavor = "multi_thread"))]
186    async fn test_extensions() {
187        let dir = TempDir::with_prefix("test_status_extensions").unwrap();
188        let data_source = ExtensibleDataSource::new(
189            MockDataSource::create(dir.path(), Default::default())
190                .await
191                .unwrap(),
192            0,
193        );
194
195        let extensions = toml! {
196            [route.post_ext]
197            PATH = ["/ext/:val"]
198            METHOD = "POST"
199            ":val" = "Integer"
200
201            [route.get_ext]
202            PATH = ["/ext"]
203            METHOD = "GET"
204        };
205
206        let mut api = define_api::<RwLock<ExtensibleDataSource<MockDataSource, u64>>, MockBase>(
207            &Options {
208                extensions: vec![extensions.into()],
209                ..Default::default()
210            },
211            MockBase::instance(),
212            "0.0.1".parse().unwrap(),
213        )
214        .unwrap();
215        api.get("get_ext", |_, state| {
216            async move { Ok(*state.as_ref()) }.boxed()
217        })
218        .unwrap()
219        .post("post_ext", |req, state| {
220            async move {
221                *state.as_mut() = req.integer_param("val")?;
222                Ok(())
223            }
224            .boxed()
225        })
226        .unwrap();
227
228        let mut app = App::<_, Error>::with_state(RwLock::new(data_source));
229        app.register_module("status", api).unwrap();
230
231        let port = reserve_tcp_port().unwrap();
232        let _server = BackgroundTask::spawn(
233            "server",
234            app.serve(format!("0.0.0.0:{port}"), MockBase::instance()),
235        );
236
237        let client = Client::<Error, MockBase>::new(
238            format!("http://localhost:{port}/status").parse().unwrap(),
239        );
240        assert!(client.connect(Some(Duration::from_secs(60))).await);
241
242        assert_eq!(client.get::<u64>("ext").send().await.unwrap(), 0);
243        client.post::<()>("ext/42").send().await.unwrap();
244        assert_eq!(client.get::<u64>("ext").send().await.unwrap(), 42);
245
246        // Ensure we can still access the built-in functionality.
247        assert_eq!(client.get::<u64>("block-height").send().await.unwrap(), 0);
248    }
249}