Skip to content

Commit 1540034

Browse files
feat(reload): add support for hot reloading of config
1 parent c7a941d commit 1540034

File tree

7 files changed

+782
-0
lines changed

7 files changed

+782
-0
lines changed

Cargo.lock

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

confik/CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,13 @@
22

33
## Unreleased
44

5+
- Add hot-reloadable configuration support with `ReloadableConfig` trait and `ReloadingConfig` wrapper. Requires the `reloading` feature (depends on `arc-swap`).
6+
- `ReloadableConfig` trait defines how to build a configuration instance
7+
- `ReloadingConfig<T, F>` provides lock-free atomic configuration swapping
8+
- Optional callbacks via `with_on_update()` for reload notifications
9+
- Optional SIGHUP signal handler via `set_signal_handler()` (requires `signal` feature, depends on `signal-hook`)
10+
- Optional tracing support for logging reload errors (requires `tracing` feature)
11+
512
## 0.15.1
613

714
- Add `confik(crate = ...)` option to `Configuration` macro.

confik/Cargo.toml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,11 @@ env = ["dep:envious"]
2828
json = ["dep:serde_json"]
2929
toml = ["dep:toml"]
3030

31+
# Hot reloading
32+
reloading = ["dep:arc-swap"]
33+
signal = ["dep:signal-hook"]
34+
tracing = ["dep:tracing"]
35+
3136
# Destination types
3237
ahash = ["dep:ahash"]
3338
bigdecimal = ["dep:bigdecimal"]
@@ -45,14 +50,22 @@ uuid = ["dep:uuid"]
4550
[dependencies]
4651
confik-macros = "=0.15.1"
4752

53+
# Always required
4854
cfg-if = "1"
4955
serde = { version = "1", default-features = false, features = ["std", "derive"] }
5056
thiserror = "2"
5157

58+
# Source types
5259
envious = { version = "0.2", optional = true }
5360
serde_json = { version = "1", optional = true }
5461
toml = { version = "0.9", optional = true, default-features = false, features = ["parse", "serde"] }
5562

63+
# Hot reloading
64+
arc-swap = { version = "1", optional = true }
65+
signal-hook = { version = "0.3", optional = true }
66+
tracing = { version = "0.1", optional = true }
67+
68+
# Destination types
5669
ahash = { version = "0.8", optional = true, features = ["serde"] }
5770
bigdecimal = { version = "0.4", optional = true, features = ["serde"] }
5871
bytesize = { version = "2", optional = true, features = ["serde"] }
@@ -66,6 +79,7 @@ url = { version = "2", optional = true, features = ["serde"] }
6679
uuid = { version = "1", optional = true, features = ["serde"] }
6780

6881
[dev-dependencies]
82+
anyhow = "1"
6983
assert_matches = "1.5"
7084
humantime-serde = "1"
7185
indoc = "2"
@@ -92,3 +106,7 @@ required-features = ["toml", "bigdecimal"]
92106
[[example]]
93107
name = "ahash"
94108
required-features = ["toml", "ahash"]
109+
110+
[[example]]
111+
name = "reloading"
112+
required-features = ["toml", "reloading"]

