multi-library: operator kill switch via libraries.enabled #70
15
CLAUDE.md
15
CLAUDE.md
@@ -195,6 +195,21 @@ photos that have a copy under lib N, but the derived data attached to
|
||||
those photos is the merged hash-keyed view. This is the answer to "show
|
||||
me archive photos with their original tags."
|
||||
|
||||
**Operator kill switch (`libraries.enabled`).** Setting `enabled=0` on a
|
||||
library is a hard pause: the watcher skips it entirely — before the
|
||||
probe, before ingest, before any maintenance pass — and the orphan-GC
|
||||
all-online consensus check filters disabled libraries out (they don't
|
||||
keep the GC window closed). Reads / serving are unaffected; nothing
|
||||
prevents `/image?path=...` from resolving against a disabled library's
|
||||
root if the file is on disk. The existing `image_exif` rows for a
|
||||
disabled library are **not deleted** — they continue to anchor
|
||||
hash-keyed derived data, so cross-library duplicates survive the
|
||||
disable. Toggle via SQL; there is intentionally no HTTP endpoint for
|
||||
library mutation (single-user tool, no role / permission story).
|
||||
Typical workflows: stage a new mount with `enabled=0` then flip to `1`;
|
||||
quiet a flaky NAS during maintenance without disturbing the rest of
|
||||
the system.
|
||||
|
||||
**Library availability and safety.** Libraries can be on network shares
|
||||
or removable media; the file watcher must not interpret a temporary
|
||||
unavailability as a mass-deletion event. Every tick begins with a
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Requires SQLite 3.35+ for ALTER TABLE DROP COLUMN.
|
||||
ALTER TABLE libraries DROP COLUMN enabled;
|
||||
14
migrations/2026-05-01-100000_libraries_enabled_flag/up.sql
Normal file
14
migrations/2026-05-01-100000_libraries_enabled_flag/up.sql
Normal file
@@ -0,0 +1,14 @@
|
||||
-- Operator-controlled kill switch for a library. When `enabled = 0` the
|
||||
-- watcher tick skips that library entirely — before the availability
|
||||
-- probe, before ingest, before any maintenance pass — and the orphan-GC
|
||||
-- all-online check treats it as out-of-scope rather than as a blocker.
|
||||
--
|
||||
-- The intended workflow is staging a new mount: insert with enabled=0,
|
||||
-- verify the row appears in /libraries with enabled=false, then UPDATE
|
||||
-- to 1 to start ingest. Same toggle works as a maintenance kill switch
|
||||
-- after the fact ("don't keep probing this NAS while I'm rebooting it").
|
||||
--
|
||||
-- Default 1 so every existing library stays running on upgrade — no
|
||||
-- behavior change without an explicit flip.
|
||||
|
||||
ALTER TABLE libraries ADD COLUMN enabled BOOLEAN NOT NULL DEFAULT 1;
|
||||
@@ -1212,6 +1212,7 @@ mod exif_dao_tests {
|
||||
name: "archive",
|
||||
root_path: "/tmp/archive",
|
||||
created_at: 0,
|
||||
enabled: true,
|
||||
})
|
||||
.execute(&mut conn)
|
||||
.expect("seed second library");
|
||||
|
||||
@@ -144,6 +144,12 @@ pub struct LibraryRow {
|
||||
pub name: String,
|
||||
pub root_path: String,
|
||||
pub created_at: i64,
|
||||
/// Operator kill switch. `false` = the watcher skips this library
|
||||
/// entirely (no probe, no ingest, no maintenance) and orphan-GC
|
||||
/// treats it as out-of-scope for the all-online consensus rule.
|
||||
/// Toggle via SQL today — there is intentionally no HTTP endpoint
|
||||
/// for library mutation (see CLAUDE.md "Multi-library data model").
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Insertable)]
|
||||
@@ -152,6 +158,7 @@ pub struct InsertLibrary<'a> {
|
||||
pub name: &'a str,
|
||||
pub root_path: &'a str,
|
||||
pub created_at: i64,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
// --- Knowledge memory models ---
|
||||
|
||||
@@ -130,6 +130,7 @@ diesel::table! {
|
||||
name -> Text,
|
||||
root_path -> Text,
|
||||
created_at -> BigInt,
|
||||
enabled -> Bool,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,13 @@ pub struct Library {
|
||||
pub id: i32,
|
||||
pub name: String,
|
||||
pub root_path: String,
|
||||
/// Operator kill switch (mirrors `libraries.enabled`). When `false`
|
||||
/// the watcher skips this library entirely — before the probe,
|
||||
/// before ingest, before maintenance. Reads / serving still work
|
||||
/// (a request whose path resolves to a disabled library's root
|
||||
/// will succeed if the file is on disk; nothing prevents that
|
||||
/// today and there's no obvious reason to). Toggle via SQL.
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
impl Library {
|
||||
@@ -57,6 +64,7 @@ impl From<LibraryRow> for Library {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
root_path: row.root_path,
|
||||
enabled: row.enabled,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -111,6 +119,7 @@ pub fn seed_or_patch_from_env(conn: &mut SqliteConnection, base_path: &str) {
|
||||
name: "main",
|
||||
root_path: base_path,
|
||||
created_at: now,
|
||||
enabled: true,
|
||||
})
|
||||
.execute(conn);
|
||||
match result {
|
||||
@@ -343,6 +352,7 @@ mod tests {
|
||||
id: 1,
|
||||
name: "main".into(),
|
||||
root_path: "/tmp/media".into(),
|
||||
enabled: true,
|
||||
};
|
||||
let rel = lib.strip_root(Path::new("/tmp/media/2024/photo.jpg"));
|
||||
assert_eq!(rel.as_deref(), Some("2024/photo.jpg"));
|
||||
@@ -356,6 +366,7 @@ mod tests {
|
||||
id: 1,
|
||||
name: "main".into(),
|
||||
root_path: "/tmp/media".into(),
|
||||
enabled: true,
|
||||
};
|
||||
let abs = lib.resolve("2024/photo.jpg");
|
||||
assert_eq!(abs, PathBuf::from("/tmp/media/2024/photo.jpg"));
|
||||
@@ -373,11 +384,13 @@ mod tests {
|
||||
id: 1,
|
||||
name: "main".into(),
|
||||
root_path: "/tmp/main".into(),
|
||||
enabled: true,
|
||||
},
|
||||
Library {
|
||||
id: 7,
|
||||
name: "archive".into(),
|
||||
root_path: "/tmp/archive".into(),
|
||||
enabled: true,
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -431,15 +444,20 @@ mod tests {
|
||||
assert!(err.contains("unknown library name"));
|
||||
}
|
||||
|
||||
fn probe_lib(id: i32, root: String) -> Library {
|
||||
Library {
|
||||
id,
|
||||
name: "main".into(),
|
||||
root_path: root,
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_online_for_existing_non_empty_dir() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
std::fs::write(tmp.path().join("photo.jpg"), b"hello").unwrap();
|
||||
let lib = Library {
|
||||
id: 1,
|
||||
name: "main".into(),
|
||||
root_path: tmp.path().to_string_lossy().into(),
|
||||
};
|
||||
let lib = probe_lib(1, tmp.path().to_string_lossy().into());
|
||||
// had_data doesn't matter when the dir has entries.
|
||||
assert!(probe_online(&lib, true).is_online());
|
||||
assert!(probe_online(&lib, false).is_online());
|
||||
@@ -447,11 +465,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn probe_stale_when_root_missing() {
|
||||
let lib = Library {
|
||||
id: 1,
|
||||
name: "main".into(),
|
||||
root_path: "/nonexistent/definitely/not/here".into(),
|
||||
};
|
||||
let lib = probe_lib(1, "/nonexistent/definitely/not/here".into());
|
||||
assert!(matches!(
|
||||
probe_online(&lib, false),
|
||||
LibraryHealth::Stale { .. }
|
||||
@@ -463,11 +477,7 @@ mod tests {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let file = tmp.path().join("not-a-dir");
|
||||
std::fs::write(&file, b"x").unwrap();
|
||||
let lib = Library {
|
||||
id: 1,
|
||||
name: "main".into(),
|
||||
root_path: file.to_string_lossy().into(),
|
||||
};
|
||||
let lib = probe_lib(1, file.to_string_lossy().into());
|
||||
assert!(matches!(
|
||||
probe_online(&lib, false),
|
||||
LibraryHealth::Stale { .. }
|
||||
@@ -478,11 +488,7 @@ mod tests {
|
||||
fn probe_empty_dir_is_online_when_no_prior_data() {
|
||||
// Fresh mount: empty directory, no rows in image_exif. Accept it.
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let lib = Library {
|
||||
id: 1,
|
||||
name: "main".into(),
|
||||
root_path: tmp.path().to_string_lossy().into(),
|
||||
};
|
||||
let lib = probe_lib(1, tmp.path().to_string_lossy().into());
|
||||
assert!(probe_online(&lib, false).is_online());
|
||||
}
|
||||
|
||||
@@ -491,11 +497,7 @@ mod tests {
|
||||
// The "share went offline" signal: directory exists but is empty,
|
||||
// and we know the library used to have content. Treat as Stale.
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let lib = Library {
|
||||
id: 1,
|
||||
name: "main".into(),
|
||||
root_path: tmp.path().to_string_lossy().into(),
|
||||
};
|
||||
let lib = probe_lib(1, tmp.path().to_string_lossy().into());
|
||||
match probe_online(&lib, true) {
|
||||
LibraryHealth::Stale { reason, .. } => {
|
||||
assert!(reason.contains("empty"), "unexpected reason: {}", reason)
|
||||
@@ -514,6 +516,7 @@ mod tests {
|
||||
id: 42,
|
||||
name: "test".into(),
|
||||
root_path: tmp.path().to_string_lossy().into(),
|
||||
enabled: true,
|
||||
};
|
||||
let map = new_health_map(&[lib.clone()]);
|
||||
|
||||
|
||||
@@ -444,15 +444,26 @@ pub fn run_orphan_gc(
|
||||
stats
|
||||
}
|
||||
|
||||
/// Helper for the watcher: are *all* libraries currently Online?
|
||||
/// Helper for the watcher: are *all enabled* libraries currently Online?
|
||||
///
|
||||
/// Disabled libraries are out-of-scope for the orphan-GC consensus
|
||||
/// rule — they don't get probed, don't have a health entry, and a
|
||||
/// system with one disabled library should still be able to GC
|
||||
/// orphans for the remaining online libraries. Treating disabled as
|
||||
/// "blocking" would mean flipping a library to `enabled=false` would
|
||||
/// permanently halt GC, which is the opposite of the intended kill-
|
||||
/// switch semantics ("turn this library off and let the rest of the
|
||||
/// system run normally").
|
||||
pub fn all_libraries_online(libs: &[Library], health: &LibraryHealthMap) -> bool {
|
||||
let guard = health.read().unwrap_or_else(|e| e.into_inner());
|
||||
libs.iter().all(|lib| {
|
||||
guard
|
||||
.get(&lib.id)
|
||||
.map(|h| h.is_online())
|
||||
.unwrap_or(false)
|
||||
})
|
||||
libs.iter()
|
||||
.filter(|lib| lib.enabled)
|
||||
.all(|lib| {
|
||||
guard
|
||||
.get(&lib.id)
|
||||
.map(|h| h.is_online())
|
||||
.unwrap_or(false)
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(QueryableByName, Debug)]
|
||||
@@ -733,11 +744,13 @@ mod tests {
|
||||
id: 1,
|
||||
name: "a".into(),
|
||||
root_path: "/x".into(),
|
||||
enabled: true,
|
||||
},
|
||||
Library {
|
||||
id: 2,
|
||||
name: "b".into(),
|
||||
root_path: "/y".into(),
|
||||
enabled: true,
|
||||
},
|
||||
];
|
||||
let health = new_health_map(&libs);
|
||||
@@ -756,4 +769,45 @@ mod tests {
|
||||
}
|
||||
assert!(!all_libraries_online(&libs, &health));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_libraries_online_treats_disabled_as_out_of_scope() {
|
||||
use crate::libraries::{LibraryHealth, new_health_map};
|
||||
// lib 1 enabled+online, lib 2 disabled (would be treated as
|
||||
// Online in the health map's optimistic seed but the map
|
||||
// entry is irrelevant — disabled libs are filtered out
|
||||
// before the health lookup).
|
||||
let libs = vec![
|
||||
Library {
|
||||
id: 1,
|
||||
name: "a".into(),
|
||||
root_path: "/x".into(),
|
||||
enabled: true,
|
||||
},
|
||||
Library {
|
||||
id: 2,
|
||||
name: "b".into(),
|
||||
root_path: "/y".into(),
|
||||
enabled: false,
|
||||
},
|
||||
];
|
||||
let health = new_health_map(&libs);
|
||||
// Sanity: forcibly mark lib 2 stale to prove disabled wins
|
||||
// over even an explicit Stale entry — the filter skips it
|
||||
// before the health check happens.
|
||||
{
|
||||
let mut g = health.write().unwrap();
|
||||
g.insert(
|
||||
2,
|
||||
LibraryHealth::Stale {
|
||||
reason: "intentionally stale".into(),
|
||||
since: 0,
|
||||
},
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
all_libraries_online(&libs, &health),
|
||||
"disabled library should not block consensus"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
18
src/main.rs
18
src/main.rs
@@ -1976,7 +1976,12 @@ fn watch_files(
|
||||
// appear online for up to WATCH_QUICK_INTERVAL_SECONDS (default
|
||||
// 60s) after boot. Same probe logic as the per-tick gate
|
||||
// below; no ingest runs here, just the health update + log.
|
||||
// Disabled libraries skip the probe entirely — they should
|
||||
// never enter the health map (treated as out-of-scope).
|
||||
for lib in &libs {
|
||||
if !lib.enabled {
|
||||
continue;
|
||||
}
|
||||
let context = opentelemetry::Context::new();
|
||||
let had_data = exif_dao
|
||||
.lock()
|
||||
@@ -1998,6 +2003,19 @@ fn watch_files(
|
||||
let is_full_scan = since_last_full.as_secs() >= full_interval_secs;
|
||||
|
||||
for lib in &libs {
|
||||
// Operator kill switch: a disabled library is invisible
|
||||
// to the watcher entirely. No probe, no ingest, no
|
||||
// maintenance, no health entry. Distinct from Stale —
|
||||
// Stale is "we wanted to but couldn't"; Disabled is
|
||||
// "we don't want to". Toggle via SQL.
|
||||
if !lib.enabled {
|
||||
debug!(
|
||||
"watcher: skipping library '{}' (id={}) — enabled=false",
|
||||
lib.name, lib.id
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Availability probe: every tick checks that the
|
||||
// library's mount is reachable, is a directory, is
|
||||
// readable, and (if image_exif has rows for it) is
|
||||
|
||||
@@ -355,6 +355,7 @@ impl AppState {
|
||||
id: crate::libraries::PRIMARY_LIBRARY_ID,
|
||||
name: "main".to_string(),
|
||||
root_path: base_path_str.clone(),
|
||||
enabled: true,
|
||||
};
|
||||
let insight_generator = InsightGenerator::new(
|
||||
ollama.clone(),
|
||||
@@ -391,6 +392,7 @@ impl AppState {
|
||||
id: crate::libraries::PRIMARY_LIBRARY_ID,
|
||||
name: "main".to_string(),
|
||||
root_path: base_path_str.clone(),
|
||||
enabled: true,
|
||||
}];
|
||||
AppState::new(
|
||||
Arc::new(StreamActor {}.start()),
|
||||
|
||||
Reference in New Issue
Block a user