Skip to content

Commit 74884f1

Browse files
rklaehndignifiedquireArqu
authored
feat(iroh-dns-server)!: eviction of stale zonestore entries (#2997)
## Description Configurable eviction of stale zonestore entries. This works by taking a snapshot of the database in regular intervals and checking if a record is possibly expired. For all possibly expired records a fire and forget message will be sent to the io write actor to check again. The actor will check the expiry again (entry could have been renewed since the last snapshot) and then deletes it if the final check confirms expiry. Between expiry checks there is a configurable delay, so the thread is not constantly spinning checking for expiry. We use a second thread since we don't want to block writing new entries by sifting through old entries. ## Breaking Changes - struct config::Config has a new field zone_store - struct metrics::Metrics has a new field store_packets_expired ## Notes & open questions Note: there are two ways to do eviction. One is to carefully keep track of the time for each entry by having a second table that has (timestamp, key) as the key and () as the value. Then you could just evict without doing a full scan by sorting by time ascending. The downside of course is that you need an entire new table, and you need to update this table every time you update an entry (delete (old time, id), insert (new time, id). ~~So I decided to just do a full scan instead for simplicity. We can change it if it becomes a problem.~~ ~~Hm, maybe we should avoid the full scan after all. I can imagine the thing being a bit less responsive than usual while the scan is ongoing. Another idea would be to have an "event log" where you just store (time, id) -> () and then use that to look for eviction *candidates*. Don't bother cleaning up this event log on every update.~~ I have now implemented a second table. It is a multimap table from timestamp to id. This gets updated on every write (slight perf downside here), and can be used to scan for evictions without having to do a full scan. There is quite a lot of code just to expose these config options. We could also omit this and just use reasonable defaults. ## Change checklist - [ ] Self-review. - [ ] Documentation updates following the [style guide](https://rust-lang.github.io/rfcs/1574-more-api-documentation-conventions.html#appendix-a-full-conventions-text), if relevant. - [ ] Tests if relevant. - [ ] All breaking changes documented. --------- Co-authored-by: dignifiedquire <[email protected]> Co-authored-by: Asmir Avdicevic <[email protected]>
1 parent 321d8ff commit 74884f1

File tree

10 files changed

+419
-54
lines changed

10 files changed

+419
-54
lines changed

Cargo.lock

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

iroh-dns-server/Cargo.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@ governor = "0.6.3" #needs new release of tower_governor for 0.7.0
2929
hickory-proto = "=0.25.0-alpha.2"
3030
hickory-server = { version = "=0.25.0-alpha.2", features = ["dns-over-rustls"] }
3131
http = "1.0.0"
32-
iroh-metrics = "0.29"
32+
humantime-serde = "1.1.1"
33+
iroh-metrics = { version = "0.29.0" }
3334
lru = "0.12.3"
3435
parking_lot = "0.12.1"
3536
pkarr = { version = "2.2.0", features = [ "async", "relay", "dht"], default-features = false }
@@ -64,6 +65,7 @@ hickory-resolver = "=0.25.0-alpha.2"
6465
iroh = { version = "0.29.0", path = "../iroh" }
6566
iroh-test = { version = "0.29.0", path = "../iroh-test" }
6667
pkarr = { version = "2.2.0", features = ["rand"] }
68+
testresult = "0.4.1"
6769

6870
[[bench]]
6971
name = "write"

iroh-dns-server/benches/write.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ use tokio::runtime::Runtime;
77
const LOCALHOST_PKARR: &str = "http://localhost:8080/pkarr";
88

99
async fn start_dns_server(config: Config) -> Result<Server> {
10-
let store = ZoneStore::persistent(Config::signed_packet_store_path()?)?;
10+
let store = ZoneStore::persistent(Config::signed_packet_store_path()?, Default::default())?;
1111
Server::spawn(config, store).await
1212
}
1313

iroh-dns-server/src/config.rs

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ use std::{
44
env,
55
net::{IpAddr, Ipv4Addr, SocketAddr},
66
path::{Path, PathBuf},
7+
time::Duration,
78
};
89

910
use anyhow::{anyhow, Context, Result};
@@ -13,6 +14,7 @@ use tracing::info;
1314
use crate::{
1415
dns::DnsConfig,
1516
http::{CertMode, HttpConfig, HttpsConfig, RateLimitConfig},
17+
store::ZoneStoreOptions,
1618
};
1719

1820
const DEFAULT_METRICS_ADDR: SocketAddr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 9117);
@@ -44,11 +46,61 @@ pub struct Config {
4446
/// Config for the mainline lookup.
4547
pub mainline: Option<MainlineConfig>,
4648

49+
/// Config for the zone store.
50+
pub zone_store: Option<StoreConfig>,
51+
4752
/// Config for pkarr rate limit
4853
#[serde(default)]
4954
pub pkarr_put_rate_limit: RateLimitConfig,
5055
}
5156

