Skip to main content

bestool_postgres/
pgtune.rs

1//! PostgreSQL tuning value computation.
2//!
3//! Produces the settings a PostgreSQL server on a Tamanu host should run with,
4//! replicating pgtune's values for an OLTP workload on SSD storage, adjusted for
5//! a resource budget that reserves headroom for the co-located application,
6//! reverse proxy, and backup tooling.
7//!
8//! The same budget and expected values back the doctor's tuning health check, so
9//! a host tuned from here passes that check.
10
11pub mod conf_block;
12
13/// One kibibyte's worth of the KiB unit these functions work in (i.e. 1).
14const KIB: u64 = 1;
15/// A mebibyte expressed in KiB.
16const MIB: u64 = 1024 * KIB;
17/// A gibibyte expressed in KiB.
18const GIB: u64 = 1024 * MIB;
19
20/// The host operating system, which changes a handful of tuning values.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum Platform {
23	Linux,
24	Windows,
25}
26
27impl Platform {
28	/// The platform this build targets.
29	pub const fn current() -> Self {
30		if cfg!(target_os = "windows") {
31			Self::Windows
32		} else {
33			Self::Linux
34		}
35	}
36
37	fn is_windows(self) -> bool {
38		matches!(self, Self::Windows)
39	}
40}
41
42/// Raw host resources, before any budgeting.
43#[derive(Debug, Clone, Copy)]
44pub struct HostResources {
45	/// Total physical memory, in kibibytes.
46	pub total_ram_kib: u64,
47	/// Number of logical CPUs.
48	pub cpus: u32,
49}
50
51/// The budgeted resources every tuning value is derived from.
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub struct Budget {
54	/// Memory, in kibibytes, that PostgreSQL may size its caches against.
55	pub ram_kib: u64,
56	/// CPUs PostgreSQL may spread parallel work across.
57	pub cpus: u32,
58}
59
60/// Everything needed to compute a full tuning set.
61#[derive(Debug, Clone, Copy)]
62pub struct TuneInputs {
63	pub platform: Platform,
64	pub resources: HostResources,
65	/// The server's major version (e.g. 16).
66	pub pg_major: u32,
67	/// The `max_connections` to size around.
68	pub max_connections: u32,
69	/// Whether the running server reports lz4 as a supported WAL compression
70	/// method. When unknown, pass `false`.
71	pub lz4_wal_supported: bool,
72	/// The `temp_file_limit`, in kibibytes, when it could be derived from the
73	/// data volume. Emitted only when present.
74	pub temp_file_limit_kib: Option<u64>,
75}
76
77/// A single `key = value` tuning directive.
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct Setting {
80	pub key: &'static str,
81	pub value: String,
82}
83
84impl Setting {
85	fn new(key: &'static str, value: impl Into<String>) -> Self {
86		Self {
87			key,
88			value: value.into(),
89		}
90	}
91}
92
93/// The RAM PostgreSQL is budgeted, in kibibytes.
94///
95/// Windows hosts are treated as entitled to at most 4 GiB, because they
96/// co-locate more workloads and PostgreSQL for Windows does not benefit from a
97/// large shared memory area. Elsewhere the budget holds back a fixed 4 GiB on
98/// hosts with real RAM, and splits small hosts down the middle.
99fn budget_ram_kib(platform: Platform, total_ram_kib: u64) -> u64 {
100	if platform.is_windows() {
101		(total_ram_kib / 2).min(4 * GIB)
102	} else if total_ram_kib >= 8 * GIB {
103		total_ram_kib - 4 * GIB
104	} else {
105		total_ram_kib / 2
106	}
107}
108
109/// The CPUs PostgreSQL is budgeted, reserving cores for co-located workloads.
110fn budget_cpus(cpus: u32) -> u32 {
111	if cpus >= 8 {
112		cpus - 4
113	} else if cpus >= 5 {
114		4
115	} else if cpus >= 2 {
116		2
117	} else {
118		1
119	}
120}
121
122/// Compute the resource budget for a host.
123pub fn budget(platform: Platform, resources: HostResources) -> Budget {
124	Budget {
125		ram_kib: budget_ram_kib(platform, resources.total_ram_kib),
126		cpus: budget_cpus(resources.cpus),
127	}
128}
129
130/// Expected `shared_buffers`, in kibibytes, for a budget.
131pub fn expected_shared_buffers_kib(budget: &Budget, platform: Platform, pg_major: u32) -> u64 {
132	let value = budget.ram_kib / 4;
133	if platform.is_windows() && pg_major < 10 {
134		value.min(512 * MIB)
135	} else {
136		value
137	}
138}
139
140/// Expected `effective_cache_size`, in kibibytes, for a budget.
141pub fn expected_effective_cache_kib(budget: &Budget) -> u64 {
142	(budget.ram_kib * 3) / 4
143}
144
145/// Expected `maintenance_work_mem`, in kibibytes, for a budget.
146pub fn expected_maintenance_kib(budget: &Budget, platform: Platform, pg_major: u32) -> u64 {
147	let value = budget.ram_kib / 16;
148	let limit = if platform.is_windows() && pg_major <= 17 {
149		2 * GIB
150	} else {
151		8 * GIB
152	};
153	if value >= limit {
154		if platform.is_windows() && pg_major <= 17 {
155			limit - MIB
156		} else {
157			limit
158		}
159	} else {
160		value
161	}
162}
163
164fn expected_work_mem_kib(
165	budget: &Budget,
166	platform: Platform,
167	pg_major: u32,
168	max_connections: u32,
169) -> u64 {
170	let shared = expected_shared_buffers_kib(budget, platform, pg_major);
171	let parallel = if budget.cpus >= 4 { budget.cpus } else { 8 };
172	let divisor = u64::from(max_connections + parallel) * 3;
173	let mut value = (budget.ram_kib.saturating_sub(shared)) / divisor.max(1);
174	value = value.max(4 * MIB);
175	if platform.is_windows() && pg_major <= 17 {
176		value = value.min(2 * GIB - MIB);
177	}
178	value
179}
180
181fn expected_wal_buffers_kib(shared_buffers_kib: u64) -> u64 {
182	let raw = (shared_buffers_kib * 3) / 100;
183	let capped = raw.clamp(32, 16 * MIB);
184	// pgtune snaps a value within the top band up to the full 16 MiB.
185	if capped > 14 * MIB && capped < 16 * MIB {
186		16 * MIB
187	} else {
188		capped
189	}
190}
191
192/// Render a kibibyte quantity with the largest unit that divides it exactly,
193/// matching the `kB`/`MB`/`GB` forms PostgreSQL accepts.
194fn render_kib(kib: u64) -> String {
195	if kib.is_multiple_of(GIB) {
196		format!("{}GB", kib / GIB)
197	} else if kib.is_multiple_of(MIB) {
198		format!("{}MB", kib / MIB)
199	} else {
200		format!("{kib}kB")
201	}
202}
203
204/// Compute the full tuning set, in canonical order.
205pub fn compute(input: &TuneInputs) -> Vec<Setting> {
206	let TuneInputs {
207		platform,
208		resources,
209		pg_major,
210		max_connections,
211		lz4_wal_supported,
212		temp_file_limit_kib,
213	} = *input;
214	let budget = budget(platform, resources);
215	let mut out = Vec::new();
216
217	let shared_buffers = expected_shared_buffers_kib(&budget, platform, pg_major);
218	out.push(Setting::new("max_connections", max_connections.to_string()));
219	out.push(Setting::new("shared_buffers", render_kib(shared_buffers)));
220	out.push(Setting::new(
221		"effective_cache_size",
222		render_kib(expected_effective_cache_kib(&budget)),
223	));
224	out.push(Setting::new(
225		"maintenance_work_mem",
226		render_kib(expected_maintenance_kib(&budget, platform, pg_major)),
227	));
228	out.push(Setting::new("checkpoint_completion_target", "0.9"));
229	out.push(Setting::new(
230		"wal_buffers",
231		render_kib(expected_wal_buffers_kib(shared_buffers)),
232	));
233	out.push(Setting::new("default_statistics_target", "100"));
234	out.push(Setting::new("random_page_cost", "1.1"));
235
236	// effective_io_concurrency relies on posix_fadvise, absent on Windows.
237	if !platform.is_windows() {
238		out.push(Setting::new("effective_io_concurrency", "200"));
239	}
240
241	out.push(Setting::new(
242		"work_mem",
243		render_kib(expected_work_mem_kib(
244			&budget,
245			platform,
246			pg_major,
247			max_connections,
248		)),
249	));
250	out.push(Setting::new(
251		"huge_pages",
252		if shared_buffers >= 2 * GIB {
253			"try"
254		} else {
255			"off"
256		},
257	));
258	out.push(Setting::new("min_wal_size", "2GB"));
259	out.push(Setting::new("max_wal_size", "8GB"));
260
261	if budget.cpus >= 4 {
262		let per_gather = budget.cpus.div_ceil(2).min(4);
263		out.push(Setting::new(
264			"max_worker_processes",
265			budget.cpus.to_string(),
266		));
267		out.push(Setting::new(
268			"max_parallel_workers_per_gather",
269			per_gather.to_string(),
270		));
271		if pg_major >= 10 {
272			out.push(Setting::new(
273				"max_parallel_workers",
274				budget.cpus.to_string(),
275			));
276		}
277		if pg_major >= 11 {
278			out.push(Setting::new(
279				"max_parallel_maintenance_workers",
280				per_gather.to_string(),
281			));
282		}
283	}
284
285	let autovacuum_max_workers = if budget.cpus >= 32 {
286		Some(5)
287	} else if budget.cpus >= 16 {
288		Some(4)
289	} else {
290		None
291	};
292	if let Some(workers) = autovacuum_max_workers {
293		out.push(Setting::new("autovacuum_max_workers", workers.to_string()));
294	}
295	if expected_maintenance_kib(&budget, platform, pg_major) >= 2 * GIB {
296		out.push(Setting::new("autovacuum_work_mem", "2GB"));
297	}
298
299	if pg_major >= 18 {
300		let io_workers = (budget.cpus / 4).clamp(3, 32);
301		if io_workers > 3 {
302			out.push(Setting::new("io_workers", io_workers.to_string()));
303		}
304		// io_uring is Linux-only; leave the default worker method on Windows.
305		if !platform.is_windows() {
306			out.push(Setting::new("io_method", "io_uring"));
307		}
308	}
309
310	if pg_major >= 12 {
311		out.push(Setting::new("jit", "off"));
312	}
313
314	let wal_compression = if pg_major >= 15 {
315		if lz4_wal_supported {
316			Some("lz4")
317		} else {
318			Some("on")
319		}
320	} else if pg_major >= 10 {
321		Some("on")
322	} else {
323		None
324	};
325	if let Some(value) = wal_compression {
326		out.push(Setting::new("wal_compression", value));
327	}
328
329	if let Some(kib) = temp_file_limit_kib {
330		out.push(Setting::new("temp_file_limit", render_kib(kib)));
331	}
332
333	out
334}
335
336#[cfg(test)]
337mod tests {
338	use super::*;
339
340	fn get<'a>(settings: &'a [Setting], key: &str) -> Option<&'a str> {
341		settings
342			.iter()
343			.find(|s| s.key == key)
344			.map(|s| s.value.as_str())
345	}
346
347	fn windows_inputs(total_gib: u64, cpus: u32) -> TuneInputs {
348		TuneInputs {
349			platform: Platform::Windows,
350			resources: HostResources {
351				total_ram_kib: total_gib * GIB,
352				cpus,
353			},
354			pg_major: 16,
355			max_connections: 100,
356			lz4_wal_supported: false,
357			temp_file_limit_kib: None,
358		}
359	}
360
361	#[test]
362	fn windows_budget_is_capped_at_4gib() {
363		assert_eq!(budget_ram_kib(Platform::Windows, 4 * GIB), 2 * GIB);
364		assert_eq!(budget_ram_kib(Platform::Windows, 8 * GIB), 4 * GIB);
365		assert_eq!(budget_ram_kib(Platform::Windows, 16 * GIB), 4 * GIB);
366		assert_eq!(budget_ram_kib(Platform::Windows, 64 * GIB), 4 * GIB);
367	}
368
369	#[test]
370	fn linux_budget_reserves_four_gib() {
371		assert_eq!(budget_ram_kib(Platform::Linux, 32 * GIB), 28 * GIB);
372		assert_eq!(budget_ram_kib(Platform::Linux, 4 * GIB), 2 * GIB);
373	}
374
375	#[test]
376	fn windows_shared_buffers_scale_with_budget() {
377		assert_eq!(
378			get(&compute(&windows_inputs(4, 2)), "shared_buffers"),
379			Some("512MB")
380		);
381		assert_eq!(
382			get(&compute(&windows_inputs(8, 2)), "shared_buffers"),
383			Some("1GB")
384		);
385		assert_eq!(
386			get(&compute(&windows_inputs(16, 4)), "shared_buffers"),
387			Some("1GB")
388		);
389		assert_eq!(
390			get(&compute(&windows_inputs(64, 16)), "shared_buffers"),
391			Some("1GB")
392		);
393	}
394
395	#[test]
396	fn windows_omits_linux_only_settings() {
397		let s = compute(&windows_inputs(16, 8));
398		assert_eq!(get(&s, "effective_io_concurrency"), None);
399		assert_eq!(get(&s, "io_method"), None);
400	}
401
402	#[test]
403	fn windows_16gib_core_values() {
404		let s = compute(&windows_inputs(16, 4));
405		// budget = 4GiB
406		assert_eq!(get(&s, "effective_cache_size"), Some("3GB"));
407		assert_eq!(get(&s, "maintenance_work_mem"), Some("256MB"));
408		assert_eq!(get(&s, "max_wal_size"), Some("8GB"));
409		assert_eq!(get(&s, "random_page_cost"), Some("1.1"));
410		assert_eq!(get(&s, "huge_pages"), Some("off"));
411	}
412
413	#[test]
414	fn parallelism_gated_on_cpu_budget() {
415		// 2 CPUs -> budget 2 -> no parallel settings.
416		let s = compute(&windows_inputs(16, 2));
417		assert_eq!(get(&s, "max_worker_processes"), None);
418		// 8 CPUs -> budget 4 -> parallel settings present.
419		let s = compute(&windows_inputs(16, 8));
420		assert_eq!(get(&s, "max_worker_processes"), Some("4"));
421		assert_eq!(get(&s, "max_parallel_workers_per_gather"), Some("2"));
422		assert_eq!(get(&s, "max_parallel_workers"), Some("4"));
423		assert_eq!(get(&s, "max_parallel_maintenance_workers"), Some("2"));
424	}
425
426	#[test]
427	fn wal_compression_falls_back_without_lz4() {
428		let mut input = windows_inputs(16, 4);
429		input.pg_major = 16;
430		input.lz4_wal_supported = false;
431		assert_eq!(get(&compute(&input), "wal_compression"), Some("on"));
432		input.lz4_wal_supported = true;
433		assert_eq!(get(&compute(&input), "wal_compression"), Some("lz4"));
434		input.pg_major = 13;
435		input.lz4_wal_supported = true;
436		assert_eq!(get(&compute(&input), "wal_compression"), Some("on"));
437	}
438
439	#[test]
440	fn pre_pg10_windows_caps_shared_buffers() {
441		let mut input = windows_inputs(64, 8);
442		input.pg_major = 9;
443		// budget 4GiB -> /4 = 1GiB, capped to 512MiB pre-10.
444		assert_eq!(get(&compute(&input), "shared_buffers"), Some("512MB"));
445		// no jit / wal_compression pre-10/12
446		assert_eq!(get(&compute(&input), "jit"), None);
447		assert_eq!(get(&compute(&input), "wal_compression"), None);
448	}
449
450	#[test]
451	fn temp_file_limit_emitted_when_present() {
452		let mut input = windows_inputs(16, 4);
453		input.temp_file_limit_kib = Some(50 * GIB);
454		assert_eq!(get(&compute(&input), "temp_file_limit"), Some("50GB"));
455		input.temp_file_limit_kib = None;
456		assert_eq!(get(&compute(&input), "temp_file_limit"), None);
457	}
458
459	#[test]
460	fn linux_32gib_matches_ops_tuning() {
461		let input = TuneInputs {
462			platform: Platform::Linux,
463			resources: HostResources {
464				total_ram_kib: 32 * GIB,
465				cpus: 8,
466			},
467			pg_major: 16,
468			max_connections: 100,
469			lz4_wal_supported: true,
470			temp_file_limit_kib: None,
471		};
472		let s = compute(&input);
473		// budget = 28GiB
474		assert_eq!(get(&s, "shared_buffers"), Some("7GB"));
475		assert_eq!(get(&s, "effective_cache_size"), Some("21GB"));
476		assert_eq!(get(&s, "random_page_cost"), Some("1.1"));
477		assert_eq!(get(&s, "effective_io_concurrency"), Some("200"));
478		assert_eq!(get(&s, "max_wal_size"), Some("8GB"));
479		assert_eq!(get(&s, "wal_compression"), Some("lz4"));
480	}
481
482	#[test]
483	fn render_kib_uses_largest_exact_unit() {
484		assert_eq!(render_kib(GIB), "1GB");
485		assert_eq!(render_kib(512 * MIB), "512MB");
486		assert_eq!(render_kib(32), "32kB");
487		assert_eq!(render_kib(1536 * MIB), "1536MB");
488	}
489}