Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .planning
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added β€” Dispatch Observability (Phase 177)

- **`moon_dispatch_path_total{path=...}` Prometheus counter**: four-way classification of every command by shard-routing decision β€” `local_inline` (SIMD fast path), `local` (standard local branch), `cross_read_fast` (RwLock shared-read bypass of SPSC), `cross_spsc` (deferred cross-shard write via `PipelineBatchSlotted`). Ratio `cross_spsc / Ξ£` is the ground-truth signal for dispatch-layer optimization work. Zero-allocation hot-path overhead (`&'static str` labels, `#[inline]` with early-return on `!METRICS_INITIALIZED`). Verified on macOS + Linux: counter sums close exactly to driven traffic, no overcount.

### Fixed β€” CI Hygiene

- **`tests/pipeline_auto_index.rs`**: tighten outer cfg from `runtime-tokio` to `all(runtime-tokio, text-index)` so the file compiles to zero tests when text-index is disabled. Previously the file compiled but the FT.SEARCH text fast path was `#[cfg]`-ed out, causing `@name:corpus` queries to fall through to the KNN-only parser and panic with "invalid KNN query syntax".
- **4 FT unwraps**: add inline `#[allow(clippy::unwrap_used)]` with invariant justifications in `vector_search/ft_text_search.rs` (3 sites inside `apply_post_processing` where `do_summarize` / `do_highlight` implies the Option is Some) and `handler_monoio/ft.rs:165` (`is_text` was derived from `query_bytes.as_ref().map_or(false, _)`). Restores the audit-unwrap baseline to 0.

### Changed

- **`text-index` is now a default feature.** BM25 full-text search (`FT.SEARCH` BM25 mode), `FT.AGGREGATE`, and three-way RRF hybrid fusion are included in all standard builds. No longer requires `--features text-index`. To exclude it (e.g. minimal embedded builds): `--no-default-features --features runtime-monoio,jemalloc,graph`.
Expand Down
76 changes: 76 additions & 0 deletions src/admin/metrics_setup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -490,6 +490,56 @@ pub fn record_spsc_drain(shard_id: usize, count: u64) {
histogram!("moon_spsc_drain_batch_size", "shard" => shard).record(count as f64);
}

// ── Dispatch routing counters (Phase 177, Step 6) ───────────────────────
// Three-way split of the connection hot path so we can quantify what
// fraction of traffic is hitting the expensive cross-shard SPSC dispatch
// vs. the free local / shared-read fast paths. Ratio of these counters
// is the ground-truth signal for validating dispatch-layer optimizations
// (HotShardMessage split, outbox batching, waker relay fusion).

/// Command executed on the connection's own shard (no cross-thread hop).
#[inline]
pub fn record_dispatch_local() {
if !METRICS_INITIALIZED.load(Ordering::Relaxed) {
return;
}
counter!("moon_dispatch_path_total", "path" => "local").increment(1);
}

/// Command executed on a remote shard via the shared-read fast path
/// (RwLock read on the target shard's database, no SPSC message).
#[inline]
pub fn record_dispatch_cross_read_fastpath() {
if !METRICS_INITIALIZED.load(Ordering::Relaxed) {
return;
}
counter!("moon_dispatch_path_total", "path" => "cross_read_fast").increment(1);
}

/// Command deferred to cross-shard SPSC dispatch (the slow path).
/// Recorded when a command is enqueued into a `remote_groups` bucket that
/// will be flushed as a `PipelineBatchSlotted` message.
#[inline]
pub fn record_dispatch_cross_spsc() {
if !METRICS_INITIALIZED.load(Ordering::Relaxed) {
return;
}
counter!("moon_dispatch_path_total", "path" => "cross_spsc").increment(1);
}

/// Command handled by the inline GET/SET fast path
/// (`try_inline_dispatch_loop` in `server/conn/blocking.rs`) β€” the hottest
/// local branch, which bypasses the standard frame-by-frame routing and
/// therefore the three counters above. Recorded in a single batch increment
/// per dispatch loop to keep the call site out of the per-command hot path.
#[inline]
pub fn record_dispatch_local_inline(count: u64) {
if count == 0 || !METRICS_INITIALIZED.load(Ordering::Relaxed) {
return;
}
counter!("moon_dispatch_path_total", "path" => "local_inline").increment(count);
}

// ── Vector search metrics (v0.1.6) ─────────────────────────────────────

/// Record a cache hit for FT.CACHESEARCH.
Expand Down Expand Up @@ -859,3 +909,29 @@ pub fn spawn_metrics_publisher() {
}
});
}

