multi-library: operator kill switch via libraries.enabled #70

Merged
cameron merged 1 commits from feature/library-enabled-flag into master 2026-05-01 19:15:22 +00:00
10 changed files with 149 additions and 32 deletions

View File

@@ -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

View File

@@ -0,0 +1,2 @@
-- Requires SQLite 3.35+ for ALTER TABLE DROP COLUMN.
ALTER TABLE libraries DROP COLUMN enabled;

View 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;

View File

@@ -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");

View File

@@ -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 ---

View File

@@ -130,6 +130,7 @@ diesel::table! {
name -> Text,
root_path -> Text,
created_at -> BigInt,
enabled -> Bool,
}
}

View File

@@ -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()]);

View File

@@ -444,10 +444,21 @@ 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| {
libs.iter()
.filter(|lib| lib.enabled)
.all(|lib| {
guard
.get(&lib.id)
.map(|h| h.is_online())
@@ -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"
);
}
}

View File

@@ -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

View File

@@ -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()),