multi-library: operator kill switch via libraries.enabled

A small follow-up to Branches A/B/C. Adds a nullable-default-1
boolean column to the `libraries` table that controls whether the
watcher considers the library at all. Useful for staging a new
mount before committing to ingest, and as a maintenance kill
switch when a library needs to be quiet without being unmounted.

Migration (2026-05-01-100000_libraries_enabled_flag)

  ALTER TABLE libraries ADD COLUMN enabled BOOLEAN NOT NULL DEFAULT 1.
  Existing rows stay enabled — no behavior change on upgrade.

Watcher gate (main.rs)

  At the top of the per-library loop, if !lib.enabled { continue; }
  — runs BEFORE the availability probe. Disabled libraries don't
  enter the health map, don't get probed, don't get ingest, don't
  get any maintenance pass. The initial sweep before the loop's
  first sleep also skips disabled libraries.

Orphan-GC consensus (library_maintenance.rs)

  all_libraries_online filters disabled libraries out of the
  consensus check — they're treated as out-of-scope, not as
  blockers. Otherwise flipping enabled=false would permanently
  halt orphan GC for the rest of the system, which is the opposite
  of the intended kill-switch semantics.

Cross-library duplicates: safe by construction. Hash-keyed derived
data (face_detections, tagged_photo with hash, photo_insights with
hash) is anchored by ANY image_exif row carrying the hash. Disabling
a library does NOT delete its image_exif rows, so a hash referenced
by a disabled library's row stays anchored — derived data survives.
collect_orphan_hashes deliberately doesn't filter image_exif by
library.enabled for exactly this reason.

No HTTP endpoint. Library mutation is rare-enough infra work that a
SQL toggle is fine, and a public mutation endpoint without a role /
permission story would be poorly-prioritized exposure for a
single-user tool. Documented in CLAUDE.md.

Tests: 226 pass (225 from Branch C + 1 new
all_libraries_online_treats_disabled_as_out_of_scope, which proves
that even an explicit Stale entry on a disabled library doesn't
block the consensus).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cameron Cordes
2026-05-01 19:10:24 +00:00
parent 23448cf5e6
commit 3598bb2cfe
10 changed files with 149 additions and 32 deletions

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