use std::sync::Arc;
use super::enricher::{
Backing, GeoIp, GeoIpLookupError, Readers, Source, file_mtime, open_backing,
};
impl GeoIp {
pub fn refresh_if_changed(&self) -> Result<bool, GeoIpLookupError> {
let mut sources = match self.inner.sources.lock() {
Ok(sources) => sources,
Err(poisoned) => poisoned.into_inner(),
};
let current = self.inner.readers.load_full();
let mut next = Readers {
city: current.city.clone(),
asn: current.asn.clone(),
generation: current.generation,
};
let mut changed = false;
let mut failure = None;
let resident_max_bytes = self.inner.resident_max_bytes;
let reopen = |source: &mut Source| reopen(source, resident_max_bytes);
match sources.city.as_mut().map(reopen) {
Some(Ok(Some(reader))) => {
next.city = Some(reader);
changed = true;
}
Some(Err(e)) => failure = Some(e),
Some(Ok(None)) | None => {}
}
match sources.asn.as_mut().map(reopen) {
Some(Ok(Some(reader))) => {
next.asn = Some(reader);
changed = true;
}
Some(Err(e)) => failure = failure.or(Some(e)),
Some(Ok(None)) | None => {}
}
if changed {
self.inner.swap_readers(next);
}
failure.map_or(Ok(changed), Err)
}
}
fn reopen(
source: &mut Source,
resident_max_bytes: u64,
) -> Result<Option<Arc<Backing>>, GeoIpLookupError> {
let mtime = file_mtime(&source.path);
if mtime.is_none() || mtime == source.mtime {
return Ok(None);
}
let backing = open_backing(&source.path, resident_max_bytes)?;
source.mtime = mtime;
Ok(Some(Arc::new(backing)))
}
#[cfg(test)]
mod tests {
use std::fs::{self, File};
use std::net::{IpAddr, Ipv4Addr};
use std::path::Path;
use std::sync::atomic::{AtomicBool, Ordering};
use std::thread;
use std::time::{Duration, SystemTime};
use super::*;
use crate::geoip::enricher::{CacheConfig, DatabaseBacking, DatabasePaths};
const CITY_DB: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/data/city-test.mmdb");
const ASN_DB: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/data/asn-test.mmdb");
const BOXFORD: &str = "2.125.160.216";
const LINKOPING: &str = "89.160.20.112";
const BASE: Duration = Duration::from_secs(1_000_000);
fn ip(literal: &str) -> IpAddr {
literal.parse().unwrap()
}
fn at(offset: u64) -> SystemTime {
SystemTime::UNIX_EPOCH + BASE + Duration::from_secs(offset)
}
fn replace(path: &Path, contents: &str, mtime: SystemTime) {
let staged = path.with_extension("staged");
fs::copy(contents, &staged).unwrap();
File::options()
.write(true)
.open(&staged)
.unwrap()
.set_modified(mtime)
.unwrap();
fs::rename(&staged, path).unwrap();
}
fn corrupt(path: &Path, mtime: SystemTime) {
let staged = path.with_extension("staged");
fs::write(&staged, b"not a MaxMind DB").unwrap();
File::options()
.write(true)
.open(&staged)
.unwrap()
.set_modified(mtime)
.unwrap();
fs::rename(&staged, path).unwrap();
}
fn city_of(geoip: &GeoIp, literal: &str) -> Option<String> {
geoip
.lookup(ip(literal))
.and_then(|record| record.city_name.as_deref().map(str::to_owned))
}
#[test]
fn an_unchanged_database_is_not_reopened() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("db.mmdb");
replace(&path, CITY_DB, at(0));
let geoip = GeoIp::open(DatabasePaths::city_only(&path), CacheConfig::default()).unwrap();
assert!(geoip.lookup(ip(BOXFORD)).is_some());
assert_eq!(geoip.cached_entries(), 1);
assert!(!geoip.refresh_if_changed().unwrap());
assert_eq!(geoip.cached_entries(), 1);
}
#[test]
fn a_replaced_database_is_reopened_and_the_cache_is_cleared() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("db.mmdb");
replace(&path, CITY_DB, at(0));
let geoip = GeoIp::open(DatabasePaths::city_only(&path), CacheConfig::default()).unwrap();
assert_eq!(city_of(&geoip, BOXFORD).as_deref(), Some("Boxford"));
assert_eq!(geoip.cached_entries(), 1);
replace(&path, ASN_DB, at(60));
assert!(geoip.refresh_if_changed().unwrap());
assert_eq!(geoip.cached_entries(), 0);
assert!(geoip.lookup(ip(BOXFORD)).is_none());
}
#[test]
fn a_second_refresh_after_a_swap_reports_nothing_further() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("db.mmdb");
replace(&path, CITY_DB, at(0));
let geoip = GeoIp::open(DatabasePaths::city_only(&path), CacheConfig::default()).unwrap();
replace(&path, CITY_DB, at(60));
assert!(geoip.refresh_if_changed().unwrap());
assert!(!geoip.refresh_if_changed().unwrap());
}
#[test]
fn refreshing_one_database_leaves_the_other_in_place() {
let dir = tempfile::tempdir().unwrap();
let city = dir.path().join("city.mmdb");
let asn = dir.path().join("asn.mmdb");
replace(&city, CITY_DB, at(0));
replace(&asn, ASN_DB, at(0));
let geoip = GeoIp::open(
DatabasePaths {
city: Some(&city),
asn: Some(&asn),
},
CacheConfig::default(),
)
.unwrap();
let before = geoip.lookup(ip(LINKOPING)).unwrap();
assert!(before.city_name.is_some());
assert!(before.autonomous_system_number.is_some());
replace(&city, CITY_DB, at(60));
assert!(geoip.refresh_if_changed().unwrap());
let after = geoip.lookup(ip(LINKOPING)).unwrap();
assert_eq!(
after.autonomous_system_number,
before.autonomous_system_number
);
assert_eq!(after.city_name, before.city_name);
assert!(!Arc::ptr_eq(&before, &after));
}
#[test]
fn a_replacement_that_will_not_open_is_reported_and_the_old_reader_stays() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("db.mmdb");
replace(&path, CITY_DB, at(0));
let geoip = GeoIp::open(DatabasePaths::city_only(&path), CacheConfig::default()).unwrap();
corrupt(&path, at(60));
assert!(matches!(
geoip.refresh_if_changed(),
Err(GeoIpLookupError::Open { .. })
));
assert_eq!(city_of(&geoip, BOXFORD).as_deref(), Some("Boxford"));
assert!(matches!(
geoip.refresh_if_changed(),
Err(GeoIpLookupError::Open { .. })
));
}
#[test]
fn a_vanished_file_keeps_the_open_reader_answering() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("db.mmdb");
replace(&path, CITY_DB, at(0));
let geoip = GeoIp::open(DatabasePaths::city_only(&path), CacheConfig::default()).unwrap();
fs::remove_file(&path).unwrap();
assert!(!geoip.refresh_if_changed().unwrap());
assert_eq!(city_of(&geoip, BOXFORD).as_deref(), Some("Boxford"));
}
#[test]
fn a_mapped_database_is_reopened_the_same_way() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("db.mmdb");
replace(&path, CITY_DB, at(0));
let geoip = GeoIp::open(
DatabasePaths::city_only(&path),
CacheConfig {
resident_max_bytes: 0,
..CacheConfig::default()
},
)
.unwrap();
assert_eq!(geoip.backings().city, Some(DatabaseBacking::Mapped));
assert_eq!(city_of(&geoip, BOXFORD).as_deref(), Some("Boxford"));
assert_eq!(geoip.cached_entries(), 1);
replace(&path, ASN_DB, at(60));
assert!(geoip.refresh_if_changed().unwrap());
assert_eq!(geoip.cached_entries(), 0);
assert_eq!(geoip.backings().city, Some(DatabaseBacking::Mapped));
assert!(geoip.lookup(ip(BOXFORD)).is_none());
}
#[test]
fn a_refresh_re_reads_the_size_and_chooses_the_backing_again() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("db.mmdb");
replace(&path, ASN_DB, at(0));
let ceiling = fs::metadata(ASN_DB).unwrap().len();
assert!(fs::metadata(CITY_DB).unwrap().len() > ceiling);
let geoip = GeoIp::open(
DatabasePaths::asn_only(&path),
CacheConfig {
resident_max_bytes: ceiling,
..CacheConfig::default()
},
)
.unwrap();
assert_eq!(geoip.backings().asn, Some(DatabaseBacking::Resident));
replace(&path, CITY_DB, at(60));
assert!(geoip.refresh_if_changed().unwrap());
assert_eq!(geoip.backings().asn, Some(DatabaseBacking::Mapped));
assert!(geoip.lookup(ip(BOXFORD)).is_some());
replace(&path, ASN_DB, at(120));
assert!(geoip.refresh_if_changed().unwrap());
assert_eq!(geoip.backings().asn, Some(DatabaseBacking::Resident));
assert!(geoip.lookup(ip(BOXFORD)).is_none());
}
#[test]
fn a_lookup_racing_a_refresh_never_files_an_answer_from_the_old_database() {
const PROBES: u8 = 255;
const FILL: u32 = 20_000;
const ROUNDS: u64 = 4;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("db.mmdb");
replace(&path, ASN_DB, at(0));
let geoip = GeoIp::open(DatabasePaths::asn_only(&path), CacheConfig::default()).unwrap();
let probes: Vec<IpAddr> = (0..PROBES)
.map(|host| IpAddr::V4(Ipv4Addr::new(1, 0, 0, host)))
.collect();
for round in 1..=ROUNDS {
let (source, resolves) = if round % 2 == 1 {
(CITY_DB, false)
} else {
(ASN_DB, true)
};
for filler in 0..FILL {
let _ = geoip.lookup(IpAddr::V4(Ipv4Addr::from(0x0b00_0000 + filler)));
}
let stop = Arc::new(AtomicBool::new(false));
let hammer = {
let geoip = geoip.clone();
let probes = probes.clone();
let stop = Arc::clone(&stop);
thread::spawn(move || {
while !stop.load(Ordering::Relaxed) {
for &probe in &probes {
let _ = geoip.lookup(probe);
}
}
})
};
replace(&path, source, at(60 * round));
assert!(geoip.refresh_if_changed().unwrap());
stop.store(true, Ordering::Relaxed);
hammer.join().unwrap();
for &probe in &probes {
assert_eq!(
geoip.lookup(probe).is_some(),
resolves,
"{probe} after round {round}"
);
}
}
}
#[test]
fn an_expired_answer_read_before_a_refresh_is_not_served_after_it() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("db.mmdb");
replace(&path, ASN_DB, at(0));
let max_age = Duration::from_millis(300);
let geoip = GeoIp::open(
DatabasePaths::asn_only(&path),
CacheConfig {
max_age: Some(max_age),
..CacheConfig::default()
},
)
.unwrap();
let probe = IpAddr::V4(Ipv4Addr::new(1, 0, 0, 1));
assert!(geoip.lookup(probe).is_some());
thread::sleep(max_age + Duration::from_millis(50));
let (has_read, release) = crate::geoip::enricher::pause::hold();
let reread = {
let geoip = geoip.clone();
thread::spawn(move || geoip.lookup(probe))
};
has_read.recv().unwrap();
replace(&path, CITY_DB, at(60));
assert!(geoip.refresh_if_changed().unwrap());
release.send(()).unwrap();
assert!(reread.join().unwrap().is_some(), "read from the old file");
assert!(geoip.lookup(probe).is_none(), "served from the old file");
}
#[test]
fn an_enricher_over_no_database_has_nothing_to_refresh() {
let geoip = GeoIp::open(DatabasePaths::default(), CacheConfig::default()).unwrap();
assert!(!geoip.refresh_if_changed().unwrap());
}
}