2026-05-14 14:49:02 +02:00
|
|
|
//! Anwesen daemon application: Hydra supervisor tree per [[ADR-004 Hydra as
|
|
|
|
|
//! Process Runtime]] and [ANW-17](https://crvrs.youtrack.cloud/issue/ANW-17).
|
|
|
|
|
//!
|
|
|
|
|
//! ```text
|
|
|
|
|
//! RootSupervisor (one_for_one)
|
|
|
|
|
//! -- vault_scanner (permanent; startup walk + overflow recovery)
|
|
|
|
|
//! -- filesystem_watcher (permanent; restart on inotify error)
|
|
|
|
|
//! -- index_writer (permanent; owns the Tantivy IndexWriter)
|
|
|
|
|
//! -- http_server (permanent; restart on bind loss)
|
|
|
|
|
//! ```
|
|
|
|
|
//!
|
|
|
|
|
//! Only `vault_scanner` has a real body in this commit: it runs a startup
|
|
|
|
|
//! walk via [`crate::vault::scan`] and accepts a `RescanNow` cast that
|
|
|
|
|
//! re-runs the same walk (for the inotify-overflow recovery path described
|
|
|
|
|
//! in [[ADR-003 Filesystem Change Tracking]]). The other three roles are
|
|
|
|
|
//! idle stubs that subsequent issues (ANW-12, ANW-13, ANW-16) replace with
|
|
|
|
|
//! real implementations.
|
|
|
|
|
//!
|
|
|
|
|
//! [`RestartCounters`] is the shared, lock-free snapshot consumed by
|
|
|
|
|
//! [ANW-8](https://crvrs.youtrack.cloud/issue/ANW-8) when wiring the
|
|
|
|
|
//! `/health` endpoint.
|
|
|
|
|
|
|
|
|
|
use std::collections::BTreeMap;
|
|
|
|
|
use std::net::SocketAddr;
|
|
|
|
|
use std::path::PathBuf;
|
|
|
|
|
use std::sync::Arc;
|
|
|
|
|
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
2026-05-14 15:21:32 +02:00
|
|
|
use std::time::Duration;
|
2026-05-14 14:49:02 +02:00
|
|
|
|
|
|
|
|
use hydra::{
|
2026-05-14 14:55:34 +02:00
|
|
|
Application, ApplicationConfig, ChildSpec, Dest, ExitReason, From as HydraFrom, GenServer,
|
2026-05-14 14:49:02 +02:00
|
|
|
GenServerOptions, Pid, SupervisionStrategy, Supervisor, SupervisorOptions,
|
|
|
|
|
};
|
2026-05-14 15:21:32 +02:00
|
|
|
use notify::{RecursiveMode, Watcher};
|
2026-05-14 14:49:02 +02:00
|
|
|
use serde::{Deserialize, Serialize};
|
2026-05-14 15:21:32 +02:00
|
|
|
use tokio::sync::mpsc;
|
|
|
|
|
use tokio::task::JoinHandle;
|
2026-05-14 14:49:02 +02:00
|
|
|
|
2026-05-14 16:23:08 +02:00
|
|
|
use crate::health::HealthState;
|
2026-05-14 15:39:00 +02:00
|
|
|
use crate::http::{self as http_layer, HttpState};
|
2026-05-14 14:55:34 +02:00
|
|
|
use crate::index::NoteIndex;
|
2026-05-14 15:39:00 +02:00
|
|
|
use crate::store::NoteStore;
|
2026-05-14 14:55:34 +02:00
|
|
|
use crate::vault::{self, Note};
|
2026-05-14 15:21:32 +02:00
|
|
|
use crate::watcher::run_debouncer;
|
2026-05-14 14:55:34 +02:00
|
|
|
|
2026-05-14 15:21:32 +02:00
|
|
|
pub(crate) const INDEX_WRITER_NAME: &str = "index_writer";
|
|
|
|
|
pub(crate) const VAULT_SCANNER_NAME: &str = "vault_scanner";
|
|
|
|
|
pub(crate) const FILESYSTEM_WATCHER_NAME: &str = "filesystem_watcher";
|
|
|
|
|
pub(crate) const HTTP_SERVER_NAME: &str = "http_server";
|
|
|
|
|
|
|
|
|
|
/// Default debounce window for the filesystem watcher. Per [[ADR-003
|
|
|
|
|
/// Filesystem Change Tracking]] -- 100 ms is the documented target, tunable
|
|
|
|
|
/// once real save patterns are observed.
|
|
|
|
|
const WATCH_DEBOUNCE_WINDOW: Duration = Duration::from_millis(100);
|
2026-05-14 14:49:02 +02:00
|
|
|
|
|
|
|
|
/// Snapshot of per-process restart counters. Each role increments its own
|
|
|
|
|
/// counter on every `init` *after the first*, so the count represents
|
|
|
|
|
/// supervisor-initiated restarts rather than the initial start.
|
|
|
|
|
#[derive(Debug, Default)]
|
|
|
|
|
pub struct RestartCounters {
|
|
|
|
|
pub vault_scanner: RoleCounter,
|
|
|
|
|
pub filesystem_watcher: RoleCounter,
|
|
|
|
|
pub index_writer: RoleCounter,
|
|
|
|
|
pub http_server: RoleCounter,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Default)]
|
|
|
|
|
pub struct RoleCounter {
|
|
|
|
|
started: AtomicBool,
|
|
|
|
|
restarts: AtomicU32,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl RoleCounter {
|
|
|
|
|
/// Record an `init` call. Returns the current restart count.
|
|
|
|
|
fn record_init(&self) -> u32 {
|
|
|
|
|
if self.started.swap(true, Ordering::AcqRel) {
|
|
|
|
|
self.restarts.fetch_add(1, Ordering::Relaxed) + 1
|
|
|
|
|
} else {
|
|
|
|
|
0
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn get(&self) -> u32 {
|
|
|
|
|
self.restarts.load(Ordering::Relaxed)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl RestartCounters {
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn new() -> Arc<Self> {
|
|
|
|
|
Arc::new(Self::default())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Snapshot the counters with the role names from [[ADR-004 Hydra as
|
|
|
|
|
/// Process Runtime]]'s "Open questions / Closed in v1" section -- these
|
|
|
|
|
/// keys are part of the `/health` contract ([ANW-8]).
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn snapshot(&self) -> BTreeMap<&'static str, u32> {
|
|
|
|
|
let mut out = BTreeMap::new();
|
|
|
|
|
out.insert("vault_scanner", self.vault_scanner.get());
|
|
|
|
|
out.insert("filesystem_watcher", self.filesystem_watcher.get());
|
|
|
|
|
out.insert("index_writer", self.index_writer.get());
|
|
|
|
|
out.insert("http_server", self.http_server.get());
|
|
|
|
|
out
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Daemon application: a Hydra [`Application`] that links a `one_for_one`
|
|
|
|
|
/// supervisor over the four process roles.
|
|
|
|
|
pub struct Anwesen {
|
|
|
|
|
pub vault: PathBuf,
|
|
|
|
|
pub bind: SocketAddr,
|
|
|
|
|
pub counters: Arc<RestartCounters>,
|
2026-05-14 15:39:00 +02:00
|
|
|
/// Shared in-memory note store. The scanner populates it, the watcher
|
|
|
|
|
/// keeps it current via the writer's batches, and the HTTP layer reads
|
|
|
|
|
/// from it on every request.
|
|
|
|
|
pub store: Arc<NoteStore>,
|
2026-05-14 16:23:08 +02:00
|
|
|
/// Shared mutable health surface consumed by `/health` ([ANW-8]).
|
|
|
|
|
pub health: Arc<HealthState>,
|
2026-05-14 14:49:02 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl Anwesen {
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn new(vault: PathBuf, bind: SocketAddr) -> Self {
|
|
|
|
|
Self {
|
|
|
|
|
vault,
|
|
|
|
|
bind,
|
|
|
|
|
counters: RestartCounters::new(),
|
2026-05-14 15:39:00 +02:00
|
|
|
store: NoteStore::new(),
|
2026-05-14 16:23:08 +02:00
|
|
|
health: HealthState::new(),
|
2026-05-14 14:49:02 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl Application for Anwesen {
|
|
|
|
|
fn config() -> ApplicationConfig {
|
|
|
|
|
// We install our own tracing-subscriber in `main::init_logging`, so
|
|
|
|
|
// tell Hydra not to install a second one. Keep the panic hook off
|
|
|
|
|
// too -- panics surface through `tracing` via our subscriber.
|
|
|
|
|
ApplicationConfig::new()
|
|
|
|
|
.with_tracing_subscribe(false)
|
|
|
|
|
.with_tracing_panics(false)
|
|
|
|
|
.with_graceful_shutdown(true)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn start(&self) -> Result<Pid, ExitReason> {
|
2026-05-14 14:55:34 +02:00
|
|
|
// Startup order matters: `vault_scanner` casts a `Rebuild` to the
|
|
|
|
|
// `index_writer` name at the end of its init. Hydra start_link is
|
|
|
|
|
// synchronous, so we put `index_writer` first to guarantee it is
|
|
|
|
|
// registered and in its message loop before `vault_scanner` runs.
|
|
|
|
|
// The `one_for_one` strategy makes the order irrelevant for restart
|
|
|
|
|
// semantics, only for the cold-start handshake.
|
2026-05-14 16:23:08 +02:00
|
|
|
//
|
|
|
|
|
// The cold-start handshake also relies on this order in the other
|
|
|
|
|
// direction: `index_writer.init` skips the `rescan_now` cast on its
|
|
|
|
|
// first init (`record_init() == 0`) because `vault_scanner` is
|
|
|
|
|
// about to run its own startup walk. Reordering these children
|
|
|
|
|
// would silently break that assumption -- so don't.
|
2026-05-14 14:49:02 +02:00
|
|
|
let children = [
|
2026-05-14 14:55:34 +02:00
|
|
|
IndexWriter {
|
2026-05-14 14:49:02 +02:00
|
|
|
counters: self.counters.clone(),
|
2026-05-14 15:39:00 +02:00
|
|
|
store: self.store.clone(),
|
2026-05-14 16:23:08 +02:00
|
|
|
health: self.health.clone(),
|
2026-05-14 14:49:02 +02:00
|
|
|
}
|
|
|
|
|
.child_spec(),
|
2026-05-14 14:55:34 +02:00
|
|
|
VaultScanner {
|
|
|
|
|
vault: self.vault.clone(),
|
2026-05-14 14:49:02 +02:00
|
|
|
counters: self.counters.clone(),
|
2026-05-14 16:23:08 +02:00
|
|
|
health: self.health.clone(),
|
2026-05-14 14:49:02 +02:00
|
|
|
}
|
|
|
|
|
.child_spec(),
|
2026-05-14 14:55:34 +02:00
|
|
|
FilesystemWatcher {
|
2026-05-14 15:21:32 +02:00
|
|
|
vault: self.vault.clone(),
|
2026-05-14 14:49:02 +02:00
|
|
|
counters: self.counters.clone(),
|
2026-05-14 16:23:08 +02:00
|
|
|
health: self.health.clone(),
|
2026-05-14 14:49:02 +02:00
|
|
|
}
|
|
|
|
|
.child_spec(),
|
|
|
|
|
HttpServer {
|
|
|
|
|
bind: self.bind,
|
|
|
|
|
counters: self.counters.clone(),
|
2026-05-14 15:39:00 +02:00
|
|
|
store: self.store.clone(),
|
2026-05-14 16:23:08 +02:00
|
|
|
health: self.health.clone(),
|
|
|
|
|
vault: self.vault.clone(),
|
2026-05-14 14:49:02 +02:00
|
|
|
}
|
|
|
|
|
.child_spec(),
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
Supervisor::with_children(children)
|
|
|
|
|
.strategy(SupervisionStrategy::OneForOne)
|
|
|
|
|
.start_link(SupervisorOptions::new().name("anwesen_root"))
|
|
|
|
|
.await
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// -- vault_scanner ----------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
|
|
|
pub enum VaultScannerMessage {
|
|
|
|
|
/// Re-run the startup walk. Sent by `filesystem_watcher` on inotify
|
|
|
|
|
/// overflow recovery per [[ADR-003 Filesystem Change Tracking]].
|
|
|
|
|
RescanNow,
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 14:55:34 +02:00
|
|
|
fn call_not_supported<M>(role: &str) -> Result<Option<M>, ExitReason> {
|
|
|
|
|
// Each role's GenServer trait shares one `Message` type for both call and
|
|
|
|
|
// cast (Hydra's API shape). To keep a stray `Foo::call(...)` from hanging
|
|
|
|
|
// forever waiting on a reply, every `handle_call` returns this error.
|
|
|
|
|
// Sie flagged the silent-Ok(None) pattern in ANW-17 review; pinning it
|
|
|
|
|
// here in ANW-12 before ANW-16 wires the watcher-to-scanner call.
|
|
|
|
|
Err(ExitReason::from(format!(
|
|
|
|
|
"{role} does not handle synchronous calls; use cast"
|
|
|
|
|
)))
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 14:49:02 +02:00
|
|
|
#[derive(Clone)]
|
|
|
|
|
pub struct VaultScanner {
|
|
|
|
|
vault: PathBuf,
|
|
|
|
|
counters: Arc<RestartCounters>,
|
2026-05-14 16:23:08 +02:00
|
|
|
health: Arc<HealthState>,
|
2026-05-14 14:49:02 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl VaultScanner {
|
|
|
|
|
fn child_spec(self) -> ChildSpec {
|
|
|
|
|
let counters = self.counters.clone();
|
|
|
|
|
let vault = self.vault.clone();
|
2026-05-14 16:23:08 +02:00
|
|
|
let health = self.health.clone();
|
2026-05-14 15:21:32 +02:00
|
|
|
ChildSpec::new(VAULT_SCANNER_NAME).start(move || {
|
2026-05-14 14:49:02 +02:00
|
|
|
VaultScanner {
|
|
|
|
|
vault: vault.clone(),
|
|
|
|
|
counters: counters.clone(),
|
2026-05-14 16:23:08 +02:00
|
|
|
health: health.clone(),
|
2026-05-14 14:49:02 +02:00
|
|
|
}
|
2026-05-14 15:21:32 +02:00
|
|
|
.start_link(GenServerOptions::new().name(VAULT_SCANNER_NAME))
|
2026-05-14 14:49:02 +02:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn run_walk(&self) {
|
2026-05-14 16:23:08 +02:00
|
|
|
self.health.set_in_flight_rescan(true);
|
2026-05-14 14:49:02 +02:00
|
|
|
let result = vault::scan(&self.vault);
|
2026-05-14 16:23:08 +02:00
|
|
|
// Clear before the cast so a consumer that reads `/health` right
|
|
|
|
|
// after the cast doesn't see a stale `in_flight_rescan=true`.
|
|
|
|
|
self.health.set_in_flight_rescan(false);
|
2026-05-14 14:49:02 +02:00
|
|
|
tracing::info!(
|
|
|
|
|
notes = result.notes.len(),
|
|
|
|
|
issues = result.issues.len(),
|
|
|
|
|
vault = %self.vault.display(),
|
|
|
|
|
"vault_scanner: walk complete"
|
|
|
|
|
);
|
|
|
|
|
for issue in &result.issues {
|
|
|
|
|
tracing::warn!(
|
|
|
|
|
path = %issue.path.display(),
|
|
|
|
|
kind = %issue.kind,
|
|
|
|
|
"vault_scanner: skipped file"
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-05-14 14:55:34 +02:00
|
|
|
// Push the fresh record set to the index writer. The cast is
|
|
|
|
|
// address-by-name so we don't have to thread the index_writer Pid
|
|
|
|
|
// through child specs.
|
|
|
|
|
IndexWriterState::cast(
|
|
|
|
|
Dest::from(INDEX_WRITER_NAME),
|
|
|
|
|
IndexWriterMessage::Rebuild(result.notes),
|
|
|
|
|
);
|
2026-05-14 14:49:02 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl GenServer for VaultScanner {
|
|
|
|
|
type Message = VaultScannerMessage;
|
|
|
|
|
|
|
|
|
|
async fn init(&mut self) -> Result<(), ExitReason> {
|
|
|
|
|
let restart = self.counters.vault_scanner.record_init();
|
|
|
|
|
tracing::info!(restart, "vault_scanner: init");
|
|
|
|
|
self.run_walk();
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn handle_cast(&mut self, message: Self::Message) -> Result<(), ExitReason> {
|
|
|
|
|
match message {
|
|
|
|
|
VaultScannerMessage::RescanNow => {
|
|
|
|
|
tracing::info!("vault_scanner: rescan_now received");
|
|
|
|
|
self.run_walk();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn handle_call(
|
|
|
|
|
&mut self,
|
|
|
|
|
_message: Self::Message,
|
|
|
|
|
_from: HydraFrom,
|
|
|
|
|
) -> Result<Option<Self::Message>, ExitReason> {
|
2026-05-14 14:55:34 +02:00
|
|
|
call_not_supported("vault_scanner")
|
2026-05-14 14:49:02 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 15:21:32 +02:00
|
|
|
// -- filesystem_watcher -----------------------------------------------------
|
2026-05-14 14:49:02 +02:00
|
|
|
|
2026-05-14 15:21:32 +02:00
|
|
|
/// The watcher accepts no inbound messages today -- its work is the
|
|
|
|
|
/// `notify` stream plus the debouncer task. The single `Noop` variant
|
|
|
|
|
/// satisfies Hydra's `Receivable` bound; once a real call/cast surface is
|
|
|
|
|
/// useful (e.g. for tests) it can be added.
|
2026-05-14 14:49:02 +02:00
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
|
|
|
pub enum FilesystemWatcherMessage {
|
|
|
|
|
Noop,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Clone)]
|
|
|
|
|
pub struct FilesystemWatcher {
|
2026-05-14 15:21:32 +02:00
|
|
|
vault: PathBuf,
|
2026-05-14 14:49:02 +02:00
|
|
|
counters: Arc<RestartCounters>,
|
2026-05-14 16:23:08 +02:00
|
|
|
health: Arc<HealthState>,
|
2026-05-14 14:49:02 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl FilesystemWatcher {
|
|
|
|
|
fn child_spec(self) -> ChildSpec {
|
|
|
|
|
let counters = self.counters.clone();
|
2026-05-14 15:21:32 +02:00
|
|
|
let vault = self.vault.clone();
|
2026-05-14 16:23:08 +02:00
|
|
|
let health = self.health.clone();
|
2026-05-14 15:21:32 +02:00
|
|
|
ChildSpec::new(FILESYSTEM_WATCHER_NAME).start(move || {
|
|
|
|
|
FilesystemWatcherState {
|
|
|
|
|
vault: vault.clone(),
|
2026-05-14 14:49:02 +02:00
|
|
|
counters: counters.clone(),
|
2026-05-14 16:23:08 +02:00
|
|
|
health: health.clone(),
|
2026-05-14 15:21:32 +02:00
|
|
|
watcher: None,
|
|
|
|
|
debouncer: None,
|
2026-05-14 14:49:02 +02:00
|
|
|
}
|
2026-05-14 15:21:32 +02:00
|
|
|
.start_link(GenServerOptions::new().name(FILESYSTEM_WATCHER_NAME))
|
2026-05-14 14:49:02 +02:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 15:21:32 +02:00
|
|
|
/// Runtime state for the watcher process. Holds the live
|
|
|
|
|
/// [`notify::RecommendedWatcher`] (must outlive event delivery) and the
|
|
|
|
|
/// [`JoinHandle`] for the debouncer task; both are torn down when the
|
|
|
|
|
/// process is dropped on restart.
|
|
|
|
|
struct FilesystemWatcherState {
|
|
|
|
|
vault: PathBuf,
|
|
|
|
|
counters: Arc<RestartCounters>,
|
2026-05-14 16:23:08 +02:00
|
|
|
health: Arc<HealthState>,
|
2026-05-14 15:21:32 +02:00
|
|
|
watcher: Option<notify::RecommendedWatcher>,
|
|
|
|
|
debouncer: Option<JoinHandle<()>>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl Drop for FilesystemWatcherState {
|
|
|
|
|
fn drop(&mut self) {
|
|
|
|
|
if let Some(h) = self.debouncer.take() {
|
|
|
|
|
h.abort();
|
|
|
|
|
}
|
|
|
|
|
// `watcher` drops on its own, which is enough to stop the inotify
|
|
|
|
|
// binding; the debouncer task then sees the channel close.
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl GenServer for FilesystemWatcherState {
|
2026-05-14 14:49:02 +02:00
|
|
|
type Message = FilesystemWatcherMessage;
|
|
|
|
|
|
|
|
|
|
async fn init(&mut self) -> Result<(), ExitReason> {
|
|
|
|
|
let restart = self.counters.filesystem_watcher.record_init();
|
2026-05-14 15:21:32 +02:00
|
|
|
|
|
|
|
|
let (tx, rx) = mpsc::unbounded_channel::<notify::Result<notify::Event>>();
|
|
|
|
|
let mut watcher = notify::recommended_watcher(move |res| {
|
|
|
|
|
// Send is non-blocking on an unbounded channel; ignore the
|
|
|
|
|
// SendError that arises only when the receiver has been dropped
|
|
|
|
|
// (process shutdown).
|
|
|
|
|
let _ = tx.send(res);
|
|
|
|
|
})
|
2026-05-14 16:23:08 +02:00
|
|
|
.map_err(|e| {
|
|
|
|
|
// Failing to construct the watcher means inotify isn't bound.
|
|
|
|
|
// Surface the state so /health reflects it before we exit and
|
|
|
|
|
// let the supervisor restart us.
|
|
|
|
|
self.health
|
|
|
|
|
.set_watcher_state(crate::health::WatcherState::Degraded);
|
|
|
|
|
ExitReason::from(format!("filesystem_watcher: recommended_watcher: {e}"))
|
|
|
|
|
})?;
|
2026-05-14 15:21:32 +02:00
|
|
|
watcher
|
|
|
|
|
.watch(&self.vault, RecursiveMode::Recursive)
|
2026-05-14 16:23:08 +02:00
|
|
|
.map_err(|e| {
|
|
|
|
|
self.health
|
|
|
|
|
.set_watcher_state(crate::health::WatcherState::Degraded);
|
|
|
|
|
ExitReason::from(format!("filesystem_watcher: watch: {e}"))
|
|
|
|
|
})?;
|
|
|
|
|
|
|
|
|
|
let handle = tokio::spawn(run_debouncer(
|
|
|
|
|
rx,
|
|
|
|
|
self.vault.clone(),
|
|
|
|
|
WATCH_DEBOUNCE_WINDOW,
|
|
|
|
|
self.health.clone(),
|
|
|
|
|
));
|
2026-05-14 15:21:32 +02:00
|
|
|
|
|
|
|
|
self.watcher = Some(watcher);
|
|
|
|
|
self.debouncer = Some(handle);
|
2026-05-14 16:23:08 +02:00
|
|
|
self.health
|
|
|
|
|
.set_watcher_state(crate::health::WatcherState::Running);
|
2026-05-14 15:21:32 +02:00
|
|
|
|
|
|
|
|
tracing::info!(
|
|
|
|
|
restart,
|
|
|
|
|
vault = %self.vault.display(),
|
|
|
|
|
debounce = ?WATCH_DEBOUNCE_WINDOW,
|
|
|
|
|
"filesystem_watcher: init"
|
|
|
|
|
);
|
2026-05-14 14:49:02 +02:00
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn handle_cast(&mut self, _message: Self::Message) -> Result<(), ExitReason> {
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn handle_call(
|
|
|
|
|
&mut self,
|
|
|
|
|
_message: Self::Message,
|
|
|
|
|
_from: HydraFrom,
|
|
|
|
|
) -> Result<Option<Self::Message>, ExitReason> {
|
2026-05-14 14:55:34 +02:00
|
|
|
call_not_supported("filesystem_watcher")
|
2026-05-14 14:49:02 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 14:55:34 +02:00
|
|
|
// -- index_writer -----------------------------------------------------------
|
2026-05-14 14:49:02 +02:00
|
|
|
|
2026-05-14 14:55:34 +02:00
|
|
|
/// Messages handled by the [`IndexWriter`] `GenServer`. All variants are casts
|
|
|
|
|
/// (one-way fire-and-forget); calls return an error from `handle_call`.
|
2026-05-14 14:49:02 +02:00
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
|
|
|
pub enum IndexWriterMessage {
|
2026-05-14 14:55:34 +02:00
|
|
|
/// Discard the current index and reindex the given notes. Sent by
|
|
|
|
|
/// `vault_scanner` at startup and after `rescan_now`.
|
|
|
|
|
Rebuild(Vec<Note>),
|
2026-05-14 15:21:32 +02:00
|
|
|
/// Apply one debounce-window's worth of upserts and deletes in a single
|
|
|
|
|
/// Tantivy commit. Sent by `filesystem_watcher`. Picks up sie's ANW-12
|
|
|
|
|
/// follow-up #3 (commit batching) before ANW-16's watcher can turn
|
|
|
|
|
/// per-save events into a hot commit loop.
|
|
|
|
|
Batch(IndexBatch),
|
|
|
|
|
/// Insert-or-replace one note. Retained for direct callers; the watcher
|
|
|
|
|
/// uses `Batch`.
|
2026-05-14 14:55:34 +02:00
|
|
|
Upsert(Box<Note>),
|
2026-05-14 15:21:32 +02:00
|
|
|
/// Drop one note from the index. Retained for direct callers; the
|
|
|
|
|
/// watcher uses `Batch`.
|
2026-05-14 14:55:34 +02:00
|
|
|
Delete(String),
|
2026-05-14 14:49:02 +02:00
|
|
|
}
|
|
|
|
|
|
2026-05-14 15:21:32 +02:00
|
|
|
/// One batched index update. Deletes apply first so a "delete-then-upsert"
|
|
|
|
|
/// sequence is unambiguous; per-path coalescing in
|
|
|
|
|
/// [`crate::watcher::coalesce`] already keeps at most one action per path.
|
|
|
|
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
|
|
|
pub struct IndexBatch {
|
|
|
|
|
pub upserts: Vec<Note>,
|
|
|
|
|
pub deletes: Vec<String>,
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 14:49:02 +02:00
|
|
|
#[derive(Clone)]
|
|
|
|
|
pub struct IndexWriter {
|
|
|
|
|
counters: Arc<RestartCounters>,
|
2026-05-14 15:39:00 +02:00
|
|
|
store: Arc<NoteStore>,
|
2026-05-14 16:23:08 +02:00
|
|
|
health: Arc<HealthState>,
|
2026-05-14 14:49:02 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl IndexWriter {
|
|
|
|
|
fn child_spec(self) -> ChildSpec {
|
|
|
|
|
let counters = self.counters.clone();
|
2026-05-14 15:39:00 +02:00
|
|
|
let store = self.store.clone();
|
2026-05-14 16:23:08 +02:00
|
|
|
let health = self.health.clone();
|
2026-05-14 14:55:34 +02:00
|
|
|
ChildSpec::new(INDEX_WRITER_NAME).start(move || {
|
|
|
|
|
IndexWriterState {
|
2026-05-14 14:49:02 +02:00
|
|
|
counters: counters.clone(),
|
2026-05-14 15:39:00 +02:00
|
|
|
store: store.clone(),
|
2026-05-14 16:23:08 +02:00
|
|
|
health: health.clone(),
|
2026-05-14 14:55:34 +02:00
|
|
|
index: None,
|
2026-05-14 14:49:02 +02:00
|
|
|
}
|
2026-05-14 14:55:34 +02:00
|
|
|
.start_link(GenServerOptions::new().name(INDEX_WRITER_NAME))
|
2026-05-14 14:49:02 +02:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 14:55:34 +02:00
|
|
|
/// Runtime state for the [`IndexWriter`] process. Held in a separate struct
|
|
|
|
|
/// so the `Clone`-friendly child-spec form (which doesn't carry the live
|
2026-05-14 15:39:00 +02:00
|
|
|
/// `NoteIndex`) stays simple. Mirrors every write into the shared
|
|
|
|
|
/// [`NoteStore`] so the HTTP layer can serve read-one and listing
|
|
|
|
|
/// responses without consulting the index.
|
2026-05-14 15:21:32 +02:00
|
|
|
pub(crate) struct IndexWriterState {
|
2026-05-14 14:55:34 +02:00
|
|
|
counters: Arc<RestartCounters>,
|
2026-05-14 15:39:00 +02:00
|
|
|
store: Arc<NoteStore>,
|
2026-05-14 16:23:08 +02:00
|
|
|
health: Arc<HealthState>,
|
2026-05-14 14:55:34 +02:00
|
|
|
/// Created lazily in `init` so a Tantivy construction failure surfaces
|
|
|
|
|
/// as an `ExitReason` and triggers a supervisor restart, rather than
|
|
|
|
|
/// poisoning the child spec.
|
|
|
|
|
index: Option<NoteIndex>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl GenServer for IndexWriterState {
|
2026-05-14 14:49:02 +02:00
|
|
|
type Message = IndexWriterMessage;
|
|
|
|
|
|
|
|
|
|
async fn init(&mut self) -> Result<(), ExitReason> {
|
|
|
|
|
let restart = self.counters.index_writer.record_init();
|
2026-05-14 14:55:34 +02:00
|
|
|
let index = NoteIndex::new()
|
|
|
|
|
.map_err(|e| ExitReason::from(format!("index_writer: NoteIndex::new failed: {e}")))?;
|
|
|
|
|
self.index = Some(index);
|
2026-05-14 16:08:13 +02:00
|
|
|
|
|
|
|
|
if restart > 0 {
|
|
|
|
|
// Writer-only restart: a fresh empty index has come up while the
|
|
|
|
|
// watcher keeps streaming batches at it. Ask `vault_scanner` for
|
|
|
|
|
// a full walk via the same `rescan_now` path the inotify-overflow
|
|
|
|
|
// recovery uses. Per kaa's pin (ADR-004 amendment 2026-05-14),
|
|
|
|
|
// rescan upserts are idempotent on path so in-flight watcher
|
|
|
|
|
// batches converge with the rescan.
|
|
|
|
|
VaultScanner::cast(
|
|
|
|
|
Dest::from(VAULT_SCANNER_NAME),
|
|
|
|
|
VaultScannerMessage::RescanNow,
|
|
|
|
|
);
|
|
|
|
|
tracing::info!(
|
|
|
|
|
restart,
|
|
|
|
|
"index_writer: init -- rescan_now dispatched to vault_scanner"
|
|
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
tracing::info!(restart, "index_writer: init");
|
|
|
|
|
}
|
2026-05-14 14:49:02 +02:00
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 14:55:34 +02:00
|
|
|
async fn handle_cast(&mut self, message: Self::Message) -> Result<(), ExitReason> {
|
|
|
|
|
let Some(index) = self.index.as_mut() else {
|
|
|
|
|
return Err(ExitReason::from(
|
|
|
|
|
"index_writer: handle_cast invoked before init",
|
|
|
|
|
));
|
|
|
|
|
};
|
|
|
|
|
match message {
|
|
|
|
|
IndexWriterMessage::Rebuild(notes) => {
|
|
|
|
|
let count = notes.len();
|
|
|
|
|
if let Err(e) = index.rebuild(¬es) {
|
|
|
|
|
tracing::error!(error = %e, "index_writer: rebuild failed");
|
|
|
|
|
return Err(ExitReason::from(format!("rebuild: {e}")));
|
|
|
|
|
}
|
2026-05-14 15:39:00 +02:00
|
|
|
self.store.replace(notes);
|
2026-05-14 14:55:34 +02:00
|
|
|
tracing::info!(notes = count, "index_writer: rebuilt");
|
|
|
|
|
}
|
2026-05-14 15:21:32 +02:00
|
|
|
IndexWriterMessage::Batch(batch) => {
|
|
|
|
|
let (u, d) = (batch.upserts.len(), batch.deletes.len());
|
|
|
|
|
if let Err(e) = index.apply_batch(&batch.upserts, &batch.deletes) {
|
|
|
|
|
tracing::error!(error = %e, "index_writer: batch apply failed");
|
|
|
|
|
return Err(ExitReason::from(format!("batch: {e}")));
|
|
|
|
|
}
|
2026-05-14 15:39:00 +02:00
|
|
|
self.store.apply_batch(batch.upserts, &batch.deletes);
|
2026-05-14 15:21:32 +02:00
|
|
|
tracing::info!(upserts = u, deletes = d, "index_writer: batch applied");
|
|
|
|
|
}
|
2026-05-14 14:55:34 +02:00
|
|
|
IndexWriterMessage::Upsert(note) => {
|
|
|
|
|
let path = note.path.clone();
|
|
|
|
|
if let Err(e) = index.upsert(¬e) {
|
|
|
|
|
tracing::error!(%path, error = %e, "index_writer: upsert failed");
|
|
|
|
|
return Err(ExitReason::from(format!("upsert {path}: {e}")));
|
|
|
|
|
}
|
2026-05-14 15:39:00 +02:00
|
|
|
self.store.upsert(*note);
|
2026-05-14 14:55:34 +02:00
|
|
|
tracing::debug!(%path, "index_writer: upserted");
|
|
|
|
|
}
|
|
|
|
|
IndexWriterMessage::Delete(path) => {
|
|
|
|
|
if let Err(e) = index.delete(&path) {
|
|
|
|
|
tracing::error!(%path, error = %e, "index_writer: delete failed");
|
|
|
|
|
return Err(ExitReason::from(format!("delete {path}: {e}")));
|
|
|
|
|
}
|
2026-05-14 15:39:00 +02:00
|
|
|
self.store.delete(&path);
|
2026-05-14 14:55:34 +02:00
|
|
|
tracing::debug!(%path, "index_writer: deleted");
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-14 16:23:08 +02:00
|
|
|
self.health.record_index_update(chrono::Utc::now());
|
2026-05-14 14:49:02 +02:00
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn handle_call(
|
|
|
|
|
&mut self,
|
|
|
|
|
_message: Self::Message,
|
|
|
|
|
_from: HydraFrom,
|
|
|
|
|
) -> Result<Option<Self::Message>, ExitReason> {
|
2026-05-14 14:55:34 +02:00
|
|
|
call_not_supported("index_writer")
|
2026-05-14 14:49:02 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 15:39:00 +02:00
|
|
|
// -- http_server ------------------------------------------------------------
|
2026-05-14 16:23:08 +02:00
|
|
|
//
|
|
|
|
|
// The axum binding lands here in ANW-13; the routes follow in /notes,
|
|
|
|
|
// /notes/<folder>/, /query, /health. Updating this comment line when the
|
|
|
|
|
// surface grows so it doesn't quietly drift back into "stub" wording.
|
2026-05-14 14:49:02 +02:00
|
|
|
|
2026-05-14 15:39:00 +02:00
|
|
|
/// The HTTP server has no inbound message protocol; the single `Noop`
|
|
|
|
|
/// variant satisfies Hydra's `Receivable` bound.
|
2026-05-14 14:49:02 +02:00
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
|
|
|
pub enum HttpServerMessage {
|
|
|
|
|
Noop,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Clone)]
|
|
|
|
|
pub struct HttpServer {
|
|
|
|
|
bind: SocketAddr,
|
|
|
|
|
counters: Arc<RestartCounters>,
|
2026-05-14 15:39:00 +02:00
|
|
|
store: Arc<NoteStore>,
|
2026-05-14 16:23:08 +02:00
|
|
|
health: Arc<HealthState>,
|
|
|
|
|
vault: PathBuf,
|
2026-05-14 14:49:02 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl HttpServer {
|
|
|
|
|
fn child_spec(self) -> ChildSpec {
|
|
|
|
|
let bind = self.bind;
|
|
|
|
|
let counters = self.counters.clone();
|
2026-05-14 15:39:00 +02:00
|
|
|
let store = self.store.clone();
|
2026-05-14 16:23:08 +02:00
|
|
|
let health = self.health.clone();
|
|
|
|
|
let vault = self.vault.clone();
|
2026-05-14 15:21:32 +02:00
|
|
|
ChildSpec::new(HTTP_SERVER_NAME).start(move || {
|
2026-05-14 15:39:00 +02:00
|
|
|
HttpServerState {
|
2026-05-14 14:49:02 +02:00
|
|
|
bind,
|
|
|
|
|
counters: counters.clone(),
|
2026-05-14 15:39:00 +02:00
|
|
|
store: store.clone(),
|
2026-05-14 16:23:08 +02:00
|
|
|
health: health.clone(),
|
|
|
|
|
restart_counters: counters.clone(),
|
|
|
|
|
vault: vault.clone(),
|
2026-05-14 15:39:00 +02:00
|
|
|
server: None,
|
2026-05-14 14:49:02 +02:00
|
|
|
}
|
2026-05-14 15:21:32 +02:00
|
|
|
.start_link(GenServerOptions::new().name(HTTP_SERVER_NAME))
|
2026-05-14 14:49:02 +02:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 15:39:00 +02:00
|
|
|
struct HttpServerState {
|
|
|
|
|
bind: SocketAddr,
|
|
|
|
|
counters: Arc<RestartCounters>,
|
|
|
|
|
store: Arc<NoteStore>,
|
2026-05-14 16:23:08 +02:00
|
|
|
health: Arc<HealthState>,
|
|
|
|
|
restart_counters: Arc<RestartCounters>,
|
|
|
|
|
vault: PathBuf,
|
2026-05-14 15:39:00 +02:00
|
|
|
server: Option<JoinHandle<()>>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl Drop for HttpServerState {
|
|
|
|
|
fn drop(&mut self) {
|
|
|
|
|
if let Some(h) = self.server.take() {
|
|
|
|
|
h.abort();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl GenServer for HttpServerState {
|
2026-05-14 14:49:02 +02:00
|
|
|
type Message = HttpServerMessage;
|
|
|
|
|
|
|
|
|
|
async fn init(&mut self) -> Result<(), ExitReason> {
|
|
|
|
|
let restart = self.counters.http_server.record_init();
|
2026-05-14 15:39:00 +02:00
|
|
|
|
|
|
|
|
let listener = tokio::net::TcpListener::bind(self.bind)
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| ExitReason::from(format!("http_server: bind {}: {e}", self.bind)))?;
|
|
|
|
|
|
|
|
|
|
let router = http_layer::router(HttpState {
|
|
|
|
|
store: self.store.clone(),
|
2026-05-14 16:23:08 +02:00
|
|
|
health: self.health.clone(),
|
|
|
|
|
restart_counters: self.restart_counters.clone(),
|
|
|
|
|
vault: self.vault.clone(),
|
2026-05-14 15:39:00 +02:00
|
|
|
});
|
|
|
|
|
let server = tokio::spawn(async move {
|
|
|
|
|
if let Err(e) = axum::serve(listener, router).await {
|
|
|
|
|
tracing::error!(error = %e, "http_server: serve loop exited with error");
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
self.server = Some(server);
|
|
|
|
|
|
|
|
|
|
tracing::info!(restart, bind = %self.bind, "http_server: init");
|
2026-05-14 14:49:02 +02:00
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn handle_cast(&mut self, _message: Self::Message) -> Result<(), ExitReason> {
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn handle_call(
|
|
|
|
|
&mut self,
|
|
|
|
|
_message: Self::Message,
|
|
|
|
|
_from: HydraFrom,
|
|
|
|
|
) -> Result<Option<Self::Message>, ExitReason> {
|
2026-05-14 14:55:34 +02:00
|
|
|
call_not_supported("http_server")
|
2026-05-14 14:49:02 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn role_counter_first_init_is_zero() {
|
|
|
|
|
let c = RoleCounter::default();
|
|
|
|
|
assert_eq!(c.record_init(), 0);
|
|
|
|
|
assert_eq!(c.get(), 0);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn role_counter_counts_restarts_only() {
|
|
|
|
|
let c = RoleCounter::default();
|
|
|
|
|
assert_eq!(c.record_init(), 0); // first start
|
|
|
|
|
assert_eq!(c.record_init(), 1); // first restart
|
|
|
|
|
assert_eq!(c.record_init(), 2); // second restart
|
|
|
|
|
assert_eq!(c.get(), 2);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn snapshot_keys_match_health_contract() {
|
|
|
|
|
let counters = RestartCounters::new();
|
|
|
|
|
let snap = counters.snapshot();
|
|
|
|
|
// Keys are part of the /health payload contract per ADR-004.
|
|
|
|
|
assert_eq!(
|
|
|
|
|
snap.keys().copied().collect::<Vec<_>>(),
|
|
|
|
|
vec![
|
|
|
|
|
"filesystem_watcher",
|
|
|
|
|
"http_server",
|
|
|
|
|
"index_writer",
|
|
|
|
|
"vault_scanner"
|
|
|
|
|
]
|
|
|
|
|
);
|
|
|
|
|
assert!(snap.values().all(|v| *v == 0));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn app_config_disables_hydra_tracing_subscribe() {
|
|
|
|
|
// We install our own tracing-subscriber; Hydra installing a second
|
|
|
|
|
// one would race and double-format every event.
|
|
|
|
|
let cfg = Anwesen::config();
|
|
|
|
|
// Field is pub(crate) inside hydra; we cannot assert directly. Instead
|
|
|
|
|
// we exercise the builder path so a future Hydra change that flips the
|
|
|
|
|
// default trips a compile-level reminder here.
|
|
|
|
|
let _ = cfg;
|
|
|
|
|
}
|
|
|
|
|
}
|