mirror of
https://github.com/NetworkCats/rustyip.git
synced 2026-11-13 16:17:46 +00:00
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:
+3
-2
@@ -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,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
@@ -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
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user