#[cfg(test)]
mod tests {
use super::*;

// Smoke tests for the dispatch-path counters added in Phase 177, Step 6.
// A full assertion on the prometheus state would require initialising a
// recorder and a scraping harness β€” out of scope here. These tests just
// pin the contract that the helpers are safe to call on the hot path
// before `init_metrics` has run and therefore never panic or allocate
// unexpectedly when the exporter is disabled (admin_port = 0).

#[test]
fn dispatch_path_counters_no_op_before_init() {
// METRICS_INITIALIZED starts false; all three helpers must early-return.
// We just assert they do not panic. The absence of a global recorder
// means counter!() would otherwise be a no-op, but the guard is what
// we actually care about: no string allocation, no label churn.
assert!(!METRICS_INITIALIZED.load(Ordering::Relaxed));
record_dispatch_local();
record_dispatch_cross_read_fastpath();
record_dispatch_cross_spsc();
record_dispatch_local_inline(0); // count == 0 must short-circuit even when init
record_dispatch_local_inline(7);
}
}
10 changes: 5 additions & 5 deletions src/command/graph/graph_read.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,11 +73,11 @@ fn json_to_graph_value(v: &serde_json::Value) -> cypher::executor::Value {
serde_json::Value::Array(arr) => {
cypher::executor::Value::List(arr.iter().map(json_to_graph_value).collect())
}
serde_json::Value::Object(map) => {
cypher::executor::Value::Map(
map.iter().map(|(k, v)| (k.clone(), json_to_graph_value(v))).collect()
)
}
serde_json::Value::Object(map) => cypher::executor::Value::Map(
map.iter()
.map(|(k, v)| (k.clone(), json_to_graph_value(v)))
.collect(),
),
}
}

Expand Down
7 changes: 7 additions & 0 deletions src/command/vector_search/ft_text_search.rs
Original file line number Diff line number Diff line change
Expand Up @@ -758,6 +758,8 @@ pub fn apply_post_processing(

// SUMMARIZE first (extracts passage), then HIGHLIGHT (wraps terms).
let processed = if do_summarize {
// Safe: `do_summarize = summarize_opts.map_or(false, ..)` so do_summarize=true β‡’ Some.
#[allow(clippy::unwrap_used)]
let opts = summarize_opts.unwrap();
let summarized = summarize_field(
original_text,
Expand All @@ -768,6 +770,8 @@ pub fn apply_post_processing(
&opts.separator,
);
if do_highlight {
// Safe: `do_highlight = highlight_opts.map_or(false, ..)` so do_highlight=true β‡’ Some.
#[allow(clippy::unwrap_used)]
let hopts = highlight_opts.unwrap();
highlight_field(
&summarized,
Expand All @@ -781,6 +785,9 @@ pub fn apply_post_processing(
summarized
}
} else {
// Safe: reached only when do_summarize=false but the `!do_highlight && !do_summarize`
// guard above ensured at least one was true, so do_highlight=true β‡’ Some.
#[allow(clippy::unwrap_used)]
let hopts = highlight_opts.unwrap();
highlight_field(
original_text,
Expand Down
36 changes: 29 additions & 7 deletions src/persistence/wal_v3/segment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -131,38 +131,60 @@ impl WalWriterV3 {
lsn
}

/// Flush the in-memory buffer to disk and fsync.
/// Write the in-memory buffer to the OS page cache (no fsync).
///
/// After this returns, all appended records are durable on stable storage.
pub fn flush_sync(&mut self) -> std::io::Result<()> {
/// Data reaches the kernel but is NOT guaranteed durable until
/// `sync_data()` is called (typically on the 1s timer or shutdown).
fn flush_write(&mut self) -> std::io::Result<()> {
if self.buf.is_empty() {
return Ok(());
}

// Check if rotation is needed before writing
if self.write_offset + self.buf.len() as u64 > self.segment_size {
self.rotate_segment()?;
}

if let Some(ref mut file) = self.current_file {
file.write_all(&self.buf)?;
file.sync_data()?;
self.write_offset += self.buf.len() as u64;
self.buf.clear();
}

Ok(())
}

/// Flush if buffer exceeds a threshold (matches v2 pattern).
/// Flush the in-memory buffer to disk and fsync.
///
/// After this returns, all appended records are durable on stable storage.
pub fn flush_sync(&mut self) -> std::io::Result<()> {
self.flush_write()?;
if let Some(ref mut file) = self.current_file {
file.sync_data()?;
}
Ok(())
}

/// Flush if buffer exceeds a threshold β€” write only, no fsync.
///
/// Matches WAL v2 pattern: frequent writes to OS page cache,
/// durable sync deferred to the 1s timer (`sync_data`).
pub fn flush_if_needed(&mut self) -> std::io::Result<()> {
if self.buf.len() >= 4096 {
self.flush_sync()
self.flush_write()
} else {
Ok(())
}
}

/// Fsync without writing β€” call after `flush_write` / `flush_if_needed`
/// to make all previously written records durable.
pub fn sync_data(&mut self) -> std::io::Result<()> {
if let Some(ref mut file) = self.current_file {
file.sync_data()?;
}
Ok(())
}

/// Return the current (next-to-be-assigned) LSN.
#[inline]
pub fn current_lsn(&self) -> u64 {
Expand Down
Loading
Loading