feat: add multimodal search and granular performance diagnostics
Core CI / core (ubuntu-latest) (push) Failing after 3m31s
Core CI / core (macos-latest) (push) Canceled after 0s
Core CI / core (windows-latest) (push) Canceled after 0s
Core CI / desktop (ubuntu-latest) (push) Successful in 5m39s
Core CI / desktop (macos-latest) (push) Canceled after 0s
Core CI / desktop (windows-latest) (push) Canceled after 0s
Core CI / core (ubuntu-latest) (push) Failing after 3m31s
Core CI / core (macos-latest) (push) Canceled after 0s
Core CI / core (windows-latest) (push) Canceled after 0s
Core CI / desktop (ubuntu-latest) (push) Successful in 5m39s
Core CI / desktop (macos-latest) (push) Canceled after 0s
Core CI / desktop (windows-latest) (push) Canceled after 0s
This commit is contained in:
1 parent
d3bb8c7618
commit
b8400c4fbf
110 files changed
+79395
-250
No files matched your search
@@ -0,0 +1,80 @@
|
||||
//! Small prepared SQLite instrumentation diagnostic; not a production benchmark.
|
||||
use anyhow::{ensure, Result};
|
||||
use searchi_core::SearchIndex;
|
||||
use std::{fs, path::Path, time::Instant};
|
||||
|
||||
fn distribution(samples: &[f64]) -> serde_json::Value {
|
||||
let mut sorted = samples.to_vec();
|
||||
sorted.sort_by(f64::total_cmp);
|
||||
let percentile = |fraction: f64| {
|
||||
sorted[((sorted.len() as f64 * fraction).ceil() as usize).saturating_sub(1)]
|
||||
};
|
||||
serde_json::json!({"samples":sorted.len(),"p50_ms":percentile(0.50),"p95_ms":percentile(0.95),"mean_ms":sorted.iter().sum::<f64>()/sorted.len() as f64})
|
||||
}
|
||||
fn main() -> Result<()> {
|
||||
let fixture = tempfile::tempdir()?;
|
||||
let root = fixture.path().join("files");
|
||||
fs::create_dir(&root)?;
|
||||
for index in 0..1000 {
|
||||
fs::write(
|
||||
root.join(format!("record-{index:04}.txt")),
|
||||
format!(
|
||||
"Supplier agreement {index}. Cancellation needs written notice. Group {}.",
|
||||
index % 31
|
||||
),
|
||||
)?;
|
||||
}
|
||||
let mut index = SearchIndex::open(fixture.path().join("index.sqlite"))?;
|
||||
let report = index.index_folder(&root)?;
|
||||
index.prewarm()?;
|
||||
ensure!(report.indexed == 1000, "Unexpected fixture indexing count");
|
||||
let queries: Vec<String> = (0..24)
|
||||
.map(|index| format!("record-{:04}.txt", index * 37))
|
||||
.collect();
|
||||
for query in &queries {
|
||||
index.search_measured(query, 10, false)?;
|
||||
index.search_measured(query, 10, true)?;
|
||||
}
|
||||
let mut basic = Vec::new();
|
||||
let mut debug = Vec::new();
|
||||
let mut trace_spans = Vec::new();
|
||||
// Alternate order to reduce monotonic cache/CPU-order effects.
|
||||
for repetition in 0..20 {
|
||||
for query in &queries {
|
||||
for detailed in if repetition % 2 == 0 {
|
||||
[false, true]
|
||||
} else {
|
||||
[true, false]
|
||||
} {
|
||||
let started = Instant::now();
|
||||
let response = index.search_measured(query, 10, detailed)?;
|
||||
let elapsed = started.elapsed().as_secs_f64() * 1000.0;
|
||||
ensure!(
|
||||
response
|
||||
.results
|
||||
.first()
|
||||
.is_some_and(|result| &result.filename == query),
|
||||
"Fixture retrieval changed"
|
||||
);
|
||||
if detailed {
|
||||
trace_spans.push(response.metrics.trace.as_ref().unwrap().len());
|
||||
debug.push(elapsed);
|
||||
} else {
|
||||
ensure!(
|
||||
response.metrics.trace.is_none(),
|
||||
"Basic unexpectedly stored traces"
|
||||
);
|
||||
basic.push(elapsed);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let output = serde_json::json!({"kind":"prepared_sqlite_trace_overhead","production_claim":false,"profile":if cfg!(debug_assertions){"debug"}else{"release"},"os":std::env::consts::OS,"arch":std::env::consts::ARCH,"files":1000,"queries":24,"repetitions":20,"measurement":"outer Rust call including metrics assembly; excludes fixture setup/JSON serialization","basic":distribution(&basic),"debug":distribution(&debug),"basic_samples_ms":basic,"debug_samples_ms":debug,"debug_span_count_min":trace_spans.iter().min(),"debug_span_count_max":trace_spans.iter().max(),"limits":"tiny warm lexical corpus; harness serial/no model/media/GPU; other VM work including native photo inference may contend; not isolated production performance"});
|
||||
let serialized = serde_json::to_string_pretty(&output)?;
|
||||
if let Some(path) = std::env::args_os().nth(1) {
|
||||
fs::write(Path::new(&path), serialized)?;
|
||||
} else {
|
||||
println!("{serialized}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -4,7 +4,10 @@
|
||||
//! Text and document extraction have byte limits, but still run in this process.
|
||||
//! Worker isolation remains a production milestone.
|
||||
mod extraction;
|
||||
pub mod metrics;
|
||||
use anyhow::{bail, Context, Result};
|
||||
pub use metrics::{MetricsMode, SearchMetrics, SearchResponse, SearchSpan};
|
||||
use metrics::{SpanToken, TraceCollector};
|
||||
use rusqlite::{params, params_from_iter, types::Value, Connection, OptionalExtension};
|
||||
use serde::Serialize;
|
||||
use std::{
|
||||
@@ -16,7 +19,7 @@ use std::{
|
||||
};
|
||||
use walkdir::WalkDir;
|
||||
|
||||
const SCHEMA_VERSION: i64 = 9;
|
||||
const SCHEMA_VERSION: i64 = 10;
|
||||
const MAX_RESULTS: usize = 1000;
|
||||
|
||||
#[derive(Debug, Default, Serialize)]
|
||||
@@ -53,6 +56,8 @@ pub struct SearchOptions {
|
||||
pub max_size: Option<u64>,
|
||||
pub modified_after_unix_ms: Option<i64>,
|
||||
pub modified_before_unix_ms: Option<i64>,
|
||||
pub min_duration_ms: Option<u32>,
|
||||
pub max_duration_ms: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -66,7 +71,8 @@ pub struct SearchResult {
|
||||
pub extraction_error: Option<String>,
|
||||
}
|
||||
|
||||
/// A verified matching passage location; numbers are one based.
|
||||
/// Verified source location. Document numbers are one based; timestamp_ms
|
||||
/// numbers are milliseconds from media start, and image numbers are zero.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct SourceLocation {
|
||||
pub kind: String,
|
||||
@@ -227,6 +233,15 @@ impl SearchIndex {
|
||||
UPDATE name_statistics_state SET dirty=1 WHERE singleton=1; END;
|
||||
PRAGMA user_version=9;",
|
||||
)?;
|
||||
initialization.execute_batch("CREATE INDEX IF NOT EXISTS files_extension ON files(extension,id);
|
||||
CREATE TABLE IF NOT EXISTS media_metadata(path TEXT PRIMARY KEY, file_id INTEGER NOT NULL UNIQUE, tags TEXT NOT NULL,duration_ms INTEGER NOT NULL);
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS media_tags USING fts5(tags,content='media_metadata',content_rowid='file_id');
|
||||
CREATE TRIGGER IF NOT EXISTS media_metadata_insert AFTER INSERT ON media_metadata BEGIN INSERT INTO media_tags(rowid,tags) VALUES(new.file_id,new.tags); END;
|
||||
CREATE TRIGGER IF NOT EXISTS media_metadata_delete AFTER DELETE ON media_metadata BEGIN INSERT INTO media_tags(media_tags,rowid,tags) VALUES('delete',old.file_id,old.tags); END;
|
||||
CREATE TRIGGER IF NOT EXISTS media_metadata_update AFTER UPDATE ON media_metadata BEGIN INSERT INTO media_tags(media_tags,rowid,tags) VALUES('delete',old.file_id,old.tags); INSERT INTO media_tags(rowid,tags) VALUES(new.file_id,new.tags); END;
|
||||
CREATE TRIGGER IF NOT EXISTS files_media_delete AFTER DELETE ON files BEGIN DELETE FROM media_metadata WHERE path=old.path; END;
|
||||
CREATE TRIGGER IF NOT EXISTS files_media_update AFTER UPDATE ON files WHEN new.path!=old.path OR new.size!=old.size OR new.mtime!=old.mtime BEGIN DELETE FROM media_metadata WHERE path=old.path; END;
|
||||
PRAGMA user_version=10;")?;
|
||||
initialization.commit()?;
|
||||
}
|
||||
Ok(Self {
|
||||
@@ -236,6 +251,32 @@ impl SearchIndex {
|
||||
})
|
||||
}
|
||||
|
||||
/// Persist genuine extracted media tags, guarded against filesystem changes.
|
||||
pub fn set_media_metadata(
|
||||
&mut self,
|
||||
path: &str,
|
||||
tags: &str,
|
||||
duration_ms: u32,
|
||||
size: u64,
|
||||
mtime_ns: i64,
|
||||
) -> Result<()> {
|
||||
if tags.len() > 32768 {
|
||||
bail!("Media tags exceed 32 KiB bound");
|
||||
}
|
||||
let tx = self.connection.transaction()?;
|
||||
let id: Option<i64> = tx
|
||||
.query_row(
|
||||
"SELECT id FROM files WHERE path=?1 AND size=?2 AND mtime=?3",
|
||||
params![path, i64::try_from(size)?, mtime_ns],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()?;
|
||||
let id = id.context("Media metadata file changed since indexing")?;
|
||||
tx.execute("INSERT INTO media_metadata(path,file_id,tags,duration_ms) VALUES(?1,?2,?3,?4) ON CONFLICT(path) DO UPDATE SET tags=excluded.tags,duration_ms=excluded.duration_ms",params![path,id,tags,duration_ms])?;
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Refresh candidate-frequency statistics off the foreground search path.
|
||||
/// Stale/missing estimates can affect speed, never literal-match correctness.
|
||||
/// The app invokes this during background startup and after reconciliation.
|
||||
@@ -465,6 +506,56 @@ impl SearchIndex {
|
||||
limit: usize,
|
||||
options: &SearchOptions,
|
||||
) -> Result<Vec<SearchResult>> {
|
||||
let mut trace = TraceCollector::new(MetricsMode::Basic);
|
||||
self.search_with_options_traced(query, limit, options, &mut trace, None)
|
||||
}
|
||||
pub fn search_measured(
|
||||
&self,
|
||||
query: &str,
|
||||
limit: usize,
|
||||
debug: bool,
|
||||
) -> Result<SearchResponse<SearchResult>> {
|
||||
self.search_with_options_measured(query, limit, &SearchOptions::default(), debug)
|
||||
}
|
||||
pub fn search_with_options_measured(
|
||||
&self,
|
||||
query: &str,
|
||||
limit: usize,
|
||||
options: &SearchOptions,
|
||||
debug: bool,
|
||||
) -> Result<SearchResponse<SearchResult>> {
|
||||
let mut trace = TraceCollector::new(if debug {
|
||||
MetricsMode::Debug
|
||||
} else {
|
||||
MetricsMode::Basic
|
||||
});
|
||||
let results = self.search_with_options_traced(query, limit, options, &mut trace, None)?;
|
||||
let metrics = trace.finish(results.len());
|
||||
Ok(SearchResponse { results, metrics })
|
||||
}
|
||||
/// Share one monotonic request collector with downstream retrieval lanes.
|
||||
pub fn search_with_options_traced(
|
||||
&self,
|
||||
query: &str,
|
||||
limit: usize,
|
||||
options: &SearchOptions,
|
||||
trace: &mut TraceCollector,
|
||||
parent: Option<SpanToken>,
|
||||
) -> Result<Vec<SearchResult>> {
|
||||
trace.timed("lexical.search", parent, |trace, parent| {
|
||||
self.search_inner(query, limit, options, trace, parent)
|
||||
})
|
||||
}
|
||||
fn search_inner(
|
||||
&self,
|
||||
query: &str,
|
||||
limit: usize,
|
||||
options: &SearchOptions,
|
||||
trace: &mut TraceCollector,
|
||||
parent: Option<SpanToken>,
|
||||
) -> Result<Vec<SearchResult>> {
|
||||
let normalization = trace.begin("lexical.normalization", parent);
|
||||
|
||||
if options
|
||||
.min_size
|
||||
.zip(options.max_size)
|
||||
@@ -479,6 +570,13 @@ impl SearchIndex {
|
||||
{
|
||||
bail!("modified-after exceeds modified-before");
|
||||
}
|
||||
if options
|
||||
.min_duration_ms
|
||||
.zip(options.max_duration_ms)
|
||||
.is_some_and(|(a, b)| a > b)
|
||||
{
|
||||
bail!("Minimum duration exceeds maximum duration");
|
||||
}
|
||||
let folder = options
|
||||
.folder
|
||||
.as_ref()
|
||||
@@ -518,11 +616,16 @@ impl SearchIndex {
|
||||
.map(|term| format!("\"{}\"", term.replace('"', "\"\"")))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" AND ");
|
||||
trace.counter(normalization, "query_bytes", query.len() as u64);
|
||||
trace.end(normalization, Default::default());
|
||||
let planning = trace.begin("lexical.candidate_plan", parent);
|
||||
let limit = limit.min(MAX_RESULTS);
|
||||
let filters = " AND (?3 IS NULL OR substr(f.path,1,length(?3))=?3)
|
||||
AND (?4 IS NULL OR f.extension=?4)
|
||||
AND (?5 IS NULL OR f.size>=?5) AND (?6 IS NULL OR f.size<=?6)
|
||||
AND (?7 IS NULL OR f.mtime/1000000>=?7) AND (?8 IS NULL OR f.mtime/1000000<=?8)";
|
||||
AND (?7 IS NULL OR f.mtime/1000000>=?7) AND (?8 IS NULL OR f.mtime/1000000<=?8)
|
||||
AND (?10 IS NULL OR EXISTS(SELECT 1 FROM media_metadata m WHERE m.file_id=f.id AND m.duration_ms>=?10))
|
||||
AND (?11 IS NULL OR EXISTS(SELECT 1 FROM media_metadata m WHERE m.file_id=f.id AND m.duration_ms<=?11))";
|
||||
let filter_values = [
|
||||
folder_prefix.map_or(Value::Null, Value::Text),
|
||||
extension.map_or(Value::Null, Value::Text),
|
||||
@@ -535,7 +638,9 @@ impl SearchIndex {
|
||||
.modified_before_unix_ms
|
||||
.map_or(Value::Null, Value::Integer),
|
||||
];
|
||||
let has_filters = filter_values.iter().any(|value| *value != Value::Null);
|
||||
let has_filters = filter_values.iter().any(|value| *value != Value::Null)
|
||||
|| options.min_duration_ms.is_some()
|
||||
|| options.max_duration_ms.is_some();
|
||||
let mut results = Vec::new();
|
||||
let mut selected = Vec::<i64>::new();
|
||||
// Each lane filters its entire candidate set before LIMIT. There is no
|
||||
@@ -561,13 +666,17 @@ impl SearchIndex {
|
||||
if cache.len() + grams.len() > 4096 {
|
||||
cache.clear();
|
||||
}
|
||||
let mut frequency_hits = 0u64;
|
||||
let mut frequency_misses = 0u64;
|
||||
let mut rare_first = grams
|
||||
.into_iter()
|
||||
.map(|gram| {
|
||||
let term = gram.to_lowercase();
|
||||
let frequency = if let Some(frequency) = cache.get(&term) {
|
||||
frequency_hits += 1;
|
||||
*frequency
|
||||
} else {
|
||||
frequency_misses += 1;
|
||||
let frequency = vocabulary
|
||||
.query_row([&term], |row| row.get::<_, i64>(0))
|
||||
.optional()?
|
||||
@@ -581,6 +690,9 @@ impl SearchIndex {
|
||||
rare_first.sort();
|
||||
drop(cache);
|
||||
let anchor = substring_anchor(&characters, &rare_first);
|
||||
trace.counter(planning, "frequency_cache_hits", frequency_hits);
|
||||
trace.counter(planning, "frequency_cache_misses", frequency_misses);
|
||||
trace.counter(planning, "trigram_index", u64::from(trigram));
|
||||
let grams = format!("\"{}\"", anchor.replace('"', "\"\""));
|
||||
let mut lanes = vec![
|
||||
(
|
||||
@@ -648,10 +760,30 @@ impl SearchIndex {
|
||||
"rank,f.id",
|
||||
));
|
||||
}
|
||||
lanes.push((
|
||||
"media_metadata",
|
||||
"media_tags CROSS JOIN files f ON f.id=media_tags.rowid".into(),
|
||||
"media_tags MATCH ?1".into(),
|
||||
fts.clone(),
|
||||
"rank,f.id",
|
||||
));
|
||||
trace.end(planning, Default::default());
|
||||
for (kind, from, condition, expression, ordering) in lanes {
|
||||
if results.len() == limit {
|
||||
break;
|
||||
}
|
||||
let lane = trace.begin(
|
||||
match kind {
|
||||
"exact_filename" => "lexical.exact_filename",
|
||||
"filename" => "lexical.filename_substring",
|
||||
"path" => "lexical.path_substring",
|
||||
"name_terms" => "lexical.filename_terms",
|
||||
"content" => "lexical.content",
|
||||
"media_metadata" => "lexical.media_metadata",
|
||||
_ => "lexical.other",
|
||||
},
|
||||
parent,
|
||||
);
|
||||
let exclusions = if selected.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
@@ -664,12 +796,14 @@ impl SearchIndex {
|
||||
.join(",")
|
||||
)
|
||||
};
|
||||
let rank = if matches!(kind, "content" | "name_terms") {
|
||||
let rank = if matches!(kind, "content" | "name_terms" | "media_metadata") {
|
||||
"rank"
|
||||
} else {
|
||||
"0.0"
|
||||
};
|
||||
let snippet = if kind == "content" {
|
||||
let snippet = if kind == "media_metadata" {
|
||||
"snippet(media_tags,0,'','',' … ',24)"
|
||||
} else if kind == "content" {
|
||||
"snippet(content_search,0,'','',' … ',24)"
|
||||
} else if kind == "name_terms" {
|
||||
"snippet(search,2,'','',' … ',24)"
|
||||
@@ -698,6 +832,20 @@ impl SearchIndex {
|
||||
let mut values = vec![Value::Text(expression), Value::Text(query.into())];
|
||||
values.extend(filter_values.iter().cloned());
|
||||
values.push(Value::Integer((limit - results.len()) as i64));
|
||||
if sql.contains("?10") {
|
||||
values.push(
|
||||
options
|
||||
.min_duration_ms
|
||||
.map_or(Value::Null, |v| Value::Integer(i64::from(v))),
|
||||
);
|
||||
values.push(
|
||||
options
|
||||
.max_duration_ms
|
||||
.map_or(Value::Null, |v| Value::Integer(i64::from(v))),
|
||||
);
|
||||
}
|
||||
let sql_span = trace.begin("lexical.filtered_sql", lane);
|
||||
trace.counter(sql_span, "filters_active", u64::from(has_filters));
|
||||
let mut statement = self.connection.prepare(&sql)?;
|
||||
let matches = statement
|
||||
.query_map(params_from_iter(values), |row| {
|
||||
@@ -715,12 +863,16 @@ impl SearchIndex {
|
||||
))
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
trace.counter(sql_span, "rows", matches.len() as u64);
|
||||
trace.end(sql_span, Default::default());
|
||||
for (id, result) in matches {
|
||||
selected.push(id);
|
||||
results.push(result);
|
||||
}
|
||||
trace.end(lane, Default::default());
|
||||
}
|
||||
if !results.is_empty() {
|
||||
let source_span = trace.begin("lexical.source_locations", parent);
|
||||
// Ownership has a B-tree index. The rowid IN constraint intersects
|
||||
// the FTS postings with only the actual finalists' passage IDs.
|
||||
let placeholders = (0..results.len())
|
||||
@@ -762,6 +914,9 @@ impl SearchIndex {
|
||||
result.snippet = snippet.clone();
|
||||
}
|
||||
}
|
||||
trace.counter(source_span, "files", results.len() as u64);
|
||||
trace.counter(source_span, "matched_sources", sources.len() as u64);
|
||||
trace.end(source_span, Default::default());
|
||||
}
|
||||
Ok(results)
|
||||
}
|
||||
@@ -856,6 +1011,100 @@ fn substring_anchor(characters: &[char], frequencies: &[(i64, String)]) -> Strin
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn measured_search_preserves_results_and_contains_private_free_nested_spans(
|
||||
) -> anyhow::Result<()> {
|
||||
let directory = tempfile::tempdir()?;
|
||||
let root = directory.path().join("sensitive-private-location");
|
||||
std::fs::create_dir(&root)?;
|
||||
std::fs::write(
|
||||
root.join("agreement.txt"),
|
||||
"confidentialneedle cancellation policy",
|
||||
)?;
|
||||
let db = directory.path().join("index.db");
|
||||
let mut index = super::SearchIndex::open(&db)?;
|
||||
index.index_folder(&root)?;
|
||||
let basic = index.search_measured("confidentialneedle", 10, false)?;
|
||||
let debug = index.search_measured("confidentialneedle", 10, true)?;
|
||||
assert_eq!(
|
||||
serde_json::to_value(&basic.results)?,
|
||||
serde_json::to_value(&debug.results)?
|
||||
);
|
||||
assert_eq!(basic.metrics.result_count, 1);
|
||||
assert!(basic.metrics.trace.is_none());
|
||||
let spans = debug.metrics.trace.as_ref().unwrap();
|
||||
assert!(spans.iter().any(|s| s.name == "lexical.content"));
|
||||
assert!(spans.iter().any(|s| s.name == "lexical.source_locations"));
|
||||
for span in spans {
|
||||
assert!(span.start_ms >= 0.0 && span.duration_ms >= 0.0);
|
||||
assert!(span.start_ms + span.duration_ms <= debug.metrics.total_ms + 1e-6);
|
||||
if let Some(parent) = span.parent {
|
||||
let parent = &spans[parent];
|
||||
assert!(span.start_ms >= parent.start_ms);
|
||||
assert!(
|
||||
span.start_ms + span.duration_ms <= parent.start_ms + parent.duration_ms + 1e-6
|
||||
);
|
||||
}
|
||||
}
|
||||
let trace = serde_json::to_string(spans)?;
|
||||
assert!(!trace.contains("confidentialneedle"));
|
||||
assert!(!trace.contains("sensitive-private-location"));
|
||||
Ok(())
|
||||
}
|
||||
#[test]
|
||||
fn genuine_media_tags_search_duration_filter_and_lifecycle() -> anyhow::Result<()> {
|
||||
let directory = tempfile::tempdir()?;
|
||||
let root = directory.path().join("music");
|
||||
std::fs::create_dir(&root)?;
|
||||
let file = root.join("track.ogg");
|
||||
std::fs::write(&file, b"storage fixture")?;
|
||||
let db = directory.path().join("index.db");
|
||||
let mut index = super::SearchIndex::open(&db)?;
|
||||
index.index_folder(&root)?;
|
||||
let path = file.canonicalize()?.to_string_lossy().into_owned();
|
||||
let (size, mtime): (u64, i64) = index.connection.query_row(
|
||||
"SELECT size,mtime FROM files WHERE path=?1",
|
||||
[&path],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
)?;
|
||||
index.set_media_metadata(
|
||||
&path,
|
||||
"artist: Kimiko Ishizaka\ntitle: Variatio 13 a 2 Clav.",
|
||||
180000,
|
||||
size,
|
||||
mtime,
|
||||
)?;
|
||||
let results = index.search("Kimiko Ishizaka", 10)?;
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0].match_kind, "media_metadata");
|
||||
assert!(results[0].snippet.contains("Kimiko"));
|
||||
let too_short = super::SearchOptions {
|
||||
max_duration_ms: Some(100000),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(index
|
||||
.search_with_options("track.ogg", 10, &too_short)?
|
||||
.is_empty());
|
||||
let correct = super::SearchOptions {
|
||||
min_duration_ms: Some(170000),
|
||||
max_duration_ms: Some(190000),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(index.search_with_options("Kimiko", 10, &correct)?.len(), 1);
|
||||
drop(index);
|
||||
let mut index = super::SearchIndex::open(&db)?;
|
||||
assert_eq!(index.search("Variatio", 10)?.len(), 1);
|
||||
std::fs::write(&file, b"changed file bytes")?;
|
||||
index.index_folder(&root)?;
|
||||
assert!(index.search("Kimiko", 10)?.is_empty());
|
||||
assert!(index
|
||||
.set_media_metadata(&path, "stale", 180000, size, mtime)
|
||||
.is_err());
|
||||
std::fs::remove_file(&file)?;
|
||||
index.index_folder(&root)?;
|
||||
assert!(index.search("Variatio", 10)?.is_empty());
|
||||
Ok(())
|
||||
}
|
||||
use super::*;
|
||||
#[test]
|
||||
fn persistence_updates_deletes_and_exact_priority() -> Result<()> {
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
//! Monotonic request metrics. Stage names are static labels, never queries/paths.
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
sync::atomic::{AtomicU64, Ordering},
|
||||
time::Instant,
|
||||
};
|
||||
const MAX_SPANS: usize = 512;
|
||||
static COLLECTOR_ID: AtomicU64 = AtomicU64::new(1);
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SearchResponse<T> {
|
||||
pub results: Vec<T>,
|
||||
pub metrics: SearchMetrics,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SearchMetrics {
|
||||
pub total_ms: f64,
|
||||
pub result_count: usize,
|
||||
pub trace: Option<Vec<SearchSpan>>,
|
||||
#[serde(default)]
|
||||
pub dropped_spans: u64,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SearchSpan {
|
||||
pub name: String,
|
||||
pub start_ms: f64,
|
||||
pub duration_ms: f64,
|
||||
pub parent: Option<usize>,
|
||||
pub counters: BTreeMap<String, u64>,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum MetricsMode {
|
||||
Basic,
|
||||
Debug,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct SpanToken {
|
||||
collector: u64,
|
||||
index: usize,
|
||||
}
|
||||
impl SpanToken {
|
||||
pub fn index(self) -> usize {
|
||||
self.index
|
||||
}
|
||||
}
|
||||
struct OpenSpan {
|
||||
value: SearchSpan,
|
||||
started: Instant,
|
||||
ended: bool,
|
||||
}
|
||||
struct DebugTrace {
|
||||
id: u64,
|
||||
spans: Vec<OpenSpan>,
|
||||
dropped: u64,
|
||||
}
|
||||
/// Basic mode stores only one Instant: no span Vec or stage/counter allocation.
|
||||
pub struct TraceCollector {
|
||||
started: Instant,
|
||||
debug: Option<DebugTrace>,
|
||||
}
|
||||
impl TraceCollector {
|
||||
pub fn new(mode: MetricsMode) -> Self {
|
||||
Self {
|
||||
started: Instant::now(),
|
||||
debug: match mode {
|
||||
MetricsMode::Basic => None,
|
||||
MetricsMode::Debug => Some(DebugTrace {
|
||||
id: COLLECTOR_ID.fetch_add(1, Ordering::Relaxed),
|
||||
spans: Vec::new(),
|
||||
dropped: 0,
|
||||
}),
|
||||
},
|
||||
}
|
||||
}
|
||||
pub fn elapsed_ms(&self) -> f64 {
|
||||
self.started.elapsed().as_secs_f64() * 1000.0
|
||||
}
|
||||
pub fn is_debug(&self) -> bool {
|
||||
self.debug.is_some()
|
||||
}
|
||||
pub fn begin(&mut self, name: &'static str, parent: Option<SpanToken>) -> Option<SpanToken> {
|
||||
let debug = self.debug.as_mut()?;
|
||||
let parent_index = match parent {
|
||||
Some(token)
|
||||
if token.collector == debug.id
|
||||
&& debug.spans.get(token.index).is_some_and(|span| !span.ended) =>
|
||||
{
|
||||
Some(token.index)
|
||||
}
|
||||
Some(_) => {
|
||||
debug.dropped = debug.dropped.saturating_add(1);
|
||||
return None;
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
if debug.spans.len() >= MAX_SPANS {
|
||||
debug.dropped = debug.dropped.saturating_add(1);
|
||||
return None;
|
||||
}
|
||||
let started = Instant::now();
|
||||
let index = debug.spans.len();
|
||||
debug.spans.push(OpenSpan {
|
||||
value: SearchSpan {
|
||||
name: name.chars().take(96).collect(),
|
||||
start_ms: started.duration_since(self.started).as_secs_f64() * 1000.0,
|
||||
duration_ms: 0.0,
|
||||
parent: parent_index,
|
||||
counters: BTreeMap::new(),
|
||||
},
|
||||
started,
|
||||
ended: false,
|
||||
});
|
||||
Some(SpanToken {
|
||||
collector: debug.id,
|
||||
index,
|
||||
})
|
||||
}
|
||||
/// Invalid/previously ended tokens are harmless. Closing a parent also
|
||||
/// closes open descendants at the same instant, preserving containment.
|
||||
pub fn end(&mut self, token: Option<SpanToken>, counters: BTreeMap<String, u64>) {
|
||||
let Some(token) = token else {
|
||||
return;
|
||||
};
|
||||
let Some(debug) = self.debug.as_mut() else {
|
||||
return;
|
||||
};
|
||||
if token.collector != debug.id
|
||||
|| token.index >= debug.spans.len()
|
||||
|| debug.spans[token.index].ended
|
||||
{
|
||||
return;
|
||||
}
|
||||
let now = Instant::now();
|
||||
for index in token.index..debug.spans.len() {
|
||||
let mut ancestor = Some(index);
|
||||
let mut contained = false;
|
||||
while let Some(current) = ancestor {
|
||||
if current == token.index {
|
||||
contained = true;
|
||||
break;
|
||||
}
|
||||
ancestor = debug.spans[current].value.parent;
|
||||
}
|
||||
if contained && !debug.spans[index].ended {
|
||||
let span = &mut debug.spans[index];
|
||||
span.value.duration_ms = now.duration_since(span.started).as_secs_f64() * 1000.0;
|
||||
span.ended = true;
|
||||
}
|
||||
}
|
||||
// At most32 distinct bounded keys; callers should use static diagnostic
|
||||
// names and counts, never source text or paths.
|
||||
let stored = &mut debug.spans[token.index].value.counters;
|
||||
for (key, value) in counters.into_iter().take(32) {
|
||||
let key: String = key.chars().take(96).collect();
|
||||
if stored.len() < 32 || stored.contains_key(&key) {
|
||||
stored.insert(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn counter(&mut self, token: Option<SpanToken>, name: &'static str, value: u64) {
|
||||
let Some(token) = token else {
|
||||
return;
|
||||
};
|
||||
let Some(debug) = self.debug.as_mut() else {
|
||||
return;
|
||||
};
|
||||
if token.collector != debug.id {
|
||||
return;
|
||||
}
|
||||
let Some(span) = debug.spans.get_mut(token.index) else {
|
||||
return;
|
||||
};
|
||||
if span.value.counters.len() < 32 || span.value.counters.contains_key(name) {
|
||||
span.value
|
||||
.counters
|
||||
.insert(name.chars().take(96).collect(), value);
|
||||
}
|
||||
}
|
||||
/// Return the closure's value unchanged, including any Result error.
|
||||
/// The closure receives its parent token for safe nested instrumentation.
|
||||
pub fn timed<R>(
|
||||
&mut self,
|
||||
name: &'static str,
|
||||
parent: Option<SpanToken>,
|
||||
run: impl FnOnce(&mut Self, Option<SpanToken>) -> R,
|
||||
) -> R {
|
||||
let token = self.begin(name, parent);
|
||||
let value = run(self, token);
|
||||
self.end(token, BTreeMap::new());
|
||||
value
|
||||
}
|
||||
pub fn finish(mut self, result_count: usize) -> SearchMetrics {
|
||||
let now = Instant::now();
|
||||
if let Some(debug) = self.debug.as_mut() {
|
||||
for span in &mut debug.spans {
|
||||
if !span.ended {
|
||||
span.value.duration_ms =
|
||||
now.duration_since(span.started).as_secs_f64() * 1000.0;
|
||||
span.ended = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
let (trace, dropped_spans) = match self.debug {
|
||||
Some(debug) => (
|
||||
Some(debug.spans.into_iter().map(|span| span.value).collect()),
|
||||
debug.dropped,
|
||||
),
|
||||
None => (None, 0),
|
||||
};
|
||||
SearchMetrics {
|
||||
total_ms: now.duration_since(self.started).as_secs_f64() * 1000.0,
|
||||
result_count,
|
||||
trace,
|
||||
dropped_spans,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn nesting_contains_children_and_preserves_error() {
|
||||
let mut trace = TraceCollector::new(MetricsMode::Debug);
|
||||
let result: Result<(), &str> = trace.timed("request", None, |trace, parent| {
|
||||
trace.timed("index", parent, |trace, child| {
|
||||
trace.counter(child, "rows", 7);
|
||||
Err("failure")
|
||||
})
|
||||
});
|
||||
assert_eq!(result, Err("failure"));
|
||||
let metrics = trace.finish(0);
|
||||
let spans = metrics.trace.unwrap();
|
||||
assert_eq!(spans[1].parent, Some(0));
|
||||
assert_eq!(spans[1].counters["rows"], 7);
|
||||
assert!(spans[1].start_ms >= spans[0].start_ms);
|
||||
assert!(
|
||||
spans[1].start_ms + spans[1].duration_ms
|
||||
<= spans[0].start_ms + spans[0].duration_ms + 1e-6
|
||||
);
|
||||
assert!(metrics.total_ms >= spans[0].start_ms + spans[0].duration_ms - 1e-6);
|
||||
}
|
||||
#[test]
|
||||
fn basic_disables_stage_storage_and_preserves_counts() {
|
||||
let mut trace = TraceCollector::new(MetricsMode::Basic);
|
||||
assert!(!trace.is_debug());
|
||||
assert!(trace.begin("unused", None).is_none());
|
||||
assert_eq!(trace.timed("unused", None, |_, _| 42), 42);
|
||||
let metrics = trace.finish(3);
|
||||
assert_eq!(metrics.result_count, 3);
|
||||
assert!(metrics.trace.is_none());
|
||||
assert_eq!(metrics.dropped_spans, 0);
|
||||
}
|
||||
#[test]
|
||||
fn trace_is_capped_and_reports_dropped_spans() {
|
||||
let mut trace = TraceCollector::new(MetricsMode::Debug);
|
||||
for index in 0..520 {
|
||||
let token = trace.begin("stage", None);
|
||||
if index == 0 {
|
||||
trace.counter(token, "existing", 99);
|
||||
}
|
||||
let counters = if index == 0 {
|
||||
(0..40).map(|key| (format!("counter_{key}"), key)).collect()
|
||||
} else {
|
||||
BTreeMap::new()
|
||||
};
|
||||
trace.end(token, counters);
|
||||
}
|
||||
let metrics = trace.finish(2);
|
||||
let spans = metrics.trace.unwrap();
|
||||
assert_eq!(spans.len(), 512);
|
||||
assert_eq!(spans[0].counters.len(), 32);
|
||||
assert_eq!(spans[0].counters["existing"], 99);
|
||||
assert_eq!(metrics.dropped_spans, 8);
|
||||
}
|
||||
#[test]
|
||||
fn foreign_tokens_and_early_parent_close_are_safe() {
|
||||
let mut one = TraceCollector::new(MetricsMode::Debug);
|
||||
let mut two = TraceCollector::new(MetricsMode::Debug);
|
||||
let root = one.begin("root", None);
|
||||
let child = one.begin("child", root);
|
||||
assert!(two.begin("foreign", root).is_none());
|
||||
one.end(root, BTreeMap::new());
|
||||
one.end(child, BTreeMap::new());
|
||||
let spans = one.finish(0).trace.unwrap();
|
||||
assert!(
|
||||
spans[1].start_ms + spans[1].duration_ms
|
||||
<= spans[0].start_ms + spans[0].duration_ms + 1e-6
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user