Skip to main content

tuwunel_database/pool/
configure.rs

1use std::{path::PathBuf, sync::Arc};
2
3use tuwunel_core::{
4	Config, Server, at, debug,
5	debug::INFO_SPAN_LEVEL,
6	debug_info, debug_warn, expected, info, is_equal_to,
7	utils::{
8		BoolExt,
9		math::usize_from_f64,
10		result::LogDebugErr,
11		stream,
12		stream::{AMPLIFICATION_LIMIT, WIDTH_LIMIT},
13		sys::{
14			cgroup_max_tasks,
15			compute::{available_parallelism, cores_available, is_core_available},
16			max_threads,
17			storage::{self, MultiDevice},
18		},
19	},
20	warn,
21};
22
23use super::{QUEUE_LIMIT, WORKER_LIMIT};
24
25/// Determine storage hardware capabilities of the system for configuring the
26/// shape of the database frontend threadpool.
27///
28/// Returns a tuple of:
29/// - `topology` Vector mapping hardware cores to hardware queues. Systems with
30///   fewer queues than cores will see queue ID's repeated. Systems with the
31///   same or more queues as cores will usually see a 1:1 association of core
32///   ID's to queue ID's. Systems with sparse core assignments will see 0 for
33///   core ID positions not available to the process. Systems where detection
34///   failed will see a default of 1:1 core identity as a best-guess maintaining
35///   core locality.
36/// - `workers` Vector mapping hardware queues to the number of threads to spawn
37///   in service of that queue. Systems with fewer queues than cores will set an
38///   affinity mask for each thread to multiple cores based on the topology.
39///   Systems with equal or more hardware queues than cores will set a single
40///   affinity for each thread.
41/// - `queues` Vector of software mpmc queues to create and the size of each
42///   queue. Each indice is associated with a thread-pool of workers which it
43///   feeds requests from various tokio tasks. When this queue reaches capacity
44///   the tokio task must yield.
45#[tracing::instrument(
46	level = INFO_SPAN_LEVEL,
47	skip_all,
48	ret(level = "trace"),
49)]
50pub(super) fn configure(server: &Arc<Server>) -> (Vec<usize>, Vec<usize>, Vec<usize>) {
51	let config = &server.config;
52	let num_cores = available_parallelism();
53
54	let cores_max = cores_available()
55		.last()
56		.unwrap_or(0)
57		.saturating_add(1);
58
59	let path: PathBuf = config.database_path.clone();
60	let device_name = storage::name_from_path(&path)
61		.log_debug_err()
62		.ok();
63
64	let devices = storage::md_discover(&path);
65	let topology_detected = devices.md.is_empty().is_false();
66	debug!(?topology_detected, ?device_name, ?devices);
67
68	let default_worker_count = topology_detected
69		.is_false()
70		.then_some(config.db_pool_workers)
71		.map(|workers| workers.saturating_mul(num_cores));
72
73	let total_tags = sum_total_tags(&devices, default_worker_count);
74	let topology = compute_topology(&devices, topology_detected, cores_max);
75	let max_workers =
76		compute_max_workers(&devices, default_worker_count, config.db_pool_max_workers);
77
78	let chan_limit = expected!(max_workers / num_cores)
79		.saturating_sub(8)
80		.saturating_add(1)
81		.next_multiple_of(8);
82
83	let workers =
84		compute_workers(&devices, config, default_worker_count, topology.len(), chan_limit);
85
86	let workers = constrain_workers(workers, max_workers);
87
88	let queues: Vec<usize> = workers
89		.iter()
90		.map(|count| {
91			count
92				.saturating_mul(config.db_pool_queue_mult)
93				.min(QUEUE_LIMIT.1)
94		})
95		.collect();
96
97	let total_workers = workers.iter().sum::<usize>();
98	let total_capacity = queues.iter().sum::<usize>();
99	let num_queues = queues.iter().filter(|&&cap| cap > 0).count();
100
101	if config.stream_width_scale > 0.0 {
102		update_stream_width(server, num_queues, total_workers, total_capacity);
103	}
104
105	log_topology(
106		topology_detected,
107		device_name.as_deref(),
108		num_cores,
109		&topology,
110		&workers,
111		&queues,
112		num_queues,
113		total_workers,
114		total_tags,
115		total_capacity,
116	);
117
118	warn_task_budget(total_workers, num_cores);
119
120	assert!(total_workers > 0, "some workers expected");
121	// A queue's capacity is positive exactly when its group is populated, so
122	// num_queues is the floor constrain_workers guarantees.
123	debug_assert!(total_workers <= max_workers.max(num_queues), "spawning too many workers");
124
125	assert!(!queues.is_empty(), "some queues expected");
126	assert!(!queues.iter().copied().all(is_equal_to!(0)), "positive queue capacity expected");
127
128	(topology, workers, queues)
129}
130
131/// Sum the total number of possible tags. Without hardware detection this
132/// reduces to the default worker count. The thread-worker model never
133/// approaches actual NVMe capacity, but the value still informs request
134/// capacity downstream.
135fn sum_total_tags(devices: &MultiDevice, default_worker_count: Option<usize>) -> usize {
136	devices
137		.md
138		.iter()
139		.flat_map(|md| md.mq.iter())
140		.filter(|mq| mq.cpu_list.iter().copied().any(is_core_available))
141		.filter_map(|mq| mq.nr_tags)
142		.chain(default_worker_count)
143		.fold(0_usize, usize::saturating_add)
144}
145
146/// Map cores to their associated hardware queue. Shared queues repeat across
147/// cores; sparse unavailable cores default to 0; undetected hardware falls back
148/// to the core identity as a best-guess maintaining core locality.
149fn compute_topology(
150	devices: &MultiDevice,
151	topology_detected: bool,
152	cores_max: usize,
153) -> Vec<usize> {
154	devices
155		.md
156		.iter()
157		.flat_map(|md| md.mq.iter())
158		.fold(vec![0; cores_max], |mut topology, mq| {
159			mq.cpu_list
160				.iter()
161				.filter(|&&id| id < cores_max)
162				.filter(|&&id| is_core_available(id))
163				.for_each(|&id| {
164					topology[id] = mq.id;
165				});
166
167			topology
168		})
169		.into_iter()
170		.enumerate()
171		.map(|(core_id, queue_id)| {
172			topology_detected
173				.then_some(queue_id)
174				.unwrap_or(core_id)
175		})
176		.collect()
177}
178
179/// Determine an ideal max worker count based on true capacity. The true value
180/// is rarely attainable in a thread-worker model so the result is clamped by
181/// both the rlimit-derived budget and the static `WORKER_LIMIT` range.
182fn compute_max_workers(
183	devices: &MultiDevice,
184	default_worker_count: Option<usize>,
185	max_workers_cfg: usize,
186) -> usize {
187	let max_threads = max_threads()
188		.map(at!(0))
189		.unwrap_or(usize::MAX)
190		.saturating_div(3);
191
192	devices
193		.md
194		.iter()
195		.flat_map(|md| md.mq.iter())
196		.filter_map(|mq| mq.nr_tags)
197		.chain(default_worker_count)
198		.fold(0_usize, usize::saturating_add)
199		.min(max_workers_cfg)
200		.clamp(WORKER_LIMIT.0, max_threads)
201		.clamp(WORKER_LIMIT.0, WORKER_LIMIT.1)
202}
203
204/// Determine the worker groupings. Each indice represents a hardware queue and
205/// contains the number of workers which will service it. The vector is
206/// truncated to the number of cores on systems with multiple hardware queues
207/// per core, and the per-pool count is capped well below NVMe capacity.
208fn compute_workers(
209	devices: &MultiDevice,
210	config: &Config,
211	default_worker_count: Option<usize>,
212	topology_len: usize,
213	chan_limit: usize,
214) -> Vec<usize> {
215	let default_workers = default_worker_count
216		.into_iter()
217		.cycle()
218		.enumerate()
219		.map(move |(core_id, count)| {
220			is_core_available(core_id)
221				.then_some(count)
222				.unwrap_or(0)
223				.min(chan_limit)
224		});
225
226	devices
227		.md
228		.iter()
229		.inspect(|md| debug!(?md))
230		.flat_map(|md| md.mq.iter())
231		.map(|mq| {
232			let shares = mq
233				.cpu_list
234				.iter()
235				.filter(|&&id| is_core_available(id))
236				.count();
237
238			let conf_limit = config
239				.db_pool_workers_limit
240				.saturating_mul(shares);
241
242			let hard_limit = devices
243				.md
244				.iter()
245				.filter(|_| shares > 0)
246				.fold(0_usize, |acc, mq| {
247					mq.nr_requests
248						.map(|nr| nr.min(conf_limit))
249						.or(Some(conf_limit))
250						.map(|nr| acc.saturating_add(nr))
251						.unwrap_or(acc)
252				});
253
254			let tags = mq
255				.nr_tags
256				.unwrap_or(WORKER_LIMIT.0)
257				.min(hard_limit)
258				.min(chan_limit);
259
260			debug!(?mq, ?shares, ?tags, ?conf_limit, ?hard_limit, ?chan_limit);
261
262			tags
263		})
264		.chain(default_workers)
265		.take(topology_len)
266		.collect()
267}
268
269/// Reduce the worker groups until their total satisfies the configured
270/// maximum.
271///
272/// Group counts are levelled down toward an even share of the maximum, with
273/// the remainder spread one apiece over the leading populated groups. Every
274/// populated group retains at least one worker so its queue keeps service,
275/// making the number of populated groups the effective floor.
276fn constrain_workers(mut workers: Vec<usize>, max_workers: usize) -> Vec<usize> {
277	let total: usize = workers.iter().sum();
278
279	if total <= max_workers {
280		return workers;
281	}
282
283	let num_groups = workers.iter().filter(|&&count| count > 0).count();
284
285	let share = expected!(max_workers / num_groups);
286	let extra = expected!(max_workers % num_groups);
287
288	workers
289		.iter_mut()
290		.filter(|count| **count > 0)
291		.enumerate()
292		.for_each(|(rank, count)| {
293			*count = share
294				.saturating_add(usize::from(rank < extra))
295				.max(1)
296				.min(*count);
297		});
298
299	workers
300}
301
302#[expect(clippy::too_many_arguments)]
303fn log_topology(
304	topology_detected: bool,
305	device_name: Option<&str>,
306	num_cores: usize,
307	topology: &[usize],
308	workers: &[usize],
309	queues: &[usize],
310	num_queues: usize,
311	total_workers: usize,
312	total_tags: usize,
313	total_capacity: usize,
314) {
315	if topology_detected {
316		debug_info!(?num_cores, ?topology, ?workers, ?queues, "Frontend topology",);
317		info!(
318			device_name = ?device_name.unwrap_or("None"),
319			?num_queues,
320			?total_workers,
321			?total_tags,
322			?total_capacity,
323			stream_width = ?stream::automatic_width(),
324			amplification = ?stream::automatic_amplification(),
325			"Frontend topology",
326		);
327	} else {
328		debug_info!(?num_cores, ?topology, ?workers, ?queues, "Frontend topology (defaults)");
329		debug_warn!(
330			device_name = ?device_name.unwrap_or("None"),
331			?total_workers,
332			?total_capacity,
333			stream_width = ?stream::automatic_width(),
334			amplification = ?stream::automatic_amplification(),
335			"Storage hardware not detected for database directory; assuming defaults.",
336		);
337	}
338}
339
340/// Warn when the pool's threads and the rest of the process may exceed a
341/// container task limit.
342///
343/// The check is advisory and the pool is not resized: which of the two
344/// numbers is the wrong one is the operator's call. A host whose cgroup sets
345/// no task limit is skipped.
346fn warn_task_budget(total_workers: usize, num_cores: usize) {
347	// Threads outside the pool measured 6 on two cores and 22 on eight, the
348	// tokio workers being num_cores of them.
349	const TASK_RESERVE: usize = 32;
350
351	let Some(max_tasks) = cgroup_max_tasks() else {
352		return;
353	};
354
355	let tasks = total_workers
356		.saturating_add(num_cores)
357		.saturating_add(TASK_RESERVE);
358
359	if tasks > max_tasks {
360		warn!(
361			?total_workers,
362			?max_tasks,
363			"The database pool may exceed this container's task limit; raise the task limit \
364			 (--pids-limit for docker and podman) or lower db_pool_max_workers."
365		);
366	}
367}
368
369#[expect(clippy::as_conversions, clippy::cast_precision_loss)]
370fn update_stream_width(
371	server: &Arc<Server>,
372	num_queues: usize,
373	total_workers: usize,
374	_total_capacity: usize,
375) {
376	assert!(num_queues > 0, "Expected at least one queue.");
377	assert!(total_workers > 0, "Expected some workers.");
378
379	let config = &server.config;
380	let scale: f64 = config.stream_width_scale.min(100.0).into();
381	let max_width = expected!(total_workers / num_queues);
382
383	let old_width = stream::automatic_width();
384	let old_scale_width = expected!(old_width * num_queues);
385
386	let new_scale = total_workers as f64 / old_scale_width as f64;
387	let new_scale = new_scale.clamp(1.0, 4.0);
388	let new_scale_width = new_scale * old_width as f64;
389	let new_scale_width = usize_from_f64(new_scale_width)
390		.expect("failed to convert f64 to usize")
391		.next_multiple_of(8);
392
393	let req_width = usize_from_f64(scale * new_scale_width as f64)
394		.expect("failed to convert f64 to usize")
395		.next_multiple_of(4)
396		.min(max_width)
397		.clamp(WIDTH_LIMIT.0, WIDTH_LIMIT.1);
398
399	let req_amp = new_scale * config.stream_amplification as f64;
400	let req_amp = usize_from_f64(req_amp * scale)
401		.expect("failed to convert f64 to usize")
402		.next_multiple_of(64)
403		.clamp(AMPLIFICATION_LIMIT.0, AMPLIFICATION_LIMIT.1);
404
405	let (old_width, new_width) = stream::set_width(req_width);
406	let (old_amp, new_amp) = stream::set_amplification(req_amp);
407	debug!(
408		config_scale = ?config.stream_width_scale,
409		?old_width,
410		?new_scale,
411		?new_width,
412		?old_amp,
413		?new_amp,
414		"Updated global stream width"
415	);
416}
417
418#[cfg(test)]
419mod tests {
420	use super::constrain_workers;
421
422	#[test]
423	fn constrain_respects_the_maximum() {
424		let constrained = constrain_workers(vec![8; 24], 64);
425
426		assert_eq!(constrained.iter().sum::<usize>(), 64);
427		assert!(constrained.iter().all(|&count| count > 0));
428	}
429
430	#[test]
431	fn constrain_floors_populated_groups() {
432		assert_eq!(constrain_workers(vec![8, 0, 8, 8], 2), vec![1, 0, 1, 1]);
433	}
434
435	#[test]
436	fn constrain_passes_a_satisfied_total() {
437		let workers = vec![4, 0, 4];
438
439		assert_eq!(constrain_workers(workers.clone(), 8), workers);
440	}
441}