Skip to content

Commit fba0710

Browse files
authored
feat: Add SQLite performance tuning configuration options (#6)
* feat: Add SQLite performance tuning configuration options - Add `[sqlite]` configuration section with cache_size_mb, busy_timeout_ms, synchronous mode, and mmap_size options - Implement automatic per-shard calculation for cache and mmap settings with floor division - Update default synchronous mode to NORMAL for better safety in WAL mode - Add comprehensive documentation in config.example.toml with tuning recommendations - Print SQLite configuration on server startup for visibility
1 parent 5666ac8 commit fba0710

File tree

8 files changed

+422
-84
lines changed

8 files changed

+422
-84
lines changed

config.example.toml

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,33 @@ enabled = true
5151
algorithm = "zstd" # Options: gzip, zstd, lz4, brotli
5252
level = 3
5353

54+
# SQLite performance tuning (optional)
55+
# Based on: https://kerkour.com/sqlite-for-servers
56+
[sqlite]
57+
# Cache size across all shards in MB (default ≈ 100 MB per shard)
58+
# Tuning tip: cache_size_mb ≈ (Available RAM in GB * 0.7 * 1024)
59+
# Example: 16GB RAM dedicated at 70% → cache_size_mb ≈ 11468 (≈2867 MB per shard for 4 shards)
60+
cache_size_mb = 400
61+
62+
# Maximum SQLite connections per shard (default: 10)
63+
# Increase for higher concurrency; remember total cache/mmap budgets are divided across shards * connections
64+
# max_connections = 10
65+
66+
# Busy timeout in milliseconds (default: 5000 = 5 seconds)
67+
# Increase for high-contention workloads
68+
busy_timeout_ms = 5000
69+
70+
# Synchronous mode (default: NORMAL)
71+
# OFF: Fastest, but risk of corruption on power loss (not recommended)
72+
# NORMAL: Recommended - good performance with corruption safety in WAL mode
73+
# FULL: Safest but slower, use for critical data
74+
synchronous = "NORMAL"
75+
76+
# Total memory-mapped I/O size in MB (default: 0 = disabled)
77+
# Only useful for databases larger than cache_size
78+
# Example: 3000 (≈3 GB total budget)
79+
# mmap_size = 0
80+
5481
# Metrics configuration (optional)
5582
# Enable Prometheus-compatible metrics endpoint
5683
[metrics]

src/app_state.rs

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ use miette::Result;
33
use moka::future::Cache;
44
use mpchash::HashRing;
55
use sqlx::SqlitePool;
6-
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode};
6+
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions};
77
use std::fs;
88
use std::str::FromStr;
99
use tokio::sync::mpsc;
@@ -63,10 +63,17 @@ impl AppState {
6363
validate_shard_count(&cfg.data_dir, cfg.num_shards)?;
6464

6565
let mut db_pools_futures = vec![];
66+
let pool_max_connections = cfg.sqlite_pool_max_connections();
6667
for i in 0..cfg.num_shards {
6768
let data_dir = cfg.data_dir.clone();
6869
let db_path = format!("{}/shard_{}.db", data_dir, i);
6970

71+
// Get SQLite configuration with defaults
72+
let cache_size_mb = cfg.sqlite_cache_size_per_connection_mb();
73+
let busy_timeout_ms = cfg.sqlite_busy_timeout_ms();
74+
let synchronous = cfg.sqlite_synchronous();
75+
let mmap_size = cfg.sqlite_mmap_per_connection_bytes();
76+
7077
let mut connect_options =
7178
SqliteConnectOptions::from_str(&format!("sqlite:{}", db_path))
7279
.expect(&format!(
@@ -75,18 +82,25 @@ impl AppState {
7582
))
7683
.create_if_missing(true)
7784
.journal_mode(SqliteJournalMode::Wal)
78-
.busy_timeout(std::time::Duration::from_millis(5000));
85+
.busy_timeout(std::time::Duration::from_millis(busy_timeout_ms));
7986

80-
// These PRAGMAs are often set for performance with WAL mode.
81-
// `synchronous = OFF` is safe except for power loss.
82-
// `cache_size` is negative to indicate KiB, so -4000 is 4MB.
83-
// `temp_store = MEMORY` avoids disk I/O for temporary tables.
87+
// Configure SQLite PRAGMAs for optimal server performance
8488
connect_options = connect_options
85-
.pragma("synchronous", "OFF")
86-
.pragma("cache_size", "-100000") // 4MB cache per shard
87-
.pragma("temp_store", "MEMORY");
89+
.pragma("synchronous", synchronous.as_str())
90+
.pragma("cache_size", format!("-{}", cache_size_mb * 1024)) // Negative means KiB
91+
.pragma("temp_store", "MEMORY")
92+
.pragma("foreign_keys", "true");
93+
94+
// Enable memory-mapped I/O if configured
95+
if mmap_size > 0 {
96+
connect_options = connect_options.pragma("mmap_size", mmap_size.to_string());
97+
}
8898

89-
db_pools_futures.push(sqlx::SqlitePool::connect_with(connect_options))
99+
db_pools_futures.push(
100+
SqlitePoolOptions::new()
101+
.max_connections(pool_max_connections)
102+
.connect_with(connect_options),
103+
)
90104
}
91105

92106
let db_pool_results: Vec<Result<SqlitePool, sqlx::Error>> =

src/cluster.rs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -434,7 +434,10 @@ impl ClusterManager {
434434
let (local_ip, local_port) = if let Some(ref advertise_addr) = self.config.advertise_addr {
435435
// Parse advertise_addr which is in "ip:port" format
436436
if let Some((ip, port)) = advertise_addr.split_once(':') {
437-
(ip.to_string(), port.parse().unwrap_or(self.local_addr.port()))
437+
(
438+
ip.to_string(),
439+
port.parse().unwrap_or(self.local_addr.port()),
440+
)
438441
} else {
439442
// If no port in advertise_addr, use the IP with local port
440443
(advertise_addr.clone(), self.local_addr.port())
@@ -444,13 +447,10 @@ impl ClusterManager {
444447
} else {
445448
(self.local_addr.ip().to_string(), self.local_addr.port())
446449
};
447-
450+
448451
let local_info = format!(
449452
"{} {}:{} myself,master - 0 0 0 connected {}",
450-
self.node_id,
451-
local_ip,
452-
local_port,
453-
local_slots_ranges
453+
self.node_id, local_ip, local_port, local_slots_ranges
454454
);
455455
result.push(local_info);
456456

src/config.rs

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,38 @@ pub struct Cfg {
1414
pub addr: Option<String>,
1515
pub cluster: Option<ClusterConfig>,
1616
pub metrics: Option<MetricsConfig>,
17+
pub sqlite: Option<SqliteConfig>,
18+
}
19+
20+
#[derive(Debug, Clone, serde::Deserialize)]
21+
pub struct SqliteConfig {
22+
/// SQLite cache size in MB for the whole process
23+
/// Default: 100 MB per shard (derived from shard count)
24+
/// Higher values improve read performance but consume more RAM
25+
pub cache_size_mb: Option<i32>,
26+
27+
/// SQLite busy timeout in milliseconds
28+
/// Default: 5000 ms (5 seconds)
29+
/// Increase for high-contention workloads
30+
pub busy_timeout_ms: Option<u64>,
31+
32+
/// SQLite synchronous mode: OFF, NORMAL, FULL
33+
/// Default: NORMAL (recommended for WAL mode)
34+
/// OFF = fastest but risk of corruption on power loss
35+
/// NORMAL = good performance with corruption safety in WAL mode
36+
/// FULL = safest but slower
37+
pub synchronous: Option<SqliteSynchronous>,
38+
39+
/// Memory-mapped I/O size in MB for the whole process
40+
/// Default: 0 (disabled)
41+
/// Only useful for large databases that don't fit in cache
42+
/// Example: 3000 (≈3 GB total)
43+
pub mmap_size: Option<u64>,
44+
45+
/// Maximum number of SQLite connections per shard (pool size)
46+
/// Default: 10 (sqlx default)
47+
/// Increase for higher concurrency at the cost of RAM
48+
pub max_connections: Option<u32>,
1749
}
1850

1951
#[derive(Debug, Clone, serde::Deserialize)]
@@ -46,6 +78,24 @@ pub enum CompressionType {
4678
Brotli,
4779
}
4880

81+
#[derive(Debug, Clone, Copy, serde::Deserialize, PartialEq)]
82+
#[serde(rename_all = "UPPERCASE")]
83+
pub enum SqliteSynchronous {
84+
OFF,
85+
NORMAL,
86+
FULL,
87+
}
88+
89+
impl SqliteSynchronous {
90+
pub fn as_str(&self) -> &'static str {
91+
match self {
92+
SqliteSynchronous::OFF => "OFF",
93+
SqliteSynchronous::NORMAL => "NORMAL",
94+
SqliteSynchronous::FULL => "FULL",
95+
}
96+
}
97+
}
98+
4999
#[derive(Debug, Clone, serde::Deserialize)]
50100
pub struct CompressionConfig {
51101
pub enabled: bool,
@@ -110,6 +160,34 @@ impl Cfg {
110160
}
111161
}
112162

163+
// Print SQLite configuration
164+
let cache_total_mb = cfg.sqlite_cache_total_mb();
165+
let cache_per_shard_mb = cfg.sqlite_cache_size_per_shard_mb();
166+
let cache_per_connection_mb = cfg.sqlite_cache_size_per_connection_mb();
167+
let busy_timeout = cfg.sqlite_busy_timeout_ms();
168+
let synchronous = cfg.sqlite_synchronous();
169+
let mmap_total_mb = cfg.sqlite_mmap_total_mb();
170+
let mmap_per_shard_mb = cfg.sqlite_mmap_per_shard_mb();
171+
let mmap_per_connection_mb = cfg.sqlite_mmap_per_connection_mb();
172+
let pool_max_connections = cfg.sqlite_pool_max_connections();
173+
174+
println!("SQLite configuration:");
175+
println!(
176+
" cache_size: {} MB total ({} MB per shard, {} MB per connection; floor)",
177+
cache_total_mb, cache_per_shard_mb, cache_per_connection_mb
178+
);
179+
println!(" busy_timeout: {} ms", busy_timeout);
180+
println!(" synchronous: {}", synchronous.as_str());
181+
if mmap_total_mb > 0 {
182+
println!(
183+
" mmap_size: {} MB total ({} MB per shard, {} MB per connection; floor)",
184+
mmap_total_mb, mmap_per_shard_mb, mmap_per_connection_mb
185+
);
186+
} else {
187+
println!(" mmap_size: disabled");
188+
}
189+
println!(" pool_max_connections: {}", pool_max_connections);
190+
113191
Ok(cfg)
114192
}
115193

@@ -119,4 +197,123 @@ impl Cfg {
119197
_ => false,
120198
}
121199
}
200+
201+
/// Get configured SQLite cache size in MB for the whole process.
202+
/// Default scales with shard count to preserve the 100 MB per-shard baseline.
203+
pub fn sqlite_cache_total_mb(&self) -> i32 {
204+
let default_per_shard = 100usize;
205+
let default_total = self
206+
.num_shards
207+
.saturating_mul(default_per_shard)
208+
.min(i32::MAX as usize) as i32;
209+
210+
self.sqlite
211+
.as_ref()
212+
.and_then(|s| s.cache_size_mb)
213+
.unwrap_or(default_total)
214+
}
215+
216+
/// Derived SQLite cache size in MB per shard (floor division).
217+
pub fn sqlite_cache_size_per_shard_mb(&self) -> i32 {
218+
let total = self.sqlite_cache_total_mb();
219+
if total <= 0 {
220+
return 0;
221+
}
222+
223+
let shards = self.num_shards.max(1) as i64;
224+
let per_shard = (total as i64) / shards;
225+
per_shard.clamp(i32::MIN as i64, i32::MAX as i64).max(0) as i32
226+
}
227+
228+
/// Derived SQLite cache size in MB per connection (floor division).
229+
pub fn sqlite_cache_size_per_connection_mb(&self) -> i32 {
230+
let total = self.sqlite_cache_total_mb();
231+
if total <= 0 {
232+
return 0;
233+
}
234+
235+
let shards = self.num_shards.max(1) as i64;
236+
let connections = self.sqlite_pool_max_connections().max(1) as i64;
237+
let divisor = shards.saturating_mul(connections);
238+
239+
let per_connection = (total as i64) / divisor;
240+
per_connection
241+
.clamp(i32::MIN as i64, i32::MAX as i64)
242+
.max(0) as i32
243+
}
244+
245+
/// Get SQLite busy timeout in milliseconds (default: 5000 ms = 5 seconds)
246+
pub fn sqlite_busy_timeout_ms(&self) -> u64 {
247+
self.sqlite
248+
.as_ref()
249+
.and_then(|s| s.busy_timeout_ms)
250+
.unwrap_or(5000)
251+
}
252+
253+
/// Get SQLite synchronous mode (default: NORMAL)
254+
pub fn sqlite_synchronous(&self) -> SqliteSynchronous {
255+
self.sqlite
256+
.as_ref()
257+
.and_then(|s| s.synchronous)
258+
.unwrap_or(SqliteSynchronous::NORMAL)
259+
}
260+
261+
/// Get SQLite mmap size in MB (default: 0 = disabled)
262+
pub fn sqlite_mmap_total_mb(&self) -> u64 {
263+
self.sqlite.as_ref().and_then(|s| s.mmap_size).unwrap_or(0)
264+
}
265+
266+
/// Derived SQLite mmap size in MB per shard (floor division).
267+
pub fn sqlite_mmap_per_shard_mb(&self) -> u64 {
268+
let total = self.sqlite_mmap_total_mb();
269+
if total == 0 {
270+
return 0;
271+
}
272+
273+
let shards = self.num_shards.max(1) as u64;
274+
total / shards
275+
}
276+
277+
/// Derived SQLite mmap size in MB per connection (floor division).
278+
pub fn sqlite_mmap_per_connection_mb(&self) -> u64 {
279+
let total = self.sqlite_mmap_total_mb();
280+
if total == 0 {
281+
return 0;
282+
}
283+
284+
let shards = self.num_shards.max(1) as u64;
285+
let connections = self.sqlite_pool_max_connections().max(1) as u64;
286+
let divisor = shards.saturating_mul(connections);
287+
288+
if divisor == 0 {
289+
return 0;
290+
}
291+
292+
total / divisor
293+
}
294+
295+
/// Derived SQLite mmap size in bytes per connection (floor division).
296+
pub fn sqlite_mmap_per_connection_bytes(&self) -> u64 {
297+
let per_connection_mb = self.sqlite_mmap_per_connection_mb();
298+
if per_connection_mb == 0 {
299+
return 0;
300+
}
301+
302+
per_connection_mb.saturating_mul(1024).saturating_mul(1024)
303+
}
304+
305+
/// Maximum number of SQLite connections per shard (pool size).
306+
pub fn sqlite_pool_max_connections(&self) -> u32 {
307+
let default = 10;
308+
let configured = self
309+
.sqlite
310+
.as_ref()
311+
.and_then(|s| s.max_connections)
312+
.unwrap_or(default);
313+
314+
match configured {
315+
0 => 1,
316+
value => value,
317+
}
318+
}
122319
}

src/migration.rs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,14 +55,17 @@ impl MigrationManager {
5555
async fn create_connection_pool(&self, shard_id: usize) -> Result<SqlitePool> {
5656
let db_path = format!("{}/shard_{}.db", self.data_dir, shard_id);
5757

58+
// Use recommended SQLite settings for migration operations
59+
// See: https://kerkour.com/sqlite-for-servers
5860
let connect_options = SqliteConnectOptions::from_str(&format!("sqlite:{}", db_path))
5961
.map_err(|e| sqlx_to_miette(e, "Failed to parse connection string"))?
6062
.create_if_missing(true)
6163
.journal_mode(SqliteJournalMode::Wal)
6264
.busy_timeout(std::time::Duration::from_millis(5000))
63-
.pragma("synchronous", "OFF")
64-
.pragma("cache_size", "-100000")
65-
.pragma("temp_store", "MEMORY");
65+
.pragma("synchronous", "NORMAL") // NORMAL is safer than OFF
66+
.pragma("cache_size", "-1024000") // 1GB cache for better performance during migration
67+
.pragma("temp_store", "MEMORY")
68+
.pragma("foreign_keys", "true");
6669

6770
SqlitePool::connect_with(connect_options)
6871
.await

0 commit comments

Comments
 (0)