57+
/// The config for the store.
58+
#[derive(Debug, Serialize, Deserialize, Clone)]
59+
pub struct StoreConfig {
60+
/// Maximum number of packets to process in a single write transaction.
61+
max_batch_size: usize,
62+
63+
/// Maximum time to keep a write transaction open.
64+
#[serde(with = "humantime_serde")]
65+
max_batch_time: Duration,
66+
67+
/// Time to keep packets in the store before eviction.
68+
#[serde(with = "humantime_serde")]
69+
eviction: Duration,
70+
71+
/// Pause between eviction checks.
72+
#[serde(with = "humantime_serde")]
73+
eviction_interval: Duration,
74+
}
75+
76+
impl Default for StoreConfig {
77+
fn default() -> Self {
78+
ZoneStoreOptions::default().into()
79+
}
80+
}
81+
82+
impl From<ZoneStoreOptions> for StoreConfig {
83+
fn from(value: ZoneStoreOptions) -> Self {
84+
Self {
85+
max_batch_size: value.max_batch_size,
86+
max_batch_time: value.max_batch_time,
87+
eviction: value.eviction,
88+
eviction_interval: value.eviction_interval,
89+
}
90+
}
91+
}
92+
93+
impl From<StoreConfig> for ZoneStoreOptions {
94+
fn from(value: StoreConfig) -> Self {
95+
Self {
96+
max_batch_size: value.max_batch_size,
97+
max_batch_time: value.max_batch_time,
98+
eviction: value.eviction,
99+
eviction_interval: value.eviction_interval,
100+
}
101+
}
102+
}
103+
52104
/// The config for the metrics server.
53105
#[derive(Debug, Serialize, Deserialize)]
54106
pub struct MetricsConfig {
@@ -187,6 +239,7 @@ impl Default for Config {
187239
rr_aaaa: None,
188240
rr_ns: Some("ns1.irohdns.example.".to_string()),
189241
},
242+
zone_store: None,
190243
metrics: None,
191244
mainline: None,
192245
pkarr_put_rate_limit: RateLimitConfig::default(),

iroh-dns-server/src/lib.rs

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,10 @@ pub use store::ZoneStore;
1616

1717
#[cfg(test)]
1818
mod tests {
19-
use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr};
19+
use std::{
20+
net::{Ipv4Addr, Ipv6Addr, SocketAddr},
21+
time::Duration,
22+
};
2023

2124
use anyhow::Result;
2225
use hickory_resolver::{
@@ -29,9 +32,16 @@ mod tests {
2932
key::SecretKey,
3033
};
3134
use pkarr::{PkarrClient, SignedPacket};
35+
use testresult::TestResult;
3236
use url::Url;
3337

34-
use crate::{config::BootstrapOption, server::Server};
38+
use crate::{
39+
config::BootstrapOption,
40+
server::Server,
41+
store::{PacketSource, ZoneStoreOptions},
42+
util::PublicKeyBytes,
43+
ZoneStore,
44+
};
3545

3646
#[tokio::test]
3747
async fn pkarr_publish_dns_resolve() -> Result<()> {
@@ -178,6 +188,36 @@ mod tests {
178188
Ok(())
179189
}
180190

191+
#[tokio::test]
192+
async fn store_eviction() -> TestResult<()> {
193+
iroh_test::logging::setup_multithreaded();
194+
let options = ZoneStoreOptions {
195+
eviction: Duration::from_millis(100),
196+
eviction_interval: Duration::from_millis(100),
197+
max_batch_time: Duration::from_millis(100),
198+
..Default::default()
199+
};
200+
let store = ZoneStore::in_memory(options)?;
201+
202+
// create a signed packet
203+
let signed_packet = random_signed_packet()?;
204+
let key = PublicKeyBytes::from_signed_packet(&signed_packet);
205+
206+
store
207+
.insert(signed_packet, PacketSource::PkarrPublish)
208+
.await?;
209+
210+
tokio::time::sleep(Duration::from_secs(1)).await;
211+
for _ in 0..10 {
212+
let entry = store.get_signed_packet(&key).await?;
213+
if entry.is_none() {
214+
return Ok(());
215+
}
216+
tokio::time::sleep(Duration::from_secs(1)).await;
217+
}
218+
panic!("store did not evict packet");
219+
}
220+
181221
#[tokio::test]
182222
async fn integration_mainline() -> Result<()> {
183223
iroh_test::logging::setup_multithreaded();
@@ -188,7 +228,8 @@ mod tests {
188228

189229
// spawn our server with mainline support
190230
let (server, nameserver, _http_url) =
191-
Server::spawn_for_tests_with_mainline(Some(BootstrapOption::Custom(bootstrap))).await?;
231+
Server::spawn_for_tests_with_options(Some(BootstrapOption::Custom(bootstrap)), None)
232+
.await?;
192233

193234
let origin = "irohdns.example.";
194235

@@ -228,4 +269,12 @@ mod tests {
228269
config.add_name_server(nameserver_config);
229270
AsyncResolver::tokio(config, Default::default())
230271
}
272+
273+
fn random_signed_packet() -> Result<SignedPacket> {
274+
let secret_key = SecretKey::generate();
275+
let node_id = secret_key.public();
276+
let relay_url: Url = "https://relay.example.".parse()?;
277+
let node_info = NodeInfo::new(node_id, Some(relay_url.clone()), Default::default());
278+
node_info.to_pkarr_signed_packet(&secret_key, 30)
279+
}
231280
}

iroh-dns-server/src/metrics.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ pub struct Metrics {
2222
pub store_packets_inserted: Counter,
2323
pub store_packets_removed: Counter,
2424
pub store_packets_updated: Counter,
25+
pub store_packets_expired: Counter,
2526
}
2627

2728
impl Default for Metrics {
@@ -44,6 +45,7 @@ impl Default for Metrics {
4445
store_packets_inserted: Counter::new("Signed packets inserted into the store"),
4546
store_packets_removed: Counter::new("Signed packets removed from the store"),
4647
store_packets_updated: Counter::new("Number of updates to existing packets"),
48+
store_packets_expired: Counter::new("Number of expired packets"),
4749
}
4850
}
4951
}

iroh-dns-server/src/server.rs

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,11 @@ use crate::{
1414

1515
/// Spawn the server and run until the `Ctrl-C` signal is received, then shutdown.
1616
pub async fn run_with_config_until_ctrl_c(config: Config) -> Result<()> {
17-
let mut store = ZoneStore::persistent(Config::signed_packet_store_path()?)?;
17+
let zone_store_options = config.zone_store.clone().unwrap_or_default();
18+
let mut store = ZoneStore::persistent(
19+
Config::signed_packet_store_path()?,
20+
zone_store_options.into(),
21+
)?;
1822
if let Some(bootstrap) = config.mainline_enabled() {
1923
info!("mainline fallback enabled");
2024
store = store.with_mainline_fallback(bootstrap);
@@ -96,14 +100,15 @@ impl Server {
96100
/// HTTP server.
97101
#[cfg(test)]
98102
pub async fn spawn_for_tests() -> Result<(Self, std::net::SocketAddr, url::Url)> {
99-
Self::spawn_for_tests_with_mainline(None).await
103+
Self::spawn_for_tests_with_options(None, None).await
100104
}
101105

102106
/// Spawn a server suitable for testing, while optionally enabling mainline with custom
103107
/// bootstrap addresses.
104108
#[cfg(test)]
105-
pub async fn spawn_for_tests_with_mainline(
109+
pub async fn spawn_for_tests_with_options(
106110
mainline: Option<crate::config::BootstrapOption>,
111+
options: Option<crate::store::ZoneStoreOptions>,
107112
) -> Result<(Self, std::net::SocketAddr, url::Url)> {
108113
use std::net::{IpAddr, Ipv4Addr};
109114

@@ -117,7 +122,7 @@ impl Server {
117122
config.https = None;
118123
config.metrics = Some(MetricsConfig::disabled());
119124

120-
let mut store = ZoneStore::in_memory()?;
125+
let mut store = ZoneStore::in_memory(options.unwrap_or_default())?;
121126
if let Some(bootstrap) = mainline {
122127
info!("mainline fallback enabled");
123128
store = store.with_mainline_fallback(bootstrap);

iroh-dns-server/src/store.rs

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ use crate::{
1919
};
2020

2121
mod signed_packets;
22+
pub use signed_packets::Options as ZoneStoreOptions;
2223

2324
/// Cache up to 1 million pkarr zones by default
2425
pub const DEFAULT_CACHE_CAPACITY: usize = 1024 * 1024;
@@ -44,14 +45,14 @@ pub struct ZoneStore {
4445

4546
impl ZoneStore {
4647
/// Create a persistent store
47-
pub fn persistent(path: impl AsRef<Path>) -> Result<Self> {
48-
let packet_store = SignedPacketStore::persistent(path)?;
48+
pub fn persistent(path: impl AsRef<Path>, options: ZoneStoreOptions) -> Result<Self> {
49+
let packet_store = SignedPacketStore::persistent(path, options)?;
4950
Ok(Self::new(packet_store))
5051
}
5152

5253
/// Create an in-memory store.
53-
pub fn in_memory() -> Result<Self> {
54-
let packet_store = SignedPacketStore::in_memory()?;
54+
pub fn in_memory(options: ZoneStoreOptions) -> Result<Self> {
55+
let packet_store = SignedPacketStore::in_memory(options)?;
5556
Ok(Self::new(packet_store))
5657
}
5758

0 commit comments

Comments
 (0)