use anyhow::{Context, Result};
use feather_reader::config::Config;
use feather_reader::{store, web, AppState, VERSION};
use tokio::sync::watch;
use tracing::info;
use tracing_subscriber::{fmt, prelude::*, EnvFilter};
#[path = "scheduler.rs"]
mod scheduler;
#[tokio::main]
async fn main() -> Result<()> {
let config = Config::from_env().context("loading configuration")?;
init_tracing();
info!(version = VERSION, bind = %config.bind, db = %config.db_path.display(), "starting featherreader");
let db = store::init(&config)
.await
.context("initializing the SQLite store")?;
if wants_vacuum_migration(std::env::args()) {
let outcome = run_vacuum_migration(&db, &config).await;
db.close().await;
return outcome;
}
check_watermark_vs_disk(&config);
match store::ensure_seed(&db, config.admin_seed_dids()).await {
Ok(new_seats) => {
if new_seats > 0 {
info!(new_seats, "seeded admin DIDs into beta_access");
}
}
Err(err) => return Err(err).context("seeding admin beta_access DIDs"),
}
let bind = config.bind;
let state = AppState::new(config, db).context("building application state")?;
state
.runtime_health
.set_started_at(chrono::Utc::now().timestamp());
let (shutdown_tx, shutdown_rx) = watch::channel(());
tokio::spawn(async move {
shutdown_signal().await;
let _ = shutdown_tx.send(());
});
let scheduler_handles = scheduler::spawn(state.clone(), shutdown_rx.clone());
let router = web::router(state);
let listener = tokio::net::TcpListener::bind(bind)
.await
.with_context(|| format!("binding to {bind}"))?;
info!(addr = %bind, "listening");
axum::serve(
listener,
router.into_make_service_with_connect_info::<std::net::SocketAddr>(),
)
.with_graceful_shutdown(wait_for_shutdown(shutdown_rx))
.await
.context("HTTP server error")?;
for h in scheduler_handles {
let _ = h.await;
}
info!("shutdown complete");
Ok(())
}
const MIGRATE_AUTO_VACUUM_FLAG: &str = "--migrate-auto-vacuum";
fn wants_vacuum_migration<I: IntoIterator<Item = String>>(args: I) -> bool {
args.into_iter()
.skip(1)
.any(|a| a == MIGRATE_AUTO_VACUUM_FLAG)
}
async fn run_vacuum_migration(db: &store::Pool, config: &Config) -> Result<()> {
info!(db = %config.db_path.display(), "auto_vacuum migration: starting");
info!(
"auto_vacuum migration: this rewrites the whole database file and holds an \
exclusive lock for minutes. Writes from a RUNNING instance will FAIL (not \
queue) for the duration, logins included. Stop the app first."
);
let available = available_disk_bytes(&config.db_path);
match store::migrate_to_incremental_vacuum(db, available).await? {
store::VacuumMigration::NotNeeded(mode) => {
info!(?mode, "auto_vacuum migration: nothing to do");
}
store::VacuumMigration::RefusedNoHeadroom {
needed,
available,
file_bytes,
} => {
tracing::warn!(
needed_bytes = needed,
available_bytes = available,
file_bytes = file_bytes.unwrap_or(0),
"auto_vacuum migration: REFUSED. A full VACUUM writes a second copy of \
the database, so it needs roughly twice the LIVE size free (the file on \
disk is larger; the freelist is not copied). Free space on the volume \
(or grow it) and run this again."
);
}
store::VacuumMigration::Migrated {
bytes_before,
bytes_after,
file_before,
file_after,
} => {
info!(
bytes_before,
bytes_after,
file_before = file_before.unwrap_or(0),
file_after = file_after.unwrap_or(0),
"auto_vacuum migration: complete; the database is now INCREMENTAL"
);
}
}
Ok(())
}
fn check_watermark_vs_disk(config: &Config) {
let watermark = config.db_size_watermark_bytes;
if watermark <= 0 {
info!("DB-size watermark disabled (0): the poller will not pause on disk pressure");
return;
}
info!(
watermark_bytes = watermark,
db = %config.db_path.display(),
"DB-size watermark effective (poller pauses new fetches at/above this)"
);
match available_disk_bytes(&config.db_path) {
Some(avail) if watermark as u64 >= avail => {
tracing::warn!(
watermark_bytes = watermark,
available_bytes = avail,
db = %config.db_path.display(),
"DB-size watermark is >= free space on its volume: it cannot protect the disk \
(the volume fills before the poller pauses). Set \
FEATHERREADER_DB_SIZE_WATERMARK_BYTES BELOW the volume size."
);
}
Some(avail) => info!(
available_bytes = avail,
"DB volume free space checked; watermark below it"
),
None => debug_no_statvfs(),
}
}
fn debug_no_statvfs() {
info!(
"could not read DB volume free space (statvfs unavailable); watermark value logged above"
);
}
#[cfg(unix)]
fn available_disk_bytes(path: &std::path::Path) -> Option<u64> {
use std::os::unix::ffi::OsStrExt;
let dir = path.parent().filter(|p| !p.as_os_str().is_empty());
let target = dir.unwrap_or_else(|| std::path::Path::new("."));
let cstr = std::ffi::CString::new(target.as_os_str().as_bytes()).ok()?;
let mut stat: libc::statvfs = unsafe { std::mem::zeroed() };
let rc = unsafe { libc::statvfs(cstr.as_ptr(), &mut stat) };
if rc != 0 {
return None;
}
let frsize = stat.f_frsize as u128;
let bavail = stat.f_bavail as u128;
Some((frsize.saturating_mul(bavail)).min(u64::MAX as u128) as u64)
}
#[cfg(not(unix))]
fn available_disk_bytes(_path: &std::path::Path) -> Option<u64> {
None
}
fn init_tracing() {
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
tracing_subscriber::registry()
.with(filter)
.with(fmt::layer())
.init();
}
async fn shutdown_signal() {
#[cfg(unix)]
{
use tokio::signal::unix::{signal, SignalKind};
match signal(SignalKind::terminate()) {
Ok(mut sigterm) => {
tokio::select! {
r = tokio::signal::ctrl_c() => {
if let Err(err) = r {
tracing::error!(%err, "failed to install Ctrl-C handler");
}
}
_ = sigterm.recv() => {}
}
}
Err(err) => {
tracing::error!(%err, "failed to install SIGTERM handler");
let _ = tokio::signal::ctrl_c().await;
}
}
info!("shutdown signal received");
}
#[cfg(not(unix))]
{
if let Err(err) = tokio::signal::ctrl_c().await {
tracing::error!(%err, "failed to install Ctrl-C handler");
}
info!("shutdown signal received");
}
}
async fn wait_for_shutdown(mut rx: watch::Receiver<()>) {
let _ = rx.changed().await;
}