feat: schedule DB updates at fixed daily UTC time instead of interval

Replace DB_UPDATE_INTERVAL_HOURS with DB_UPDATE_TIME_UTC (HH:MM format,
default 01:20) so the database update runs at a predictable time each day.
This ensures the update happens 20 minutes after the upstream Merged IP
Data release at 01:00 UTC.
This commit is contained in:
NetworkCats
2026-03-22 16:43:02 +08:00
parent cefdceab2c
commit ccfa7decda
6 changed files with 127 additions and 29 deletions
+3 -2
View File
@@ -7,8 +7,9 @@ DB_PATH=data/Merged-IP.mmdb
# URL to download the MMDB database from
DB_UPDATE_URL=https://github.com/NetworkCats/Merged-IP-Data/releases/latest/download/Merged-IP.mmdb
# How often to check for database updates (in hours)
DB_UPDATE_INTERVAL_HOURS=24
# Daily UTC time to update the database (HH:MM format)
# Merged IP Data releases at 01:00 UTC; this should be at least 20 minutes later
DB_UPDATE_TIME_UTC=01:20
# Site domain name (used for display/metadata)
SITE_DOMAIN=localhost
+1 -1
View File
@@ -1,7 +1,7 @@
LISTEN_ADDR=0.0.0.0:3000
DB_PATH=/data/Merged-IP.mmdb
DB_UPDATE_URL={{ db_update_url }}
DB_UPDATE_INTERVAL_HOURS=24
DB_UPDATE_TIME_UTC={{ db_update_time_utc | default('01:20') }}
SITE_DOMAIN={{ site_domain }}
{% if ipv4_domain %}
IPV4_DOMAIN={{ ipv4_domain }}
+1 -1
View File
@@ -21,7 +21,7 @@ services:
- LISTEN_ADDR=0.0.0.0:3000
- DB_PATH=/data/Merged-IP.mmdb
- DB_UPDATE_URL=${DB_UPDATE_URL:-https://github.com/NetworkCats/Merged-IP-Data/releases/latest/download/Merged-IP.mmdb}
- DB_UPDATE_INTERVAL_HOURS=${DB_UPDATE_INTERVAL_HOURS:-24}
- DB_UPDATE_TIME_UTC=${DB_UPDATE_TIME_UTC:-01:20}
- SITE_DOMAIN=${SITE_DOMAIN:-localhost}
- IPV4_DOMAIN=${IPV4_DOMAIN:-}
volumes:
+58 -15
View File
@@ -5,12 +5,31 @@ pub struct Config {
pub listen_addr: SocketAddr,
pub db_path: String,
pub db_update_url: String,
pub db_update_interval_hours: u64,
/// Daily UTC time to check for database updates, as (hour, minute).
pub db_update_time_utc: (u8, u8),
pub site_domain: String,
pub ipv4_domain: String,
pub dev_mode: bool,
}
/// Parses a `"HH:MM"` string into `(hour, minute)`, panicking on invalid input.
fn parse_time_utc(s: &str) -> (u8, u8) {
let parts: Vec<&str> = s.split(':').collect();
assert!(
parts.len() == 2,
"DB_UPDATE_TIME_UTC must be in HH:MM format"
);
let hour: u8 = parts[0]
.parse()
.expect("DB_UPDATE_TIME_UTC hour must be a valid integer");
let minute: u8 = parts[1]
.parse()
.expect("DB_UPDATE_TIME_UTC minute must be a valid integer");
assert!(hour < 24, "DB_UPDATE_TIME_UTC hour must be 0-23");
assert!(minute < 60, "DB_UPDATE_TIME_UTC minute must be 0-59");
(hour, minute)
}
impl Config {
pub fn from_env() -> Self {
let listen_addr = env::var("LISTEN_ADDR")
@@ -25,10 +44,8 @@ impl Config {
.to_string()
});
let db_update_interval_hours = env::var("DB_UPDATE_INTERVAL_HOURS")
.unwrap_or_else(|_| "24".to_string())
.parse()
.expect("DB_UPDATE_INTERVAL_HOURS must be a valid integer");
let db_update_time_utc =
parse_time_utc(&env::var("DB_UPDATE_TIME_UTC").unwrap_or_else(|_| "01:20".to_string()));
let site_domain = env::var("SITE_DOMAIN").unwrap_or_else(|_| "localhost".to_string());
@@ -42,7 +59,7 @@ impl Config {
listen_addr,
db_path,
db_update_url,
db_update_interval_hours,
db_update_time_utc,
site_domain,
ipv4_domain,
dev_mode,
@@ -69,7 +86,7 @@ mod tests {
"LISTEN_ADDR",
"DB_PATH",
"DB_UPDATE_URL",
"DB_UPDATE_INTERVAL_HOURS",
"DB_UPDATE_TIME_UTC",
"SITE_DOMAIN",
"IPV4_DOMAIN",
"DEV_MODE",
@@ -101,7 +118,7 @@ mod tests {
assert_eq!(config.listen_addr.to_string(), "0.0.0.0:3000");
assert_eq!(config.db_path, "data/Merged-IP.mmdb");
assert!(config.db_update_url.contains("Merged-IP.mmdb"));
assert_eq!(config.db_update_interval_hours, 24);
assert_eq!(config.db_update_time_utc, (1, 20));
assert_eq!(config.site_domain, "localhost");
assert!(config.ipv4_domain.is_empty());
assert!(!config.dev_mode);
@@ -156,19 +173,19 @@ mod tests {
}
#[test]
fn custom_update_interval() {
fn custom_update_time() {
let _guard = lock_env();
// SAFETY: ENV_LOCK is held.
unsafe {
clear_config_vars();
set_var("DB_UPDATE_INTERVAL_HOURS", "12");
set_var("DB_UPDATE_TIME_UTC", "03:45");
}
let config = Config::from_env();
assert_eq!(config.db_update_interval_hours, 12);
assert_eq!(config.db_update_time_utc, (3, 45));
// SAFETY: ENV_LOCK is held.
unsafe { remove_var("DB_UPDATE_INTERVAL_HOURS") };
unsafe { remove_var("DB_UPDATE_TIME_UTC") };
}
#[test]
@@ -259,13 +276,39 @@ mod tests {
}
#[test]
#[should_panic(expected = "DB_UPDATE_INTERVAL_HOURS must be a valid integer")]
fn invalid_update_interval_panics() {
#[should_panic(expected = "DB_UPDATE_TIME_UTC must be in HH:MM format")]
fn invalid_update_time_panics() {
let _guard = lock_env();
// SAFETY: ENV_LOCK is held.
unsafe {
clear_config_vars();
set_var("DB_UPDATE_INTERVAL_HOURS", "not-a-number");
set_var("DB_UPDATE_TIME_UTC", "not-a-time");
}
let _config = Config::from_env();
}
#[test]
#[should_panic(expected = "DB_UPDATE_TIME_UTC hour must be 0-23")]
fn update_time_invalid_hour_panics() {
let _guard = lock_env();
// SAFETY: ENV_LOCK is held.
unsafe {
clear_config_vars();
set_var("DB_UPDATE_TIME_UTC", "25:00");
}
let _config = Config::from_env();
}
#[test]
#[should_panic(expected = "DB_UPDATE_TIME_UTC minute must be 0-59")]
fn update_time_invalid_minute_panics() {
let _guard = lock_env();
// SAFETY: ENV_LOCK is held.
unsafe {
clear_config_vars();
set_var("DB_UPDATE_TIME_UTC", "01:60");
}
let _config = Config::from_env();
+1 -1
View File
@@ -75,7 +75,7 @@ async fn main() {
init_db,
init_path,
init_url,
config.db_update_interval_hours,
config.db_update_time_utc,
init_cancel,
)
.await;
+63 -9
View File
@@ -1,6 +1,6 @@
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tokio::fs;
use tokio::io::AsyncWriteExt;
@@ -101,31 +101,57 @@ pub fn spawn_updater(
shared_db: SharedDb,
db_path: PathBuf,
update_url: String,
interval_hours: u64,
update_time_utc: (u8, u8),
cancel: CancellationToken,
) -> JoinHandle<()> {
tokio::spawn(run_updater(
shared_db,
db_path,
update_url,
interval_hours,
update_time_utc,
cancel,
))
}
/// Runs the periodic database updater loop. This is the inner async function
/// that can be called directly when already inside a spawned task.
/// Returns the number of seconds from `now_secs` (seconds since UNIX epoch)
/// until the next occurrence of the given UTC `(hour, minute)`.
fn secs_until_next(now_secs: u64, hour: u8, minute: u8) -> u64 {
const SECS_PER_DAY: u64 = 86_400;
let time_of_day = now_secs % SECS_PER_DAY;
let target = u64::from(hour) * 3600 + u64::from(minute) * 60;
if target > time_of_day {
target - time_of_day
} else {
// Target time already passed today; schedule for tomorrow.
SECS_PER_DAY - time_of_day + target
}
}
/// Runs the daily database updater loop. Sleeps until the configured UTC time
/// each day, then downloads and swaps in a fresh database.
pub async fn run_updater(
shared_db: SharedDb,
db_path: PathBuf,
update_url: String,
interval_hours: u64,
update_time_utc: (u8, u8),
cancel: CancellationToken,
) {
let interval = Duration::from_secs(interval_hours * 3600);
let (hour, minute) = update_time_utc;
loop {
let now_secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system clock before UNIX epoch")
.as_secs();
let delay = Duration::from_secs(secs_until_next(now_secs, hour, minute));
info!(
"next database update in {}h {}m (at {:02}:{:02} UTC)",
delay.as_secs() / 3600,
(delay.as_secs() % 3600) / 60,
hour,
minute,
);
tokio::select! {
() = tokio::time::sleep(interval) => {}
() = tokio::time::sleep(delay) => {}
() = cancel.cancelled() => {
info!("updater task cancelled, stopping");
break;
@@ -251,7 +277,7 @@ mod tests {
shared_db,
PathBuf::from("/nonexistent/path.mmdb"),
"https://invalid.example.com/db.mmdb".to_string(),
24,
(1, 20),
cancel.clone(),
);
@@ -264,6 +290,34 @@ mod tests {
);
}
#[test]
fn secs_until_next_later_today() {
// 00:00:00 UTC, target 01:20 UTC => 1h 20m = 4800s
assert_eq!(secs_until_next(0, 1, 20), 4800);
}
#[test]
fn secs_until_next_wraps_to_tomorrow() {
// 02:00:00 UTC (7200s into the day), target 01:20 (4800s into the day)
// => should wrap to next day: 86400 - 7200 + 4800 = 84000s
let now_secs = 7200; // midnight + 2 hours
assert_eq!(secs_until_next(now_secs, 1, 20), 84000);
}
#[test]
fn secs_until_next_exact_time_wraps_to_tomorrow() {
// Exactly at the target time => should schedule for next day (full 24h).
let target_secs = 1 * 3600 + 20 * 60; // 01:20 = 4800s
assert_eq!(secs_until_next(target_secs, 1, 20), 86400);
}
#[test]
fn secs_until_next_midnight_target() {
// 23:00:00 UTC, target 00:00 UTC => 1 hour = 3600s
let now_secs = 23 * 3600;
assert_eq!(secs_until_next(now_secs, 0, 0), 3600);
}
#[tokio::test]
async fn update_db_swaps_shared_reader() {
let dir = TempDir::new().unwrap();