Skip to content

Commit 0935d32

Browse files
Add internal endpoint for tokio task dumps (#5594)
# Description of Changes Introduces another `/internal` route for capturing tokio task dumps. The endpoint is available under `/internal/task-dump` and returns a plain-text dump of every task on the selected Tokio runtime. ### Usage Examples #### Dump the main runtime ```bash curl -fsS \ 'http://127.0.0.1:3000/internal/task-dump?runtime=main' \ > task-dump-main.txt ``` `timeout_ms` defaults to 2 seconds. #### Dump the replication runtime with a longer timeout ```bash curl -fsS \ 'http://127.0.0.1:80/internal/task-dump?runtime=replication&timeout_ms=5000' \ > task-dump-replication.txt ``` An unknown runtime returns `400 Bad Request` with the valid names. If the runtime cannot complete the dump within the requested timeout, the endpoint returns `504 Gateway Timeout`. # API and ABI breaking changes None # Expected complexity level and risk 2 # Testing - [x] Manual test on supported platform (not macos)
1 parent f9f1e19 commit 0935d32

6 files changed

Lines changed: 195 additions & 9 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/client-api/Cargo.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ spacetimedb-schema.workspace = true
1717

1818
base64.workspace = true
1919
http-body-util.workspace = true
20-
tokio = { version = "1.2", features = ["full"] }
20+
tokio = { workspace = true, features = ["full"] }
2121
lazy_static = "1.4.0"
2222
log = "0.4.4"
2323
serde = "1.0.136"
@@ -60,6 +60,9 @@ thiserror.workspace = true
6060
[target.'cfg(not(target_env = "msvc"))'.dependencies]
6161
jemalloc_pprof.workspace = true
6262

63+
[target.'cfg(all(target_os = "linux", any(target_arch = "aarch64", target_arch = "x86", target_arch = "x86_64")))'.dependencies]
64+
tokio = { workspace = true, features = ["taskdump"] }
65+
6366
[dev-dependencies]
6467
tower = "0.5"
6568
jsonwebtoken.workspace = true

crates/client-api/src/routes/internal.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
use crate::NodeDelegate;
22

3+
mod task_dump;
4+
5+
pub use task_dump::TaskDumpRegistry;
6+
37
#[cfg(not(target_env = "msvc"))]
48
mod jemalloc_profiling {
59
use axum::body::Body;
@@ -158,5 +162,7 @@ pub fn router<S>() -> axum::Router<S>
158162
where
159163
S: NodeDelegate + Clone + 'static,
160164
{
161-
axum::Router::new().nest("/heap", jemalloc_profiling::jemalloc_router())
165+
axum::Router::new()
166+
.nest("/heap", jemalloc_profiling::jemalloc_router())
167+
.nest("/task-dump", task_dump::router())
162168
}
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
use axum::routing::get;
2+
3+
#[cfg(all(
4+
tokio_unstable,
5+
target_os = "linux",
6+
any(target_arch = "aarch64", target_arch = "x86", target_arch = "x86_64")
7+
))]
8+
mod imp {
9+
use std::{collections::BTreeMap, fmt::Write as _, num::NonZeroU64, sync::Arc, time::Duration};
10+
11+
use axum::{
12+
extract::{Extension, Query},
13+
response::Response,
14+
};
15+
use http::{header::CONTENT_TYPE, StatusCode};
16+
use serde::Deserialize;
17+
use tokio::runtime::Handle;
18+
19+
const DEFAULT_TIMEOUT_MS: u64 = 2_000;
20+
21+
/// The Tokio runtimes which can be inspected by the internal task dump endpoint.
22+
#[derive(Clone, Default)]
23+
pub struct TaskDumpRegistry {
24+
runtimes: Arc<BTreeMap<&'static str, Handle>>,
25+
}
26+
27+
impl TaskDumpRegistry {
28+
pub fn new(runtimes: impl IntoIterator<Item = (&'static str, Handle)>) -> Self {
29+
Self {
30+
runtimes: Arc::new(runtimes.into_iter().collect()),
31+
}
32+
}
33+
}
34+
35+
#[derive(Deserialize)]
36+
pub(super) struct TaskDumpQuery {
37+
runtime: String,
38+
timeout_ms: Option<NonZeroU64>,
39+
}
40+
41+
pub(super) async fn handle_get_task_dump(
42+
registry: Option<Extension<TaskDumpRegistry>>,
43+
Query(query): Query<TaskDumpQuery>,
44+
) -> Result<Response, (StatusCode, String)> {
45+
let Some(Extension(registry)) = registry else {
46+
return Err((
47+
StatusCode::NOT_IMPLEMENTED,
48+
"Tokio task dumps are not configured for this server".into(),
49+
));
50+
};
51+
52+
let Some(runtime) = registry.runtimes.get(query.runtime.as_str()).cloned() else {
53+
let valid = registry.runtimes.keys().copied().collect::<Vec<_>>().join(", ");
54+
return Err((
55+
StatusCode::BAD_REQUEST,
56+
format!("unknown Tokio runtime {:?}; valid runtimes: {valid}", query.runtime),
57+
));
58+
};
59+
60+
let timeout_ms = query.timeout_ms.map_or(DEFAULT_TIMEOUT_MS, NonZeroU64::get);
61+
let dump = tokio::time::timeout(Duration::from_millis(timeout_ms), runtime.dump())
62+
.await
63+
.map_err(|_| {
64+
(
65+
StatusCode::GATEWAY_TIMEOUT,
66+
format!(
67+
"timed out after {timeout_ms}ms while dumping Tokio runtime {:?}",
68+
query.runtime
69+
),
70+
)
71+
})?;
72+
73+
let runtime_name = query.runtime;
74+
let body = tokio::task::spawn_blocking(move || format_dump(&runtime_name, dump))
75+
.await
76+
.map_err(|err| {
77+
(
78+
StatusCode::INTERNAL_SERVER_ERROR,
79+
format!("task dump formatting failed: {err}"),
80+
)
81+
})?;
82+
83+
Response::builder()
84+
.header(CONTENT_TYPE, "text/plain; charset=utf-8")
85+
.body(body.into())
86+
.map_err(|err| (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()))
87+
}
88+
89+
fn format_dump(runtime_name: &str, dump: tokio::runtime::Dump) -> String {
90+
let tasks = dump.tasks();
91+
let mut output = String::new();
92+
writeln!(output, "runtime: {runtime_name}").unwrap();
93+
writeln!(output, "tasks: {}", tasks.iter().count()).unwrap();
94+
95+
for task in tasks.iter() {
96+
writeln!(output, "\nTASK {}:", task.id()).unwrap();
97+
writeln!(output, "{}", task.trace()).unwrap();
98+
}
99+
100+
output
101+
}
102+
103+
#[cfg(test)]
104+
mod tests {
105+
use super::*;
106+
use http_body_util::BodyExt as _;
107+
use tokio::runtime::Handle;
108+
109+
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
110+
async fn dumps_registered_runtime_with_default_timeout() {
111+
let registry = TaskDumpRegistry::new([("main", Handle::current())]);
112+
let response = handle_get_task_dump(
113+
Some(Extension(registry)),
114+
Query(TaskDumpQuery {
115+
runtime: "main".into(),
116+
timeout_ms: None,
117+
}),
118+
)
119+
.await
120+
.unwrap();
121+
122+
assert_eq!(response.status(), StatusCode::OK);
123+
let body = response.into_body().collect().await.unwrap().to_bytes();
124+
let body = std::str::from_utf8(&body).unwrap();
125+
assert!(body.starts_with("runtime: main\ntasks: "));
126+
}
127+
}
128+
}
129+
130+
#[cfg(not(all(
131+
tokio_unstable,
132+
target_os = "linux",
133+
any(target_arch = "aarch64", target_arch = "x86", target_arch = "x86_64")
134+
)))]
135+
mod imp {
136+
use axum::response::{IntoResponse as _, Response};
137+
use http::StatusCode;
138+
use tokio::runtime::Handle;
139+
140+
/// The Tokio runtimes which can be inspected by the internal task dump endpoint.
141+
#[derive(Clone, Default)]
142+
pub struct TaskDumpRegistry;
143+
144+
impl TaskDumpRegistry {
145+
pub fn new(_: impl IntoIterator<Item = (&'static str, Handle)>) -> Self {
146+
Self
147+
}
148+
}
149+
150+
pub(super) async fn handle_get_task_dump() -> Response {
151+
(
152+
StatusCode::NOT_IMPLEMENTED,
153+
"Tokio task dumps require a Linux aarch64, x86, or x86_64 build with tokio_unstable enabled",
154+
)
155+
.into_response()
156+
}
157+
158+
#[cfg(test)]
159+
mod tests {
160+
use super::*;
161+
162+
#[tokio::test]
163+
async fn reports_unsupported_platform() {
164+
assert_eq!(handle_get_task_dump().await.status(), StatusCode::NOT_IMPLEMENTED);
165+
}
166+
}
167+
}
168+
169+
use imp::handle_get_task_dump;
170+
pub use imp::TaskDumpRegistry;
171+
172+
pub fn router<S: Clone + Send + Sync + 'static>() -> axum::Router<S> {
173+
axum::Router::new().route("/", get(handle_get_task_dump))
174+
}