confik/examples/reloading.rs

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
use confik::{Configuration, FileSource, ReloadableConfig};
2+
use std::{fs, path::PathBuf, sync::LazyLock};
3+
use tempfile::NamedTempFile;
4+
5+
#[derive(Configuration, Debug)]
6+
struct ServerConfig {
7+
host: String,
8+
port: u16,
9+
max_connections: usize,
10+
}
11+
12+
// Global path to the config file (in a real app, this would be a known location)
13+
// We need to keep the NamedTempFile alive, so we store it in a LazyLock
14+
static CONFIG_FILE: LazyLock<NamedTempFile> = LazyLock::new(|| {
15+
tempfile::Builder::new()
16+
.suffix(".toml")
17+
.tempfile()
18+
.expect("Failed to create temp file")
19+
});
20+
21+
static CONFIG_PATH: LazyLock<PathBuf> = LazyLock::new(|| CONFIG_FILE.path().to_path_buf());
22+
23+
impl ReloadableConfig for ServerConfig {
24+
type Error = anyhow::Error;
25+
26+
fn build() -> Result<Self, Self::Error> {
27+
let config = Self::builder()
28+
.override_with(FileSource::new(CONFIG_PATH.as_path()))
29+
.try_build()?;
30+
31+
if config.max_connections > 1_000 {
32+
anyhow::bail!("max_connections must be <= 1000");
33+
}
34+
35+
Ok(config)
36+
}
37+
}
38+
39+
fn main() {
40+
println!("=== Hot-Reloadable Configuration Example ===\n");
41+
42+
// Initialize with the first configuration
43+
fs::write(
44+
&*CONFIG_PATH,
45+
r#"
46+
host = "localhost"
47+
port = 8080
48+
max_connections = 100
49+
"#,
50+
)
51+
.expect("Failed to write initial config");
52+
println!("Created config file at: {:?}\n", &*CONFIG_PATH);
53+
54+
// Create a reloading config using the convenient method
55+
let config = ServerConfig::reloading().expect("Failed to load initial config");
56+
57+
// Load and use the current configuration
58+
let current = config.load();
59+
println!("Initial configuration:");
60+
println!(" Host: {}", current.host);
61+
println!(" Port: {}", current.port);
62+
println!(" Max Connections: {}", current.max_connections);
63+
64+
// Add a callback to be notified when config reloads
65+
let config_with_callback = config.with_on_update(|| {
66+
println!("✓ Configuration reloaded successfully!");
67+
});
68+
69+
// Modify the config file
70+
println!("\n--- Modifying the config file ---");
71+
fs::write(
72+
&*CONFIG_PATH,
73+
r#"
74+
host = "0.0.0.0"
75+
port = 80
76+
max_connections = 10
77+
"#,
78+
)
79+
.expect("Failed to write updated config");
80+
println!("Updated config file with new values");
81+
82+
println!("\n--- Reloading configuration ---");
83+
config_with_callback
84+
.reload()
85+
.expect("Failed to reload config");
86+
87+
// The configuration is atomically updated with new values!
88+
let updated = config_with_callback.load();
89+
println!("After reload:");
90+
println!(" Host: {}", updated.host);
91+
println!(" Port: {}", updated.port);
92+
println!(" Max Connections: {}", updated.max_connections);
93+
94+
// Verify the values actually changed
95+
assert_eq!(updated.host, "0.0.0.0");
96+
assert_eq!(updated.port, 80);
97+
assert_eq!(updated.max_connections, 10);
98+
println!("\n✓ Configuration values changed successfully!");
99+
100+
// Write invalid config
101+
fs::write(
102+
&*CONFIG_PATH,
103+
r#"
104+
host = "localhost"
105+
port = 8080
106+
max_connections = 99999999
107+
"#,
108+
)
109+
.expect("Failed to write updated config");
110+
println!("Updated config file with new values");
111+
112+
println!("\n--- Reloading configuration ---");
113+
config_with_callback
114+
.reload()
115+
.expect_err("Invalid config rejected");
116+
117+
// Verify the values actually changed
118+
assert_eq!(updated.host, "0.0.0.0");
119+
assert_eq!(updated.port, 80);
120+
assert_eq!(updated.max_connections, 10);
121+
122+
println!("\n=== Example complete ===");
123+
}

confik/src/lib.md

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,74 @@ assert_eq!(
5050
# }
5151
```
5252

53+
## Hot Reloading
54+
55+
Configuration can be made hot-reloadable using the [`ReloadingConfig`] wrapper. This allows you to atomically swap configuration at runtime without restarting your application. Requires the `reloading` feature.
56+
57+
```no_run
58+
# #[cfg(all(feature = "toml", feature = "reloading"))]
59+
# {
60+
use confik::{Configuration, ReloadableConfig, FileSource};
61+
62+
#[derive(Debug, Configuration)]
63+
struct AppConfig {
64+
host: String,
65+
port: u16,
66+
}
67+
68+
impl ReloadableConfig for AppConfig {
69+
type Error = confik::Error;
70+
71+
fn build() -> Result<Self, Self::Error> {
72+
Self::builder()
73+
.override_with(FileSource::new("config.toml"))
74+
.try_build()
75+
}
76+
}
77+
78+
// Create a reloading config
79+
let config = AppConfig::reloading().unwrap();
80+
81+
// Access the current config (cheap, non-blocking)
82+
let current = config.load();
83+
println!("Host: {}", current.host);
84+
85+
// Reload from sources
86+
config.reload().unwrap();
87+
88+
// Add a callback for reload notifications
89+
let config = config.with_on_update(|| {
90+
println!("Config reloaded!");
91+
});
92+
# }
93+
```
94+
95+
### Signal Handling
96+
97+
When the `signal` feature is enabled (requires `reloading`), you can also set up automatic reloading on SIGHUP:
98+
99+
```no_run
100+
# #[cfg(all(feature = "signal", feature = "toml"))]
101+
# {
102+
# use confik::{Configuration, ReloadableConfig, FileSource};
103+
# #[derive(Debug, Configuration)]
104+
# struct AppConfig { host: String, port: u16 }
105+
# impl ReloadableConfig for AppConfig {
106+
# type Error = confik::Error;
107+
# fn build() -> Result<Self, Self::Error> {
108+
# Self::builder().override_with(FileSource::new("config.toml")).try_build()
109+
# }
110+
# }
111+
let config = AppConfig::reloading().unwrap();
112+
let handle = config.set_signal_handler().unwrap();
113+
114+
// Config will now reload when receiving SIGHUP
115+
// Send SIGHUP: kill -HUP <pid>
116+
# }
117+
```
118+
119+
When the `tracing` feature is enabled, reload errors in the signal handler will be automatically logged with `tracing::error!`.
120+
53121
## Sources
54122

55123
A [`Source`] is any type that can create [`ConfigurationBuilder`]s. This crate implements the following sources:

0 commit comments

Comments
 (0)