Add factory reset, safe catalog rebuild, and DB hardening
- Factory reset endpoint clears computed tables (catalog, nightly_cache, tonight, weather_cache), VACUUMs the DB, then rebuilds in background. Preserves all user data (imaging_log, gallery, phd2_logs, horizon). - Catalog rebuild now fetches data BEFORE touching the DB — network failures no longer leave the catalog empty. DELETE + INSERT wrapped in a single transaction via replace_catalog() so a mid-write failure rolls back and old data is preserved. - Added nightly_cache indexes and bumped pool to 10 connections with 30s acquire timeout to prevent exhaustion during rebuilds. - Settings page: factory reset button with inline confirmation dialog. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+42
-32
@@ -103,6 +103,7 @@ pub fn build_router(pool: SqlitePool) -> Router {
|
||||
.route("/api/catalog/refresh", post(catalog_refresh))
|
||||
.route("/api/catalog/rebuild", get(catalog_rebuild))
|
||||
.route("/api/nightly/recompute", post(nightly_recompute))
|
||||
.route("/api/factory-reset", post(factory_reset))
|
||||
// Stats
|
||||
.route("/api/stats", get(stats::get_stats))
|
||||
// Static gallery files served via tower-http
|
||||
@@ -131,30 +132,13 @@ async fn catalog_rebuild(
|
||||
axum::extract::State(state): axum::extract::State<AppState>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
let pool = state.pool.clone();
|
||||
|
||||
match catalog_rebuild_task(&pool).await {
|
||||
Ok(stats) => {
|
||||
tracing::info!(
|
||||
"Manual catalog rebuild complete: {} objects ({})",
|
||||
stats.total,
|
||||
stats.by_type.iter()
|
||||
.map(|(t, c)| format!("{}: {}", t, c))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
);
|
||||
Ok(Json(serde_json::json!({
|
||||
"status": "success",
|
||||
"total": stats.total,
|
||||
"by_type": stats.by_type,
|
||||
"messier_count": stats.messier_count,
|
||||
"has_sizes": stats.has_sizes,
|
||||
})))
|
||||
tokio::spawn(async move {
|
||||
match catalog_rebuild_task(&pool).await {
|
||||
Ok(stats) => tracing::info!("Manual catalog rebuild complete: {} objects", stats.total),
|
||||
Err(e) => tracing::error!("Manual catalog rebuild failed: {}", e),
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Manual catalog rebuild failed: {}", e);
|
||||
Err(AppError::Internal(format!("Rebuild failed: {}", e)))
|
||||
}
|
||||
}
|
||||
});
|
||||
Ok(Json(serde_json::json!({ "status": "rebuild_started" })))
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
@@ -166,15 +150,11 @@ struct RebuildStats {
|
||||
}
|
||||
|
||||
async fn catalog_rebuild_task(pool: &SqlitePool) -> Result<RebuildStats, Box<dyn std::error::Error>> {
|
||||
// Clear existing catalog
|
||||
sqlx::query("DELETE FROM catalog").execute(pool).await?;
|
||||
sqlx::query("DELETE FROM nightly_cache").execute(pool).await?;
|
||||
|
||||
// Build fresh catalog
|
||||
// Fetch catalog data FIRST — if network fails, existing DB is untouched
|
||||
let entries = crate::catalog::build_catalog().await?;
|
||||
let total = entries.len();
|
||||
|
||||
// Compute stats
|
||||
// Compute stats from in-memory entries before any DB writes
|
||||
let mut by_type: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
|
||||
for entry in &entries {
|
||||
*by_type.entry(entry.obj_type.clone()).or_insert(0) += 1;
|
||||
@@ -182,8 +162,10 @@ async fn catalog_rebuild_task(pool: &SqlitePool) -> Result<RebuildStats, Box<dyn
|
||||
let messier_count = entries.iter().filter(|e| e.messier_num.is_some()).count();
|
||||
let has_sizes = entries.iter().filter(|e| e.size_arcmin_maj.is_some()).count();
|
||||
|
||||
// Upsert entries to database
|
||||
crate::catalog::upsert_entries(pool, &entries).await?;
|
||||
// Atomically replace catalog (DELETE + INSERT in one transaction).
|
||||
// If the insert fails halfway, the transaction rolls back and old data is preserved.
|
||||
sqlx::query("DELETE FROM nightly_cache").execute(pool).await?;
|
||||
crate::catalog::replace_catalog(pool, &entries).await?;
|
||||
|
||||
// Update catalog version
|
||||
sqlx::query("INSERT OR REPLACE INTO settings (key, value) VALUES ('catalog_version', ?)")
|
||||
@@ -191,7 +173,7 @@ async fn catalog_rebuild_task(pool: &SqlitePool) -> Result<RebuildStats, Box<dyn
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
// Automatically trigger nightly recompute
|
||||
// Trigger nightly recompute
|
||||
if let Err(e) = crate::jobs::precompute_tonight(pool).await {
|
||||
tracing::warn!("Nightly precompute after rebuild failed: {}", e);
|
||||
}
|
||||
@@ -212,6 +194,34 @@ async fn nightly_recompute(
|
||||
Ok(Json(serde_json::json!({ "status": "recompute_started" })))
|
||||
}
|
||||
|
||||
async fn factory_reset(
|
||||
axum::extract::State(state): axum::extract::State<AppState>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
tracing::info!("Factory reset: clearing transient data...");
|
||||
|
||||
// Clear all computed/cached tables — preserve user data (imaging_log, gallery, phd2_logs, horizon, target_notes, custom_targets)
|
||||
for table in &["nightly_cache", "tonight", "catalog", "weather_cache"] {
|
||||
sqlx::query(&format!("DELETE FROM {}", table))
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Reclaim disk space
|
||||
sqlx::query("VACUUM").execute(&state.pool).await?;
|
||||
tracing::info!("Factory reset: VACUUM complete");
|
||||
|
||||
// Rebuild catalog + nightly precompute in background
|
||||
let pool = state.pool.clone();
|
||||
tokio::spawn(async move {
|
||||
match catalog_rebuild_task(&pool).await {
|
||||
Ok(stats) => tracing::info!("Factory reset rebuild complete: {} objects", stats.total),
|
||||
Err(e) => tracing::error!("Factory reset rebuild failed: {}", e),
|
||||
}
|
||||
});
|
||||
|
||||
Ok(Json(serde_json::json!({ "status": "reset_started", "message": "Catalog cleared and rebuild started. Imaging logs and gallery preserved." })))
|
||||
}
|
||||
|
||||
async fn health(
|
||||
axum::extract::State(state): axum::extract::State<AppState>,
|
||||
) -> Json<serde_json::Value> {
|
||||
|
||||
@@ -302,6 +302,51 @@ pub async fn build_catalog() -> anyhow::Result<Vec<CatalogEntry>> {
|
||||
}
|
||||
|
||||
|
||||
/// Atomically replace the entire catalog: DELETE then INSERT in one transaction.
|
||||
/// If the insert fails halfway, the transaction rolls back and the old catalog is preserved.
|
||||
/// Call this only after build_catalog() has already succeeded.
|
||||
pub async fn replace_catalog(pool: &SqlitePool, entries: &[CatalogEntry]) -> anyhow::Result<()> {
|
||||
let mut tx = pool.begin().await?;
|
||||
sqlx::query("DELETE FROM catalog").execute(&mut *tx).await?;
|
||||
for e in entries {
|
||||
sqlx::query(
|
||||
r#"INSERT OR REPLACE INTO catalog
|
||||
(id, name, common_name, obj_type, ra_deg, dec_deg, ra_h, dec_dms,
|
||||
constellation, size_arcmin_maj, size_arcmin_min, pos_angle_deg,
|
||||
mag_v, surface_brightness, hubble_type, messier_num, is_highlight,
|
||||
fov_fill_pct, mosaic_flag, mosaic_panels_w, mosaic_panels_h,
|
||||
difficulty, guide_star_density, fetched_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)"#,
|
||||
)
|
||||
.bind(&e.id).bind(&e.name).bind(&e.common_name).bind(&e.obj_type)
|
||||
.bind(e.ra_deg).bind(e.dec_deg).bind(&e.ra_h).bind(&e.dec_dms)
|
||||
.bind(&e.constellation).bind(e.size_arcmin_maj).bind(e.size_arcmin_min)
|
||||
.bind(e.pos_angle_deg).bind(e.mag_v).bind(e.surface_brightness)
|
||||
.bind(&e.hubble_type).bind(e.messier_num).bind(e.is_highlight)
|
||||
.bind(e.fov_fill_pct).bind(e.mosaic_flag).bind(e.mosaic_panels_w)
|
||||
.bind(e.mosaic_panels_h).bind(e.difficulty).bind(e.guide_star_density.as_deref())
|
||||
.bind(e.fetched_at)
|
||||
.execute(&mut *tx).await?;
|
||||
}
|
||||
tx.commit().await?;
|
||||
|
||||
// Apply cross-catalog number mappings (best-effort, outside transaction)
|
||||
for (num, id) in caldwell::caldwell_map() {
|
||||
let _ = sqlx::query("UPDATE catalog SET caldwell_num = ? WHERE id = ?").bind(num).bind(id).execute(pool).await;
|
||||
}
|
||||
for (num, id) in caldwell::arp_map() {
|
||||
let _ = sqlx::query("UPDATE catalog SET arp_num = ? WHERE id = ?").bind(num).bind(id).execute(pool).await;
|
||||
}
|
||||
for (num, id) in melotte::melotte_map() {
|
||||
let _ = sqlx::query("UPDATE catalog SET melotte_num = ? WHERE id = ?").bind(num).bind(id).execute(pool).await;
|
||||
}
|
||||
for (num, id) in collinder::collinder_map() {
|
||||
let _ = sqlx::query("UPDATE catalog SET collinder_num = ? WHERE id = ?").bind(num).bind(id).execute(pool).await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn upsert_entries(pool: &SqlitePool, entries: &[CatalogEntry]) -> anyhow::Result<()> {
|
||||
let mut tx = pool.begin().await?;
|
||||
for e in entries {
|
||||
|
||||
@@ -11,7 +11,8 @@ pub async fn init_db(database_url: &str) -> anyhow::Result<SqlitePool> {
|
||||
.foreign_keys(true);
|
||||
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(5)
|
||||
.max_connections(10)
|
||||
.acquire_timeout(std::time::Duration::from_secs(30))
|
||||
.connect_with(options)
|
||||
.await
|
||||
.context("failed to connect to SQLite")?;
|
||||
|
||||
Reference in New Issue
Block a user