crates/client-api/src/routes/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ pub mod metrics;
1313
pub mod prometheus;
1414
pub mod subscribe;
1515

16+
pub use self::internal::TaskDumpRegistry;
1617
use self::{database::DatabaseRoutes, identity::IdentityRoutes};
1718

1819
/// This API call is just designed to allow clients to determine whether or not they can

crates/standalone/src/subcommands/start.rs

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ use std::sync::Arc;
77

88
use crate::{StandaloneEnv, StandaloneOptions};
99
use anyhow::Context;
10-
use axum::extract::DefaultBodyLimit;
10+
use axum::extract::{DefaultBodyLimit, Extension};
1111
use clap::ArgAction::SetTrue;
1212
use clap::{Arg, ArgMatches};
1313
use spacetimedb::config::{parse_config, CertificateAuthority};
@@ -19,6 +19,7 @@ use spacetimedb::worker_metrics;
1919
use spacetimedb_client_api::routes::database::DatabaseRoutes;
2020
use spacetimedb_client_api::routes::router;
2121
use spacetimedb_client_api::routes::subscribe::WebSocketOptions;
22+
use spacetimedb_client_api::routes::TaskDumpRegistry;
2223
use spacetimedb_paths::cli::{PrivKeyPath, PubKeyPath};
2324
use spacetimedb_paths::server::{ConfigToml, ServerDataDir};
2425
use tokio::net::TcpListener;
@@ -197,19 +198,19 @@ pub async fn exec(args: &ArgMatches, db_cores: JobCores) -> anyhow::Result<()> {
197198
)
198199
.await?;
199200
worker_metrics::spawn_jemalloc_stats(listen_addr.clone());
200-
worker_metrics::spawn_tokio_stats(
201-
listen_addr.clone(),
202-
"main".to_string(),
203-
tokio::runtime::Handle::current(),
204-
);
201+
let main_rt = tokio::runtime::Handle::current();
202+
worker_metrics::spawn_tokio_stats(listen_addr.clone(), "main".to_string(), main_rt.clone());
205203
worker_metrics::spawn_page_pool_stats(listen_addr.clone(), ctx.page_pool().clone());
206204
worker_metrics::spawn_bsatn_rlb_pool_stats(listen_addr.clone(), ctx.bsatn_rlb_pool().clone());
207205
let mut db_routes = DatabaseRoutes::default();
208206
db_routes.root_post = db_routes.root_post.layer(DefaultBodyLimit::disable());
209207
db_routes.db_put = db_routes.db_put.layer(DefaultBodyLimit::disable());
210208
db_routes.pre_publish = db_routes.pre_publish.layer(DefaultBodyLimit::disable());
211209
let extra = axum::Router::new().nest("/health", spacetimedb_client_api::routes::health::router());
212-
let service = router(&ctx, db_routes, IdentityRoutes::default(), extra).with_state(ctx.clone());
210+
let task_dumps = TaskDumpRegistry::new([("main", main_rt)]);
211+
let service = router(&ctx, db_routes, IdentityRoutes::default(), extra)
212+
.layer(Extension(task_dumps))
213+
.with_state(ctx.clone());
213214

214215
// Check if the requested port is available on both IPv4 and IPv6.
215216
// If not, offer to find an available port by incrementing (unless non-interactive).

0 commit comments

Comments
 (0)