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"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
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]]
|
[[package]]
|
||||||
name = "anstream"
|
name = "anstream"
|
||||||
version = "1.0.0"
|
version = "1.0.0"
|
||||||
|
|
@ -332,8 +341,10 @@ version = "0.4.44"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
|
checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"iana-time-zone",
|
||||||
"num-traits",
|
"num-traits",
|
||||||
"serde",
|
"serde",
|
||||||
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -388,6 +399,12 @@ version = "0.4.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
|
checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "core-foundation-sys"
|
||||||
|
version = "0.8.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cpufeatures"
|
name = "cpufeatures"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
|
|
@ -902,6 +919,30 @@ dependencies = [
|
||||||
"serde",
|
"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]]
|
[[package]]
|
||||||
name = "id-arena"
|
name = "id-arena"
|
||||||
version = "2.3.0"
|
version = "2.3.0"
|
||||||
|
|
@ -2302,12 +2343,65 @@ version = "0.4.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
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]]
|
[[package]]
|
||||||
name = "windows-link"
|
name = "windows-link"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
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]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.59.0"
|
version = "0.59.0"
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ path = "src/main.rs"
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
axum = "0.8"
|
axum = "0.8"
|
||||||
blake3 = "1"
|
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"] }
|
clap = { version = "4", features = ["derive", "env"] }
|
||||||
hydra = "0.1"
|
hydra = "0.1"
|
||||||
notify = "8"
|
notify = "8"
|
||||||
|
|
|
||||||
72
src/app.rs
72
src/app.rs
|
|
@ -36,6 +36,7 @@ use serde::{Deserialize, Serialize};
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
use tokio::task::JoinHandle;
|
use tokio::task::JoinHandle;
|
||||||
|
|
||||||
|
use crate::health::HealthState;
|
||||||
use crate::http::{self as http_layer, HttpState};
|
use crate::http::{self as http_layer, HttpState};
|
||||||
use crate::index::NoteIndex;
|
use crate::index::NoteIndex;
|
||||||
use crate::store::NoteStore;
|
use crate::store::NoteStore;
|
||||||
|
|
@ -114,6 +115,8 @@ pub struct Anwesen {
|
||||||
/// keeps it current via the writer's batches, and the HTTP layer reads
|
/// keeps it current via the writer's batches, and the HTTP layer reads
|
||||||
/// from it on every request.
|
/// from it on every request.
|
||||||
pub store: Arc<NoteStore>,
|
pub store: Arc<NoteStore>,
|
||||||
|
/// Shared mutable health surface consumed by `/health` ([ANW-8]).
|
||||||
|
pub health: Arc<HealthState>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Anwesen {
|
impl Anwesen {
|
||||||
|
|
@ -124,6 +127,7 @@ impl Anwesen {
|
||||||
bind,
|
bind,
|
||||||
counters: RestartCounters::new(),
|
counters: RestartCounters::new(),
|
||||||
store: NoteStore::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.
|
// registered and in its message loop before `vault_scanner` runs.
|
||||||
// The `one_for_one` strategy makes the order irrelevant for restart
|
// The `one_for_one` strategy makes the order irrelevant for restart
|
||||||
// semantics, only for the cold-start handshake.
|
// 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 = [
|
let children = [
|
||||||
IndexWriter {
|
IndexWriter {
|
||||||
counters: self.counters.clone(),
|
counters: self.counters.clone(),
|
||||||
store: self.store.clone(),
|
store: self.store.clone(),
|
||||||
|
health: self.health.clone(),
|
||||||
}
|
}
|
||||||
.child_spec(),
|
.child_spec(),
|
||||||
VaultScanner {
|
VaultScanner {
|
||||||
vault: self.vault.clone(),
|
vault: self.vault.clone(),
|
||||||
counters: self.counters.clone(),
|
counters: self.counters.clone(),
|
||||||
|
health: self.health.clone(),
|
||||||
}
|
}
|
||||||
.child_spec(),
|
.child_spec(),
|
||||||
FilesystemWatcher {
|
FilesystemWatcher {
|
||||||
vault: self.vault.clone(),
|
vault: self.vault.clone(),
|
||||||
counters: self.counters.clone(),
|
counters: self.counters.clone(),
|
||||||
|
health: self.health.clone(),
|
||||||
}
|
}
|
||||||
.child_spec(),
|
.child_spec(),
|
||||||
HttpServer {
|
HttpServer {
|
||||||
bind: self.bind,
|
bind: self.bind,
|
||||||
counters: self.counters.clone(),
|
counters: self.counters.clone(),
|
||||||
store: self.store.clone(),
|
store: self.store.clone(),
|
||||||
|
health: self.health.clone(),
|
||||||
|
vault: self.vault.clone(),
|
||||||
}
|
}
|
||||||
.child_spec(),
|
.child_spec(),
|
||||||
];
|
];
|
||||||
|
|
@ -201,23 +216,30 @@ fn call_not_supported<M>(role: &str) -> Result<Option<M>, ExitReason> {
|
||||||
pub struct VaultScanner {
|
pub struct VaultScanner {
|
||||||
vault: PathBuf,
|
vault: PathBuf,
|
||||||
counters: Arc<RestartCounters>,
|
counters: Arc<RestartCounters>,
|
||||||
|
health: Arc<HealthState>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl VaultScanner {
|
impl VaultScanner {
|
||||||
fn child_spec(self) -> ChildSpec {
|
fn child_spec(self) -> ChildSpec {
|
||||||
let counters = self.counters.clone();
|
let counters = self.counters.clone();
|
||||||
let vault = self.vault.clone();
|
let vault = self.vault.clone();
|
||||||
|
let health = self.health.clone();
|
||||||
ChildSpec::new(VAULT_SCANNER_NAME).start(move || {
|
ChildSpec::new(VAULT_SCANNER_NAME).start(move || {
|
||||||
VaultScanner {
|
VaultScanner {
|
||||||
vault: vault.clone(),
|
vault: vault.clone(),
|
||||||
counters: counters.clone(),
|
counters: counters.clone(),
|
||||||
|
health: health.clone(),
|
||||||
}
|
}
|
||||||
.start_link(GenServerOptions::new().name(VAULT_SCANNER_NAME))
|
.start_link(GenServerOptions::new().name(VAULT_SCANNER_NAME))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run_walk(&self) {
|
fn run_walk(&self) {
|
||||||
|
self.health.set_in_flight_rescan(true);
|
||||||
let result = vault::scan(&self.vault);
|
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!(
|
tracing::info!(
|
||||||
notes = result.notes.len(),
|
notes = result.notes.len(),
|
||||||
issues = result.issues.len(),
|
issues = result.issues.len(),
|
||||||
|
|
@ -285,16 +307,19 @@ pub enum FilesystemWatcherMessage {
|
||||||
pub struct FilesystemWatcher {
|
pub struct FilesystemWatcher {
|
||||||
vault: PathBuf,
|
vault: PathBuf,
|
||||||
counters: Arc<RestartCounters>,
|
counters: Arc<RestartCounters>,
|
||||||
|
health: Arc<HealthState>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FilesystemWatcher {
|
impl FilesystemWatcher {
|
||||||
fn child_spec(self) -> ChildSpec {
|
fn child_spec(self) -> ChildSpec {
|
||||||
let counters = self.counters.clone();
|
let counters = self.counters.clone();
|
||||||
let vault = self.vault.clone();
|
let vault = self.vault.clone();
|
||||||
|
let health = self.health.clone();
|
||||||
ChildSpec::new(FILESYSTEM_WATCHER_NAME).start(move || {
|
ChildSpec::new(FILESYSTEM_WATCHER_NAME).start(move || {
|
||||||
FilesystemWatcherState {
|
FilesystemWatcherState {
|
||||||
vault: vault.clone(),
|
vault: vault.clone(),
|
||||||
counters: counters.clone(),
|
counters: counters.clone(),
|
||||||
|
health: health.clone(),
|
||||||
watcher: None,
|
watcher: None,
|
||||||
debouncer: None,
|
debouncer: None,
|
||||||
}
|
}
|
||||||
|
|
@ -310,6 +335,7 @@ impl FilesystemWatcher {
|
||||||
struct FilesystemWatcherState {
|
struct FilesystemWatcherState {
|
||||||
vault: PathBuf,
|
vault: PathBuf,
|
||||||
counters: Arc<RestartCounters>,
|
counters: Arc<RestartCounters>,
|
||||||
|
health: Arc<HealthState>,
|
||||||
watcher: Option<notify::RecommendedWatcher>,
|
watcher: Option<notify::RecommendedWatcher>,
|
||||||
debouncer: Option<JoinHandle<()>>,
|
debouncer: Option<JoinHandle<()>>,
|
||||||
}
|
}
|
||||||
|
|
@ -337,15 +363,33 @@ impl GenServer for FilesystemWatcherState {
|
||||||
// (process shutdown).
|
// (process shutdown).
|
||||||
let _ = tx.send(res);
|
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
|
watcher
|
||||||
.watch(&self.vault, RecursiveMode::Recursive)
|
.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.watcher = Some(watcher);
|
||||||
self.debouncer = Some(handle);
|
self.debouncer = Some(handle);
|
||||||
|
self.health
|
||||||
|
.set_watcher_state(crate::health::WatcherState::Running);
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
restart,
|
restart,
|
||||||
|
|
@ -404,16 +448,19 @@ pub struct IndexBatch {
|
||||||
pub struct IndexWriter {
|
pub struct IndexWriter {
|
||||||
counters: Arc<RestartCounters>,
|
counters: Arc<RestartCounters>,
|
||||||
store: Arc<NoteStore>,
|
store: Arc<NoteStore>,
|
||||||
|
health: Arc<HealthState>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl IndexWriter {
|
impl IndexWriter {
|
||||||
fn child_spec(self) -> ChildSpec {
|
fn child_spec(self) -> ChildSpec {
|
||||||
let counters = self.counters.clone();
|
let counters = self.counters.clone();
|
||||||
let store = self.store.clone();
|
let store = self.store.clone();
|
||||||
|
let health = self.health.clone();
|
||||||
ChildSpec::new(INDEX_WRITER_NAME).start(move || {
|
ChildSpec::new(INDEX_WRITER_NAME).start(move || {
|
||||||
IndexWriterState {
|
IndexWriterState {
|
||||||
counters: counters.clone(),
|
counters: counters.clone(),
|
||||||
store: store.clone(),
|
store: store.clone(),
|
||||||
|
health: health.clone(),
|
||||||
index: None,
|
index: None,
|
||||||
}
|
}
|
||||||
.start_link(GenServerOptions::new().name(INDEX_WRITER_NAME))
|
.start_link(GenServerOptions::new().name(INDEX_WRITER_NAME))
|
||||||
|
|
@ -429,6 +476,7 @@ impl IndexWriter {
|
||||||
pub(crate) struct IndexWriterState {
|
pub(crate) struct IndexWriterState {
|
||||||
counters: Arc<RestartCounters>,
|
counters: Arc<RestartCounters>,
|
||||||
store: Arc<NoteStore>,
|
store: Arc<NoteStore>,
|
||||||
|
health: Arc<HealthState>,
|
||||||
/// Created lazily in `init` so a Tantivy construction failure surfaces
|
/// Created lazily in `init` so a Tantivy construction failure surfaces
|
||||||
/// as an `ExitReason` and triggers a supervisor restart, rather than
|
/// as an `ExitReason` and triggers a supervisor restart, rather than
|
||||||
/// poisoning the child spec.
|
/// poisoning the child spec.
|
||||||
|
|
@ -508,6 +556,7 @@ impl GenServer for IndexWriterState {
|
||||||
tracing::debug!(%path, "index_writer: deleted");
|
tracing::debug!(%path, "index_writer: deleted");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
self.health.record_index_update(chrono::Utc::now());
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -521,6 +570,10 @@ impl GenServer for IndexWriterState {
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- http_server ------------------------------------------------------------
|
// -- 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`
|
/// The HTTP server has no inbound message protocol; the single `Noop`
|
||||||
/// variant satisfies Hydra's `Receivable` bound.
|
/// variant satisfies Hydra's `Receivable` bound.
|
||||||
|
|
@ -534,6 +587,8 @@ pub struct HttpServer {
|
||||||
bind: SocketAddr,
|
bind: SocketAddr,
|
||||||
counters: Arc<RestartCounters>,
|
counters: Arc<RestartCounters>,
|
||||||
store: Arc<NoteStore>,
|
store: Arc<NoteStore>,
|
||||||
|
health: Arc<HealthState>,
|
||||||
|
vault: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl HttpServer {
|
impl HttpServer {
|
||||||
|
|
@ -541,11 +596,16 @@ impl HttpServer {
|
||||||
let bind = self.bind;
|
let bind = self.bind;
|
||||||
let counters = self.counters.clone();
|
let counters = self.counters.clone();
|
||||||
let store = self.store.clone();
|
let store = self.store.clone();
|
||||||
|
let health = self.health.clone();
|
||||||
|
let vault = self.vault.clone();
|
||||||
ChildSpec::new(HTTP_SERVER_NAME).start(move || {
|
ChildSpec::new(HTTP_SERVER_NAME).start(move || {
|
||||||
HttpServerState {
|
HttpServerState {
|
||||||
bind,
|
bind,
|
||||||
counters: counters.clone(),
|
counters: counters.clone(),
|
||||||
store: store.clone(),
|
store: store.clone(),
|
||||||
|
health: health.clone(),
|
||||||
|
restart_counters: counters.clone(),
|
||||||
|
vault: vault.clone(),
|
||||||
server: None,
|
server: None,
|
||||||
}
|
}
|
||||||
.start_link(GenServerOptions::new().name(HTTP_SERVER_NAME))
|
.start_link(GenServerOptions::new().name(HTTP_SERVER_NAME))
|
||||||
|
|
@ -557,6 +617,9 @@ struct HttpServerState {
|
||||||
bind: SocketAddr,
|
bind: SocketAddr,
|
||||||
counters: Arc<RestartCounters>,
|
counters: Arc<RestartCounters>,
|
||||||
store: Arc<NoteStore>,
|
store: Arc<NoteStore>,
|
||||||
|
health: Arc<HealthState>,
|
||||||
|
restart_counters: Arc<RestartCounters>,
|
||||||
|
vault: PathBuf,
|
||||||
server: Option<JoinHandle<()>>,
|
server: Option<JoinHandle<()>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -580,6 +643,9 @@ impl GenServer for HttpServerState {
|
||||||
|
|
||||||
let router = http_layer::router(HttpState {
|
let router = http_layer::router(HttpState {
|
||||||
store: self.store.clone(),
|
store: self.store.clone(),
|
||||||
|
health: self.health.clone(),
|
||||||
|
restart_counters: self.restart_counters.clone(),
|
||||||
|
vault: self.vault.clone(),
|
||||||
});
|
});
|
||||||
let server = tokio::spawn(async move {
|
let server = tokio::spawn(async move {
|
||||||
if let Err(e) = axum::serve(listener, router).await {
|
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 serde::Serialize;
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use crate::app::RestartCounters;
|
||||||
|
use crate::health::HealthState;
|
||||||
use crate::store::NoteStore;
|
use crate::store::NoteStore;
|
||||||
use crate::vault::{Note, frontmatter_to_json};
|
use crate::vault::{Note, frontmatter_to_json};
|
||||||
|
|
||||||
|
|
@ -38,6 +42,9 @@ fn rfc3339_z(dt: DateTime<Utc>) -> String {
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct HttpState {
|
pub struct HttpState {
|
||||||
pub store: Arc<NoteStore>,
|
pub store: Arc<NoteStore>,
|
||||||
|
pub health: Arc<HealthState>,
|
||||||
|
pub restart_counters: Arc<RestartCounters>,
|
||||||
|
pub vault: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn router(state: HttpState) -> Router {
|
pub fn router(state: HttpState) -> Router {
|
||||||
|
|
@ -49,6 +56,7 @@ pub fn router(state: HttpState) -> Router {
|
||||||
.route("/notes/", get(list_root_folder))
|
.route("/notes/", get(list_root_folder))
|
||||||
.route("/notes/{*path}", get(get_notes))
|
.route("/notes/{*path}", get(get_notes))
|
||||||
.route("/query", get(get_query))
|
.route("/query", get(get_query))
|
||||||
|
.route("/health", get(get_health))
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -101,6 +109,42 @@ async fn list_root_folder(State(state): State<HttpState>) -> Response {
|
||||||
list_folder(&state, "").into_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 {
|
async fn get_query(State(state): State<HttpState>, OriginalUri(uri): OriginalUri) -> Response {
|
||||||
let raw = uri.query().unwrap_or("");
|
let raw = uri.query().unwrap_or("");
|
||||||
let parsed = match crate::query::parse(raw) {
|
let parsed = match crate::query::parse(raw) {
|
||||||
|
|
@ -377,6 +421,9 @@ mod tests {
|
||||||
store.replace(notes);
|
store.replace(notes);
|
||||||
let r = router(HttpState {
|
let r = router(HttpState {
|
||||||
store: store.clone(),
|
store: store.clone(),
|
||||||
|
health: crate::health::HealthState::new(),
|
||||||
|
restart_counters: crate::app::RestartCounters::new(),
|
||||||
|
vault: PathBuf::from("/test/vault"),
|
||||||
});
|
});
|
||||||
(r, store)
|
(r, store)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
//! supervisor tree, and the HTTP surface as those issues land.
|
//! supervisor tree, and the HTTP surface as those issues land.
|
||||||
|
|
||||||
pub mod app;
|
pub mod app;
|
||||||
|
pub mod health;
|
||||||
pub mod http;
|
pub mod http;
|
||||||
pub mod index;
|
pub mod index;
|
||||||
pub mod query;
|
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 serde_json::Value as JsonValue;
|
||||||
|
|
||||||
use crate::store::NoteStore;
|
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)]
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
pub enum QueryError {
|
pub enum QueryError {
|
||||||
|
|
@ -127,6 +127,21 @@ pub fn parse(raw: &str) -> Result<ParsedQuery, QueryError> {
|
||||||
}
|
}
|
||||||
|
|
||||||
let (field, op) = split_operator(&key)?;
|
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 {
|
q.predicates.push(Predicate {
|
||||||
field: field.to_string(),
|
field: field.to_string(),
|
||||||
op,
|
op,
|
||||||
|
|
@ -238,13 +253,17 @@ pub struct QueryResponse {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run a parsed query against the in-memory note set.
|
/// 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
|
pub fn execute<F>(store: &NoteStore, query: &ParsedQuery, format_ts: F) -> QueryResponse
|
||||||
where
|
where
|
||||||
F: Fn(chrono::DateTime<chrono::Utc>) -> String,
|
F: Fn(chrono::DateTime<chrono::Utc>) -> String,
|
||||||
{
|
{
|
||||||
let prefix = query.path_prefix.as_deref().unwrap_or("");
|
let prefix = query.path_prefix.as_deref().unwrap_or("");
|
||||||
let matches = store.with_read(|notes| {
|
let matches = store.with_read(|notes| {
|
||||||
let mut out = Vec::new();
|
let mut out: Vec<ResultEntry> = Vec::new();
|
||||||
for (path, note) in notes {
|
for (path, note) in notes {
|
||||||
if !path_matches(path, prefix, query.recursive) {
|
if !path_matches(path, prefix, query.recursive) {
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -252,27 +271,23 @@ where
|
||||||
if !predicates_match(&query.predicates, ¬e.frontmatter) {
|
if !predicates_match(&query.predicates, ¬e.frontmatter) {
|
||||||
continue;
|
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
|
out
|
||||||
});
|
});
|
||||||
let total = matches.len();
|
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),
|
Some(limit) if matches.len() > limit => (matches.into_iter().take(limit).collect(), true),
|
||||||
_ => (matches, false),
|
_ => (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 {
|
QueryResponse {
|
||||||
results: entries,
|
results,
|
||||||
total,
|
total,
|
||||||
truncated,
|
truncated,
|
||||||
}
|
}
|
||||||
|
|
@ -282,15 +297,18 @@ fn path_matches(path: &str, prefix: &str, recursive: bool) -> bool {
|
||||||
if prefix.is_empty() {
|
if prefix.is_empty() {
|
||||||
return recursive || !path.contains('/');
|
return recursive || !path.contains('/');
|
||||||
}
|
}
|
||||||
let scoped = match path.strip_prefix(prefix) {
|
// Anchor the prefix on a segment boundary so `__anw-path=Projects`
|
||||||
Some(rest) => rest.strip_prefix('/').unwrap_or(rest),
|
// doesn't match `Projects-old/x.md`. Fix-pattern matches the folder
|
||||||
None => return false,
|
// listing in ANW-14 (sie's ANW-15 review #3).
|
||||||
};
|
let scoped = if path == prefix {
|
||||||
if scoped.is_empty() {
|
|
||||||
// The prefix itself names a note; consider it in-scope.
|
// The prefix itself names a note; consider it in-scope.
|
||||||
return true;
|
""
|
||||||
}
|
} else if let Some(rest) = path.strip_prefix(&format!("{prefix}/")) {
|
||||||
if recursive {
|
rest
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if recursive || scoped.is_empty() {
|
||||||
true
|
true
|
||||||
} else {
|
} else {
|
||||||
!scoped.contains('/')
|
!scoped.contains('/')
|
||||||
|
|
@ -475,6 +493,8 @@ mod tests {
|
||||||
use chrono::{TimeZone, Utc};
|
use chrono::{TimeZone, Utc};
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use crate::vault::Note;
|
||||||
|
|
||||||
fn note(path: &str, fm: Frontmatter) -> Note {
|
fn note(path: &str, fm: Frontmatter) -> Note {
|
||||||
Note {
|
Note {
|
||||||
path: path.into(),
|
path: path.into(),
|
||||||
|
|
@ -679,6 +699,34 @@ mod tests {
|
||||||
assert_eq!(r.results[0].path, "Projects/anwesen/x.md");
|
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]
|
#[test]
|
||||||
fn control_param_recursive_false_limits_to_direct_children() {
|
fn control_param_recursive_false_limits_to_direct_children() {
|
||||||
let q = parse("__anw-path=Projects&__anw-recursive=false").unwrap();
|
let q = parse("__anw-path=Projects&__anw-recursive=false").unwrap();
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@
|
||||||
|
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::Arc;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use hydra::{Dest, GenServer};
|
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::{INDEX_WRITER_NAME, IndexBatch, IndexWriterMessage, IndexWriterState};
|
||||||
use crate::app::{VAULT_SCANNER_NAME, VaultScanner, VaultScannerMessage};
|
use crate::app::{VAULT_SCANNER_NAME, VaultScanner, VaultScannerMessage};
|
||||||
|
use crate::health::HealthState;
|
||||||
use crate::vault;
|
use crate::vault;
|
||||||
|
|
||||||
/// One path-scoped action derived from a native filesystem event. Always
|
/// 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>>,
|
mut rx: UnboundedReceiver<notify::Result<Event>>,
|
||||||
vault_root: PathBuf,
|
vault_root: PathBuf,
|
||||||
window: Duration,
|
window: Duration,
|
||||||
|
health: Arc<HealthState>,
|
||||||
) {
|
) {
|
||||||
loop {
|
loop {
|
||||||
let Some(first) = rx.recv().await else {
|
let Some(first) = rx.recv().await else {
|
||||||
break;
|
break;
|
||||||
};
|
};
|
||||||
|
health.record_event(chrono::Utc::now());
|
||||||
let mut events = vec![first];
|
let mut events = vec![first];
|
||||||
let deadline = Instant::now() + window;
|
let deadline = Instant::now() + window;
|
||||||
loop {
|
loop {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue