Skip to content

Commit 1def7a4

Browse files
Add internal endpoint for tokio task dumps
1 parent 69ebbf7 commit 1def7a4

6 files changed

Lines changed: 296 additions & 11 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: 8 additions & 2 deletions
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;
@@ -154,9 +158,11 @@ mod jemalloc_profiling {
154158
}
155159

156160
// The internal router is for things that are not meant to be exposed to the public API.
157-
pub fn router<S>() -> axum::Router<S>
161+
pub fn router<S>(task_dumps: TaskDumpRegistry) -> 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(task_dumps))
162168
}
Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
use axum::{extract::Extension, 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 _, 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, sync::Mutex};
18+
19+
const MAX_TIMEOUT_MS: u64 = 30_000;
20+
21+
#[derive(Clone)]
22+
struct Runtime {
23+
handle: Handle,
24+
dump_lock: Arc<Mutex<()>>,
25+
}
26+
27+
/// The Tokio runtimes which can be inspected by the internal task dump endpoint.
28+
#[derive(Clone, Default)]
29+
pub struct TaskDumpRegistry {
30+
runtimes: Arc<BTreeMap<&'static str, Runtime>>,
31+
}
32+
33+
impl TaskDumpRegistry {
34+
pub fn new(runtimes: impl IntoIterator<Item = (&'static str, Handle)>) -> Self {
35+
let runtimes = runtimes
36+
.into_iter()
37+
.map(|(name, handle)| {
38+
(
39+
name,
40+
Runtime {
41+
handle,
42+
dump_lock: Arc::new(Mutex::new(())),
43+
},
44+
)
45+
})
46+
.collect();
47+
Self {
48+
runtimes: Arc::new(runtimes),
49+
}
50+
}
51+
52+
fn get(&self, name: &str) -> Option<&Runtime> {
53+
self.runtimes.get(name)
54+
}
55+
56+
fn names(&self) -> impl Iterator<Item = &'static str> + '_ {
57+
self.runtimes.keys().copied()
58+
}
59+
}
60+
61+
#[derive(Deserialize)]
62+
pub(super) struct TaskDumpQuery {
63+
runtime: String,
64+
timeout_ms: u64,
65+
}
66+
67+
impl TaskDumpQuery {
68+
fn validate(&self) -> Result<(), (StatusCode, String)> {
69+
if !(1..=MAX_TIMEOUT_MS).contains(&self.timeout_ms) {
70+
return Err((
71+
StatusCode::BAD_REQUEST,
72+
format!("timeout_ms must be between 1 and {MAX_TIMEOUT_MS}"),
73+
));
74+
}
75+
Ok(())
76+
}
77+
}
78+
79+
pub(super) async fn handle_get_task_dump(
80+
Extension(registry): Extension<TaskDumpRegistry>,
81+
Query(query): Query<TaskDumpQuery>,
82+
) -> Result<Response, (StatusCode, String)> {
83+
query.validate()?;
84+
85+
let Some(runtime) = registry.get(&query.runtime).cloned() else {
86+
let valid = registry.names().collect::<Vec<_>>().join(", ");
87+
return Err((
88+
StatusCode::BAD_REQUEST,
89+
format!("unknown Tokio runtime {:?}; valid runtimes: {valid}", query.runtime),
90+
));
91+
};
92+
93+
let permit = runtime.dump_lock.try_lock_owned().map_err(|_| {
94+
(
95+
StatusCode::CONFLICT,
96+
format!("a task dump is already in progress for runtime {:?}", query.runtime),
97+
)
98+
})?;
99+
100+
// Keep the permit in the spawned task so that a timed-out dump continues
101+
// to exclude new requests until Tokio's dump future actually finishes.
102+
let dump_task = tokio::spawn(async move {
103+
let dump = runtime.handle.dump().await;
104+
(permit, dump)
105+
});
106+
let (_permit, dump) = tokio::time::timeout(Duration::from_millis(query.timeout_ms), dump_task)
107+
.await
108+
.map_err(|_| {
109+
(
110+
StatusCode::GATEWAY_TIMEOUT,
111+
format!(
112+
"timed out after {}ms while dumping Tokio runtime {:?}",
113+
query.timeout_ms, query.runtime
114+
),
115+
)
116+
})?
117+
.map_err(|err| {
118+
(
119+
StatusCode::INTERNAL_SERVER_ERROR,
120+
format!("task dump worker failed for runtime {:?}: {err}", query.runtime),
121+
)
122+
})?;
123+
124+
let runtime_name = query.runtime;
125+
let body = tokio::task::spawn_blocking(move || format_dump(&runtime_name, dump))
126+
.await
127+
.map_err(|err| {
128+
(
129+
StatusCode::INTERNAL_SERVER_ERROR,
130+
format!("task dump formatting failed: {err}"),
131+
)
132+
})?;
133+
134+
Response::builder()
135+
.header(CONTENT_TYPE, "text/plain; charset=utf-8")
136+
.body(body.into())
137+
.map_err(|err| (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()))
138+
}
139+
140+
fn format_dump(runtime_name: &str, dump: tokio::runtime::Dump) -> String {
141+
let tasks = dump.tasks();
142+
let mut output = String::new();
143+
writeln!(output, "runtime: {runtime_name}").unwrap();
144+
writeln!(output, "tasks: {}", tasks.iter().count()).unwrap();
145+
146+
for task in tasks.iter() {
147+
writeln!(output, "\nTASK {}:", task.id()).unwrap();
148+
writeln!(output, "{}", task.trace()).unwrap();
149+
}
150+
151+
output
152+
}
153+
154+
#[cfg(test)]
155+
mod tests {
156+
use super::*;
157+
use http_body_util::BodyExt as _;
158+
159+
#[tokio::test]
160+
async fn registry_names_are_sorted() {
161+
let handle = Handle::current();
162+
let registry = TaskDumpRegistry::new([("replication", handle.clone()), ("main", handle)]);
163+
164+
assert_eq!(registry.names().collect::<Vec<_>>(), ["main", "replication"]);
165+
}
166+
167+
#[test]
168+
fn timeout_must_be_in_range() {
169+
for timeout_ms in [1, MAX_TIMEOUT_MS] {
170+
assert!(TaskDumpQuery {
171+
runtime: "main".into(),
172+
timeout_ms,
173+
}
174+
.validate()
175+
.is_ok());
176+
}
177+
178+
for timeout_ms in [0, MAX_TIMEOUT_MS + 1] {
179+
assert!(TaskDumpQuery {
180+
runtime: "main".into(),
181+
timeout_ms,
182+
}
183+
.validate()
184+
.is_err());
185+
}
186+
}
187+
188+
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
189+
async fn dumps_registered_runtime() {
190+
let registry = TaskDumpRegistry::new([("main", Handle::current())]);
191+
let response = handle_get_task_dump(
192+
Extension(registry),
193+
Query(TaskDumpQuery {
194+
runtime: "main".into(),
195+
timeout_ms: 10_000,
196+
}),
197+
)
198+
.await
199+
.unwrap();
200+
201+
assert_eq!(response.status(), StatusCode::OK);
202+
let body = response.into_body().collect().await.unwrap().to_bytes();
203+
let body = std::str::from_utf8(&body).unwrap();
204+
assert!(body.starts_with("runtime: main\ntasks: "));
205+
}
206+
}
207+
}
208+
209+
#[cfg(not(all(
210+
tokio_unstable,
211+
target_os = "linux",
212+
any(target_arch = "aarch64", target_arch = "x86", target_arch = "x86_64")
213+
)))]
214+
mod imp {
215+
use axum::response::{IntoResponse as _, Response};
216+
use http::StatusCode;
217+
use tokio::runtime::Handle;
218+
219+
/// The Tokio runtimes which can be inspected by the internal task dump endpoint.
220+
#[derive(Clone, Default)]
221+
pub struct TaskDumpRegistry;
222+
223+
impl TaskDumpRegistry {
224+
pub fn new(_: impl IntoIterator<Item = (&'static str, Handle)>) -> Self {
225+
Self
226+
}
227+
}
228+
229+
pub(super) async fn handle_get_task_dump() -> Response {
230+
(
231+
StatusCode::NOT_IMPLEMENTED,
232+
"Tokio task dumps require a Linux aarch64, x86, or x86_64 build with tokio_unstable enabled",
233+
)
234+
.into_response()
235+
}
236+
237+
#[cfg(test)]
238+
mod tests {
239+
use super::*;
240+
241+
#[tokio::test]
242+
async fn reports_unsupported_platform() {
243+
assert_eq!(handle_get_task_dump().await.status(), StatusCode::NOT_IMPLEMENTED);
244+
}
245+
}
246+
}
247+
248+
use imp::handle_get_task_dump;
249+
pub use imp::TaskDumpRegistry;
250+
251+
pub fn router<S: Clone + Send + Sync + 'static>(registry: TaskDumpRegistry) -> axum::Router<S> {
252+
axum::Router::new()
253+
.route("/", get(handle_get_task_dump))
254+
.layer(Extension(registry))
255+
}

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

Lines changed: 21 additions & 1 deletion
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
@@ -26,6 +27,25 @@ pub fn router<S>(
2627
identity_routes: IdentityRoutes<S>,
2728
extra: axum::Router<S>,
2829
) -> axum::Router<S>
30+
where
31+
S: NodeDelegate + ControlStateDelegate + Authorization + Clone + 'static,
32+
{
33+
router_with_task_dumps(
34+
ctx,
35+
database_routes,
36+
identity_routes,
37+
extra,
38+
TaskDumpRegistry::default(),
39+
)
40+
}
41+
42+
pub fn router_with_task_dumps<S>(
43+
ctx: &S,
44+
database_routes: DatabaseRoutes<S>,
45+
identity_routes: IdentityRoutes<S>,
46+
extra: axum::Router<S>,
47+
task_dumps: TaskDumpRegistry,
48+
) -> axum::Router<S>
2949
where
3050
S: NodeDelegate + ControlStateDelegate + Authorization + Clone + 'static,
3151
{
@@ -46,5 +66,5 @@ where
4666

4767
axum::Router::new()
4868
.nest("/v1", router.layer(cors))
49-
.nest("/internal", internal::router())
69+
.nest("/internal", internal::router(task_dumps))
5070
}

crates/standalone/src/subcommands/start.rs

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,9 @@ use spacetimedb::startup::{self, TracingOptions};
1717
use spacetimedb::util::jobs::JobCores;
1818
use spacetimedb::worker_metrics;
1919
use spacetimedb_client_api::routes::database::DatabaseRoutes;
20-
use spacetimedb_client_api::routes::router;
20+
use spacetimedb_client_api::routes::router_with_task_dumps;
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,18 @@ 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 =
212+
router_with_task_dumps(&ctx, db_routes, IdentityRoutes::default(), extra, task_dumps).with_state(ctx.clone());
213213

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

0 commit comments

Comments
 (0)