ANW-8 /health endpoint + ANW-15 local follow-ups
This commit is contained in:
parent
b045efd1f0
commit
1e4d2a7b7c
8 changed files with 440 additions and 27 deletions
94
Cargo.lock
generated
94
Cargo.lock
generated
|
|
@ -17,6 +17,15 @@ version = "0.2.21"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
||||
|
||||
[[package]]
|
||||
name = "android_system_properties"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstream"
|
||||
version = "1.0.0"
|
||||
|
|
@ -332,8 +341,10 @@ version = "0.4.44"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
|
||||
dependencies = [
|
||||
"iana-time-zone",
|
||||
"num-traits",
|
||||
"serde",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -388,6 +399,12 @@ version = "0.4.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation-sys"
|
||||
version = "0.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
|
|
@ -902,6 +919,30 @@ dependencies = [
|
|||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iana-time-zone"
|
||||
version = "0.1.65"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
|
||||
dependencies = [
|
||||
"android_system_properties",
|
||||
"core-foundation-sys",
|
||||
"iana-time-zone-haiku",
|
||||
"js-sys",
|
||||
"log",
|
||||
"wasm-bindgen",
|
||||
"windows-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iana-time-zone-haiku"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "id-arena"
|
||||
version = "2.3.0"
|
||||
|
|
@ -2302,12 +2343,65 @@ version = "0.4.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.62.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
|
||||
dependencies = [
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-link",
|
||||
"windows-result",
|
||||
"windows-strings",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-implement"
|
||||
version = "0.60.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.59.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-strings"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.59.0"
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ path = "src/main.rs"
|
|||
anyhow = "1"
|
||||
axum = "0.8"
|
||||
blake3 = "1"
|
||||
chrono = { version = "0.4", default-features = false, features = ["std", "serde"] }
|
||||
chrono = { version = "0.4", default-features = false, features = ["std", "serde", "clock"] }
|
||||
clap = { version = "4", features = ["derive", "env"] }
|
||||
hydra = "0.1"
|
||||
notify = "8"
|
||||
|
|
|
|||
72
src/app.rs
72
src/app.rs
|
|
@ -36,6 +36,7 @@ use serde::{Deserialize, Serialize};
|
|||
use tokio::sync::mpsc;
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
use crate::health::HealthState;
|
||||
use crate::http::{self as http_layer, HttpState};
|
||||
use crate::index::NoteIndex;
|
||||
use crate::store::NoteStore;
|
||||
|
|
@ -114,6 +115,8 @@ pub struct Anwesen {
|
|||
/// keeps it current via the writer's batches, and the HTTP layer reads
|
||||
/// from it on every request.
|
||||
pub store: Arc<NoteStore>,
|
||||
/// Shared mutable health surface consumed by `/health` ([ANW-8]).
|
||||
pub health: Arc<HealthState>,
|
||||
}
|
||||
|
||||
impl Anwesen {
|
||||
|
|
@ -124,6 +127,7 @@ impl Anwesen {
|
|||
bind,
|
||||
counters: RestartCounters::new(),
|
||||
store: NoteStore::new(),
|
||||
health: HealthState::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -146,26 +150,37 @@ impl Application for Anwesen {
|
|||
// 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.
|
||||
//
|
||||
// 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.
|
||||
let children = [
|
||||
IndexWriter {
|
||||
counters: self.counters.clone(),
|
||||
store: self.store.clone(),
|
||||
health: self.health.clone(),
|
||||
}
|
||||
.child_spec(),
|
||||
VaultScanner {
|
||||
vault: self.vault.clone(),
|
||||
counters: self.counters.clone(),
|
||||
health: self.health.clone(),
|
||||
}
|
||||
.child_spec(),
|
||||
FilesystemWatcher {
|
||||
vault: self.vault.clone(),
|
||||
counters: self.counters.clone(),
|
||||
health: self.health.clone(),
|
||||
}
|
||||
.child_spec(),
|
||||
HttpServer {
|
||||
bind: self.bind,
|
||||
counters: self.counters.clone(),
|
||||
store: self.store.clone(),
|
||||
health: self.health.clone(),
|
||||
vault: self.vault.clone(),
|
||||
}
|
||||
.child_spec(),
|
||||
];
|
||||
|
|
@ -201,23 +216,30 @@ fn call_not_supported<M>(role: &str) -> Result<Option<M>, ExitReason> {
|
|||
pub struct VaultScanner {
|
||||
vault: PathBuf,
|
||||
counters: Arc<RestartCounters>,
|
||||
health: Arc<HealthState>,
|
||||
}
|
||||
|
||||
impl VaultScanner {
|
||||
fn child_spec(self) -> ChildSpec {
|
||||
let counters = self.counters.clone();
|
||||
let vault = self.vault.clone();
|
||||
let health = self.health.clone();
|
||||
ChildSpec::new(VAULT_SCANNER_NAME).start(move || {
|
||||
VaultScanner {
|
||||
vault: vault.clone(),
|
||||
counters: counters.clone(),
|
||||
health: health.clone(),
|
||||
}
|
||||
.start_link(GenServerOptions::new().name(VAULT_SCANNER_NAME))
|
||||
})
|
||||
}
|
||||
|
||||
fn run_walk(&self) {
|
||||
self.health.set_in_flight_rescan(true);
|
||||
let result = vault::scan(&self.vault);
|
||||
// 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);
|
||||
tracing::info!(
|
||||
notes = result.notes.len(),
|
||||
issues = result.issues.len(),
|
||||
|
|
@ -285,16 +307,19 @@ pub enum FilesystemWatcherMessage {
|
|||
pub struct FilesystemWatcher {
|
||||
vault: PathBuf,
|
||||
counters: Arc<RestartCounters>,
|
||||
health: Arc<HealthState>,
|
||||
}
|
||||
|
||||
impl FilesystemWatcher {
|
||||
fn child_spec(self) -> ChildSpec {
|
||||
let counters = self.counters.clone();
|
||||
let vault = self.vault.clone();
|
||||
let health = self.health.clone();
|
||||
ChildSpec::new(FILESYSTEM_WATCHER_NAME).start(move || {
|
||||
FilesystemWatcherState {
|
||||
vault: vault.clone(),
|
||||
counters: counters.clone(),
|
||||
health: health.clone(),
|
||||
watcher: None,
|
||||
debouncer: None,
|
||||
}
|
||||
|
|
@ -310,6 +335,7 @@ impl FilesystemWatcher {
|
|||
struct FilesystemWatcherState {
|
||||
vault: PathBuf,
|
||||
counters: Arc<RestartCounters>,
|
||||
health: Arc<HealthState>,
|
||||
watcher: Option<notify::RecommendedWatcher>,
|
||||
debouncer: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
|
@ -337,15 +363,33 @@ impl GenServer for FilesystemWatcherState {
|
|||
// (process shutdown).
|
||||
let _ = tx.send(res);
|
||||
})
|
||||
.map_err(|e| ExitReason::from(format!("filesystem_watcher: recommended_watcher: {e}")))?;
|
||||
.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}"))
|
||||
})?;
|
||||
watcher
|
||||
.watch(&self.vault, RecursiveMode::Recursive)
|
||||
.map_err(|e| ExitReason::from(format!("filesystem_watcher: watch: {e}")))?;
|
||||
.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));
|
||||
let handle = tokio::spawn(run_debouncer(
|
||||
rx,
|
||||
self.vault.clone(),
|
||||
WATCH_DEBOUNCE_WINDOW,
|
||||
self.health.clone(),
|
||||
));
|
||||
|
||||
self.watcher = Some(watcher);
|
||||
self.debouncer = Some(handle);
|
||||
self.health
|
||||
.set_watcher_state(crate::health::WatcherState::Running);
|
||||
|
||||
tracing::info!(
|
||||
restart,
|
||||
|
|
@ -404,16 +448,19 @@ pub struct IndexBatch {
|
|||
pub struct IndexWriter {
|
||||
counters: Arc<RestartCounters>,
|
||||
store: Arc<NoteStore>,
|
||||
health: Arc<HealthState>,
|
||||
}
|
||||
|
||||
impl IndexWriter {
|
||||
fn child_spec(self) -> ChildSpec {
|
||||
let counters = self.counters.clone();
|
||||
let store = self.store.clone();
|
||||
let health = self.health.clone();
|
||||
ChildSpec::new(INDEX_WRITER_NAME).start(move || {
|
||||
IndexWriterState {
|
||||
counters: counters.clone(),
|
||||
store: store.clone(),
|
||||
health: health.clone(),
|
||||
index: None,
|
||||
}
|
||||
.start_link(GenServerOptions::new().name(INDEX_WRITER_NAME))
|
||||
|
|
@ -429,6 +476,7 @@ impl IndexWriter {
|
|||
pub(crate) struct IndexWriterState {
|
||||
counters: Arc<RestartCounters>,
|
||||
store: Arc<NoteStore>,
|
||||
health: Arc<HealthState>,
|
||||
/// Created lazily in `init` so a Tantivy construction failure surfaces
|
||||
/// as an `ExitReason` and triggers a supervisor restart, rather than
|
||||
/// poisoning the child spec.
|
||||
|
|
@ -508,6 +556,7 @@ impl GenServer for IndexWriterState {
|
|||
tracing::debug!(%path, "index_writer: deleted");
|
||||
}
|
||||
}
|
||||
self.health.record_index_update(chrono::Utc::now());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -521,6 +570,10 @@ impl GenServer for IndexWriterState {
|
|||
}
|
||||
|
||||
// -- http_server ------------------------------------------------------------
|
||||
//
|
||||
// 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.
|
||||
|
||||
/// The HTTP server has no inbound message protocol; the single `Noop`
|
||||
/// variant satisfies Hydra's `Receivable` bound.
|
||||
|
|
@ -534,6 +587,8 @@ pub struct HttpServer {
|
|||
bind: SocketAddr,
|
||||
counters: Arc<RestartCounters>,
|
||||
store: Arc<NoteStore>,
|
||||
health: Arc<HealthState>,
|
||||
vault: PathBuf,
|
||||
}
|
||||
|
||||
impl HttpServer {
|
||||
|
|
@ -541,11 +596,16 @@ impl HttpServer {
|
|||
let bind = self.bind;
|
||||
let counters = self.counters.clone();
|
||||
let store = self.store.clone();
|
||||
let health = self.health.clone();
|
||||
let vault = self.vault.clone();
|
||||
ChildSpec::new(HTTP_SERVER_NAME).start(move || {
|
||||
HttpServerState {
|
||||
bind,
|
||||
counters: counters.clone(),
|
||||
store: store.clone(),
|
||||
health: health.clone(),
|
||||
restart_counters: counters.clone(),
|
||||
vault: vault.clone(),
|
||||
server: None,
|
||||
}
|
||||
.start_link(GenServerOptions::new().name(HTTP_SERVER_NAME))
|
||||
|
|
@ -557,6 +617,9 @@ struct HttpServerState {
|
|||
bind: SocketAddr,
|
||||
counters: Arc<RestartCounters>,
|
||||
store: Arc<NoteStore>,
|
||||
health: Arc<HealthState>,
|
||||
restart_counters: Arc<RestartCounters>,
|
||||
vault: PathBuf,
|
||||
server: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
|
|
@ -580,6 +643,9 @@ impl GenServer for HttpServerState {
|
|||
|
||||
let router = http_layer::router(HttpState {
|
||||
store: self.store.clone(),
|
||||
health: self.health.clone(),
|
||||
restart_counters: self.restart_counters.clone(),
|
||||
vault: self.vault.clone(),
|
||||
});
|
||||
let server = tokio::spawn(async move {
|
||||
if let Err(e) = axum::serve(listener, router).await {
|
||||
|
|
|
|||
153
src/health.rs
Normal file
153
src/health.rs
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
//! Shared health state surfaced by the `/health` endpoint per [ANW-8].
|
||||
//!
|
||||
//! The fields the User Manual exposes are written from three places:
|
||||
//!
|
||||
//! - `in_flight_rescan` -- set by `vault_scanner` around each walk
|
||||
//! (startup or `RescanNow`);
|
||||
//! - `last_index_update_ts` -- set by `index_writer` on each successful
|
||||
//! Rebuild / Batch / Upsert / Delete commit;
|
||||
//! - `last_event_ts` and `watcher_state` -- set by `filesystem_watcher`
|
||||
//! as raw events arrive (or fail to bind, per [[ADR-003 Filesystem
|
||||
//! Change Tracking]]).
|
||||
//!
|
||||
//! Reads come from the HTTP layer. Atomic counters are used so updates
|
||||
//! never contend on a lock; `chrono::DateTime<Utc>` is stored as a Unix
|
||||
//! millisecond stamp (0 means "never").
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU8, Ordering};
|
||||
|
||||
use chrono::{DateTime, TimeZone, Utc};
|
||||
|
||||
/// Stable identifier for `watcher_state` -- the inotify binding only.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum WatcherState {
|
||||
Running,
|
||||
Degraded,
|
||||
}
|
||||
|
||||
impl WatcherState {
|
||||
#[must_use]
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Running => "running",
|
||||
Self::Degraded => "degraded",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct HealthState {
|
||||
in_flight_rescan: AtomicBool,
|
||||
last_index_update_ms: AtomicI64,
|
||||
last_event_ms: AtomicI64,
|
||||
watcher_state: AtomicU8, // 0 = Running, 1 = Degraded
|
||||
}
|
||||
|
||||
impl HealthState {
|
||||
#[must_use]
|
||||
pub fn new() -> Arc<Self> {
|
||||
Arc::new(Self::default())
|
||||
}
|
||||
|
||||
pub fn set_in_flight_rescan(&self, on: bool) {
|
||||
self.in_flight_rescan.store(on, Ordering::Release);
|
||||
}
|
||||
|
||||
/// # Panics
|
||||
/// Never; the timestamp is unconditionally stored.
|
||||
pub fn record_index_update(&self, at: DateTime<Utc>) {
|
||||
self.last_index_update_ms
|
||||
.store(at.timestamp_millis(), Ordering::Release);
|
||||
}
|
||||
|
||||
pub fn record_event(&self, at: DateTime<Utc>) {
|
||||
self.last_event_ms
|
||||
.store(at.timestamp_millis(), Ordering::Release);
|
||||
}
|
||||
|
||||
pub fn set_watcher_state(&self, s: WatcherState) {
|
||||
self.watcher_state.store(
|
||||
match s {
|
||||
WatcherState::Running => 0,
|
||||
WatcherState::Degraded => 1,
|
||||
},
|
||||
Ordering::Release,
|
||||
);
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn in_flight_rescan(&self) -> bool {
|
||||
self.in_flight_rescan.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn last_index_update(&self) -> Option<DateTime<Utc>> {
|
||||
let ms = self.last_index_update_ms.load(Ordering::Acquire);
|
||||
if ms == 0 {
|
||||
None
|
||||
} else {
|
||||
Utc.timestamp_millis_opt(ms).single()
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn last_event(&self) -> Option<DateTime<Utc>> {
|
||||
let ms = self.last_event_ms.load(Ordering::Acquire);
|
||||
if ms == 0 {
|
||||
None
|
||||
} else {
|
||||
Utc.timestamp_millis_opt(ms).single()
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn watcher_state(&self) -> WatcherState {
|
||||
if self.watcher_state.load(Ordering::Acquire) == 0 {
|
||||
WatcherState::Running
|
||||
} else {
|
||||
WatcherState::Degraded
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn fresh_state_reads_initial_values() {
|
||||
let s = HealthState::default();
|
||||
assert!(!s.in_flight_rescan());
|
||||
assert!(s.last_index_update().is_none());
|
||||
assert!(s.last_event().is_none());
|
||||
assert_eq!(s.watcher_state(), WatcherState::Running);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rescan_flag_round_trips() {
|
||||
let s = HealthState::default();
|
||||
s.set_in_flight_rescan(true);
|
||||
assert!(s.in_flight_rescan());
|
||||
s.set_in_flight_rescan(false);
|
||||
assert!(!s.in_flight_rescan());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timestamp_round_trips_at_millisecond_resolution() {
|
||||
let s = HealthState::default();
|
||||
let now = Utc.with_ymd_and_hms(2026, 5, 14, 12, 0, 0).unwrap();
|
||||
s.record_index_update(now);
|
||||
// Stored as ms; expect the same wall-clock value back.
|
||||
assert_eq!(s.last_index_update(), Some(now));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn watcher_state_round_trips() {
|
||||
let s = HealthState::default();
|
||||
s.set_watcher_state(WatcherState::Degraded);
|
||||
assert_eq!(s.watcher_state(), WatcherState::Degraded);
|
||||
s.set_watcher_state(WatcherState::Running);
|
||||
assert_eq!(s.watcher_state(), WatcherState::Running);
|
||||
}
|
||||
}
|
||||
47
src/http.rs
47
src/http.rs
|
|
@ -24,6 +24,10 @@ use chrono::{DateTime, SecondsFormat, Utc};
|
|||
use serde::Serialize;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::app::RestartCounters;
|
||||
use crate::health::HealthState;
|
||||
use crate::store::NoteStore;
|
||||
use crate::vault::{Note, frontmatter_to_json};
|
||||
|
||||
|
|
@ -38,6 +42,9 @@ fn rfc3339_z(dt: DateTime<Utc>) -> String {
|
|||
#[derive(Clone)]
|
||||
pub struct HttpState {
|
||||
pub store: Arc<NoteStore>,
|
||||
pub health: Arc<HealthState>,
|
||||
pub restart_counters: Arc<RestartCounters>,
|
||||
pub vault: PathBuf,
|
||||
}
|
||||
|
||||
pub fn router(state: HttpState) -> Router {
|
||||
|
|
@ -49,6 +56,7 @@ pub fn router(state: HttpState) -> Router {
|
|||
.route("/notes/", get(list_root_folder))
|
||||
.route("/notes/{*path}", get(get_notes))
|
||||
.route("/query", get(get_query))
|
||||
.route("/health", get(get_health))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
|
|
@ -101,6 +109,42 @@ async fn list_root_folder(State(state): State<HttpState>) -> Response {
|
|||
list_folder(&state, "").into_response()
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct HealthResponse {
|
||||
vault_path: String,
|
||||
note_count: usize,
|
||||
last_index_update_ts: Option<String>,
|
||||
last_event_ts: Option<String>,
|
||||
watcher_state: &'static str,
|
||||
in_flight_rescan: bool,
|
||||
supervisor: SupervisorBlock,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct SupervisorBlock {
|
||||
restarts: BTreeMap<&'static str, u32>,
|
||||
}
|
||||
|
||||
async fn get_health(State(state): State<HttpState>) -> Response {
|
||||
let body = HealthResponse {
|
||||
vault_path: state.vault.to_string_lossy().into_owned(),
|
||||
note_count: state.store.len(),
|
||||
last_index_update_ts: state.health.last_index_update().map(rfc3339_z),
|
||||
last_event_ts: state.health.last_event().map(rfc3339_z),
|
||||
watcher_state: state.health.watcher_state().as_str(),
|
||||
in_flight_rescan: state.health.in_flight_rescan(),
|
||||
supervisor: SupervisorBlock {
|
||||
restarts: state.restart_counters.snapshot(),
|
||||
},
|
||||
};
|
||||
let bytes = serde_json::to_vec(&body).expect("health response serializes");
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(bytes))
|
||||
.expect("static response")
|
||||
}
|
||||
|
||||
async fn get_query(State(state): State<HttpState>, OriginalUri(uri): OriginalUri) -> Response {
|
||||
let raw = uri.query().unwrap_or("");
|
||||
let parsed = match crate::query::parse(raw) {
|
||||
|
|
@ -377,6 +421,9 @@ mod tests {
|
|||
store.replace(notes);
|
||||
let r = router(HttpState {
|
||||
store: store.clone(),
|
||||
health: crate::health::HealthState::new(),
|
||||
restart_counters: crate::app::RestartCounters::new(),
|
||||
vault: PathBuf::from("/test/vault"),
|
||||
});
|
||||
(r, store)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
//! supervisor tree, and the HTTP surface as those issues land.
|
||||
|
||||
pub mod app;
|
||||
pub mod health;
|
||||
pub mod http;
|
||||
pub mod index;
|
||||
pub mod query;
|
||||
|
|
|
|||
94
src/query.rs
94
src/query.rs
|
|
@ -24,7 +24,7 @@ use serde::Serialize;
|
|||
use serde_json::Value as JsonValue;
|
||||
|
||||
use crate::store::NoteStore;
|
||||
use crate::vault::{Frontmatter, Note, Value, frontmatter_to_json};
|
||||
use crate::vault::{Frontmatter, Value, frontmatter_to_json};
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum QueryError {
|
||||
|
|
@ -127,6 +127,21 @@ pub fn parse(raw: &str) -> Result<ParsedQuery, QueryError> {
|
|||
}
|
||||
|
||||
let (field, op) = split_operator(&key)?;
|
||||
// Pre-validate values whose error-shape is documented per ANW-15 sie
|
||||
// review #4 (regex) and #5 (exists). Surface 400s at parse time
|
||||
// instead of silently zero-matching.
|
||||
match op {
|
||||
Operator::Regex => {
|
||||
Regex::new(&value)
|
||||
.map_err(|e| QueryError::BadValue(format!("{key}: invalid regex: {e}")))?;
|
||||
}
|
||||
Operator::Exists => {
|
||||
value
|
||||
.parse::<bool>()
|
||||
.map_err(|_| QueryError::BadValue(format!("{key}={value}")))?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
q.predicates.push(Predicate {
|
||||
field: field.to_string(),
|
||||
op,
|
||||
|
|
@ -238,13 +253,17 @@ pub struct QueryResponse {
|
|||
}
|
||||
|
||||
/// Run a parsed query against the in-memory note set.
|
||||
///
|
||||
/// Per ANW-15 sie review #6, the per-match clone shed inside the
|
||||
/// read-lock closure projects directly into the response shape -- no
|
||||
/// `raw_bytes` / `body` copy happens for `/query`.
|
||||
pub fn execute<F>(store: &NoteStore, query: &ParsedQuery, format_ts: F) -> QueryResponse
|
||||
where
|
||||
F: Fn(chrono::DateTime<chrono::Utc>) -> String,
|
||||
{
|
||||
let prefix = query.path_prefix.as_deref().unwrap_or("");
|
||||
let matches = store.with_read(|notes| {
|
||||
let mut out = Vec::new();
|
||||
let mut out: Vec<ResultEntry> = Vec::new();
|
||||
for (path, note) in notes {
|
||||
if !path_matches(path, prefix, query.recursive) {
|
||||
continue;
|
||||
|
|
@ -252,27 +271,23 @@ where
|
|||
if !predicates_match(&query.predicates, ¬e.frontmatter) {
|
||||
continue;
|
||||
}
|
||||
out.push(note.clone());
|
||||
out.push(ResultEntry {
|
||||
path: note.path.clone(),
|
||||
frontmatter: frontmatter_to_json(¬e.frontmatter),
|
||||
last_modified: format_ts(note.last_modified),
|
||||
etag: note.etag.clone(),
|
||||
size: note.size,
|
||||
});
|
||||
}
|
||||
out
|
||||
});
|
||||
let total = matches.len();
|
||||
let (results, truncated): (Vec<Note>, bool) = match query.limit {
|
||||
let (results, truncated): (Vec<ResultEntry>, bool) = match query.limit {
|
||||
Some(limit) if matches.len() > limit => (matches.into_iter().take(limit).collect(), true),
|
||||
_ => (matches, false),
|
||||
};
|
||||
let entries = results
|
||||
.into_iter()
|
||||
.map(|n| ResultEntry {
|
||||
path: n.path,
|
||||
frontmatter: frontmatter_to_json(&n.frontmatter),
|
||||
last_modified: format_ts(n.last_modified),
|
||||
etag: n.etag,
|
||||
size: n.size,
|
||||
})
|
||||
.collect();
|
||||
QueryResponse {
|
||||
results: entries,
|
||||
results,
|
||||
total,
|
||||
truncated,
|
||||
}
|
||||
|
|
@ -282,15 +297,18 @@ fn path_matches(path: &str, prefix: &str, recursive: bool) -> bool {
|
|||
if prefix.is_empty() {
|
||||
return recursive || !path.contains('/');
|
||||
}
|
||||
let scoped = match path.strip_prefix(prefix) {
|
||||
Some(rest) => rest.strip_prefix('/').unwrap_or(rest),
|
||||
None => return false,
|
||||
};
|
||||
if scoped.is_empty() {
|
||||
// Anchor the prefix on a segment boundary so `__anw-path=Projects`
|
||||
// doesn't match `Projects-old/x.md`. Fix-pattern matches the folder
|
||||
// listing in ANW-14 (sie's ANW-15 review #3).
|
||||
let scoped = if path == prefix {
|
||||
// The prefix itself names a note; consider it in-scope.
|
||||
return true;
|
||||
}
|
||||
if recursive {
|
||||
""
|
||||
} else if let Some(rest) = path.strip_prefix(&format!("{prefix}/")) {
|
||||
rest
|
||||
} else {
|
||||
return false;
|
||||
};
|
||||
if recursive || scoped.is_empty() {
|
||||
true
|
||||
} else {
|
||||
!scoped.contains('/')
|
||||
|
|
@ -475,6 +493,8 @@ mod tests {
|
|||
use chrono::{TimeZone, Utc};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::vault::Note;
|
||||
|
||||
fn note(path: &str, fm: Frontmatter) -> Note {
|
||||
Note {
|
||||
path: path.into(),
|
||||
|
|
@ -679,6 +699,34 @@ mod tests {
|
|||
assert_eq!(r.results[0].path, "Projects/anwesen/x.md");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_param_path_anchors_on_segment_boundary() {
|
||||
// ANW-22 regression: `Projects` must not match `Projects-old/x.md`.
|
||||
let q = parse("__anw-path=Projects").unwrap();
|
||||
let s = store_with(vec![
|
||||
note("Projects/y.md", fm(&[])),
|
||||
note("Projects-old/x.md", fm(&[])),
|
||||
]);
|
||||
let r = run(&q, &s);
|
||||
assert_eq!(r.total, 1);
|
||||
assert_eq!(r.results[0].path, "Projects/y.md");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regex_invalid_returns_bad_value() {
|
||||
// ANW-15 sie review #4: invalid regex is 400 at parse time, not
|
||||
// silently zero-match at exec time.
|
||||
let err = parse("title__regex=(unclosed").unwrap_err();
|
||||
assert!(matches!(err, QueryError::BadValue(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exists_unparseable_bool_returns_bad_value() {
|
||||
// ANW-15 sie review #5: malformed bool is 400, not silent false.
|
||||
let err = parse("deprecated__exists=maybe").unwrap_err();
|
||||
assert!(matches!(err, QueryError::BadValue(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_param_recursive_false_limits_to_direct_children() {
|
||||
let q = parse("__anw-path=Projects&__anw-recursive=false").unwrap();
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use hydra::{Dest, GenServer};
|
||||
|
|
@ -22,6 +23,7 @@ use tokio::sync::mpsc::UnboundedReceiver;
|
|||
|
||||
use crate::app::{INDEX_WRITER_NAME, IndexBatch, IndexWriterMessage, IndexWriterState};
|
||||
use crate::app::{VAULT_SCANNER_NAME, VaultScanner, VaultScannerMessage};
|
||||
use crate::health::HealthState;
|
||||
use crate::vault;
|
||||
|
||||
/// One path-scoped action derived from a native filesystem event. Always
|
||||
|
|
@ -183,11 +185,13 @@ pub async fn run_debouncer(
|
|||
mut rx: UnboundedReceiver<notify::Result<Event>>,
|
||||
vault_root: PathBuf,
|
||||
window: Duration,
|
||||
health: Arc<HealthState>,
|
||||
) {
|
||||
loop {
|
||||
let Some(first) = rx.recv().await else {
|
||||
break;
|
||||
};
|
||||
health.record_event(chrono::Utc::now());
|
||||
let mut events = vec![first];
|
||||
let deadline = Instant::now() + window;
|
||||
loop {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue