Files
Jeffrey Emanuel b0f2da9ed5 test(search): cover passage embeddings, schedule census, and one-shot delta gate
Robot goldens follow health/quarantine fields. Skip .rch-tmp and
.release-format.
2026-09-14 18:11:17 -04:00

4669 lines
169 KiB
Rust

//! E2E integration tests for search/index pipeline.
//!
//! Tests cover:
//! - Full index flow with temp data-dir
//! - Search with JSON output (hits, match_type, aggregations)
//! - Watch-once environment path functionality
//! - Trace/log file capture (no mocks)
//!
//! Part of bead: coding_agent_session_search-0jt (TST.11)
use chrono::{SecondsFormat, Utc};
use coding_agent_search::franken_sync::compat::{ConnectionExt, RowExt};
use coding_agent_search::search::tantivy::{
Fields, SearchableIndexSummary, expected_index_dir, index_dir, open_federated_search_readers,
searchable_index_summary,
};
use coding_agent_search::storage::sqlite::SqliteStorage;
use rusqlite::Connection as RusqliteConnection;
use serial_test::serial;
use std::fs;
use std::path::Path;
#[cfg(target_os = "linux")]
use std::process::Command as StdCommand;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::mpsc;
use std::time::{Duration, Instant};
#[macro_use]
mod util;
// `cass_std_cmd` (the only consumer) is compiled on Linux only.
#[cfg(target_os = "linux")]
use util::e2e_log::E2eCommandEnvironment;
use util::e2e_log::{E2ePerformanceMetrics, PhaseTracker};
// =============================================================================
// E2E Logger Support
// =============================================================================
// GH472: full source coverage with conditional lexical rebuilding.
mod gh472 {
use super::*;
use serde_json::{Value, json};
use std::cell::Cell;
use std::collections::BTreeMap;
use std::path::PathBuf;
type Archive = BTreeMap<PathBuf, Value>;
struct Fixture {
_temp: tempfile::TempDir,
home: PathBuf,
tracker: PhaseTracker,
streaming: &'static str,
invocation: Cell<usize>,
}
struct Run {
report: Value,
events: Vec<Value>,
}
impl Run {
fn rebuild_entries(&self) -> usize {
self.events
.iter()
.filter(|event| {
event["fields"]["message"] == "lexical rebuild prep profile"
&& event["fields"]["component"] == "main"
&& event["fields"]["step"] == "open_readonly"
})
.count()
}
fn assert_no_rebuild(&self) {
assert_eq!(self.rebuild_entries(), 0, "{:?}", self.events);
assert!(
self.events.iter().all(|event| {
event["fields"]["message"]
!= "lexical rebuild authoritative equivalence evidence"
}),
"{:?}",
self.events
);
}
fn assert_inline(&self) {
assert_eq!(
self.report["indexing_stats"]["lexical_strategy"], "incremental_inline",
"{}",
self.report
);
assert_eq!(
self.report["indexing_stats"]["lexical_strategy_reason"],
"incremental_scan_applies_inline_lexical_updates_only_for_new_messages",
"{}",
self.report
);
assert!(
self.report["indexing_stats"]
.get("lexical_repair")
.is_none()
);
assert_eq!(self.report["lexical_update_deferred"], false);
self.assert_no_rebuild();
}
fn assert_codex_observations(&self, skipped: usize, parsed: usize) {
// Mutable path dependencies deliberately cannot certify parser
// identity. Those builds must parse every source; pinned builds
// must demonstrate the exact durable-observation reuse below.
let (skipped, parsed) = if env!("CASS_SOURCE_INGEST_REUSE") == "true" {
(skipped, parsed)
} else {
(0, skipped + parsed)
};
let observations = self
.events
.iter()
.filter(|event| {
event["fields"]["message"] == "source_ingest_observation"
&& event["fields"]["connector"] == "codex"
})
.map(|event| {
event["fields"]["skipped"]
.as_bool()
.expect("source observation includes an explicit skip decision")
})
.collect::<Vec<_>>();
assert_eq!(
observations.iter().filter(|&&value| value).count(),
skipped,
"{:?}",
self.events
);
assert_eq!(
observations.iter().filter(|&&value| !value).count(),
parsed,
"{:?}",
self.events
);
}
fn assert_totals(&self, conversations: usize, messages: usize) {
assert_eq!(
self.report["conversations"], conversations,
"{}",
self.report
);
assert_eq!(self.report["messages"], messages, "{}", self.report);
assert_eq!(
self.report["indexing_stats"]["total_conversations"], conversations,
"{}",
self.report
);
assert_eq!(
self.report["indexing_stats"]["total_messages"], messages,
"{}",
self.report
);
}
}
impl Fixture {
fn new(name: &str, streaming: &'static str) -> Self {
let temp = tempfile::TempDir::new().expect("create isolated GH472 home");
let home = fs::canonicalize(temp.path()).expect("canonical fixture home");
fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(home.join(".env"))
.expect("stop dotenv discovery before reaching an ancestor checkout");
for directory in [
".codex",
".claude",
".config",
".local/share",
"data",
"aider",
"traces",
] {
fs::create_dir_all(home.join(directory)).expect("create fixture directory");
}
let fixture_name = home.file_name().unwrap().to_str().unwrap();
let tracker = tracker_for(&format!(
"gh472_{name}_streaming_{streaming}_{fixture_name}"
));
Self {
_temp: temp,
home,
tracker,
streaming,
invocation: Cell::new(0),
}
}
fn data(&self) -> PathBuf {
self.home.join("data")
}
fn index(&self) -> PathBuf {
expected_index_dir(&self.data())
}
fn checkpoint_path(&self) -> PathBuf {
self.index().join(".lexical-rebuild-state.json")
}
fn checkpoint(&self) -> Value {
let path = self.checkpoint_path();
let bytes = fs::read(&path).expect("read real lexical checkpoint");
serde_json::from_slice(&bytes)
.unwrap_or_else(|error| panic!("invalid checkpoint {}: {error}", path.display()))
}
fn run(&self, args: &[&str], overrides: &[(&str, &str)]) -> Run {
let number = self.invocation.get();
self.invocation.set(number + 1);
let trace = self.home.join("traces").join(format!("run-{number}.jsonl"));
assert!(!trace.exists(), "each child must use a fresh trace");
let environment = self
.tracker
.command_environment()
.with_home(&self.home)
.with_codex_home(self.home.join(".codex"));
let mut command = environment.cass_assert_command();
command.env_clear();
for key in ["PATH", "SystemRoot", "WINDIR"] {
if let Some(value) = std::env::var_os(key) {
command.env(key, value);
}
}
environment.apply_to_assert(&mut command);
command
.current_dir(&self.home)
.env("USERPROFILE", &self.home)
.env("XDG_CONFIG_HOME", self.home.join(".config"))
.env("XDG_DATA_HOME", self.home.join(".local/share"))
.env("CLAUDE_CONFIG_DIR", self.home.join(".claude"))
.env("CASS_AIDER_DATA_ROOT", self.home.join("aider"))
.env("CASS_DATA_DIR", self.data())
.env("CASS_IGNORE_SOURCES_CONFIG", "1")
.env("CASS_STREAMING_INDEX", self.streaming)
.env("CASS_AUTO_REFRESH", "0")
.env("CASS_RESPONSIVENESS_DISABLE", "1")
.env("CASS_PREP_PROFILE", "1")
.env("CASS_INDEX_NO_PROGRESS_EVENTS", "1")
.env("CODING_AGENT_SEARCH_NO_UPDATE_PROMPT", "1")
.env("RUST_MIN_STACK", "134217728")
.env("TUI_HEADLESS", "1")
.env("NO_COLOR", "1")
.env("CASS_TRACE_FILE", &trace)
.env(
"CASS_TRACE_FILTER",
"warn,coding_agent_search::indexer=debug",
)
.env("CASS_TRACE_MAX_BYTES", "8388608")
.env("CASS_TRACE_MAX_EVENTS", "65536")
.args(["--color", "never"]);
if args.first() == Some(&"schedule") {
// The trace sink holds an exclusive file lock. Override only
// the scheduler parent's CLI path; its one index child must
// inherit CASS_TRACE_FILE and produce the asserted trace.
let parent_trace = self
.home
.join("traces")
.join(format!("run-{number}-parent.jsonl"));
assert!(!parent_trace.exists(), "fresh scheduler parent trace");
command.arg("--trace-file").arg(parent_trace);
}
command.args(args).timeout(Duration::from_secs(180));
for (key, value) in overrides {
command.env(key, value);
}
let output = command.output().expect("run actual GH472 cass child");
assert!(
output.status.success(),
"args={args:?} overrides={overrides:?}\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let report = serde_json::from_slice(&output.stdout).unwrap_or_else(|error| {
panic!(
"invalid JSON for {args:?}: {error}\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
)
});
let log = fs::read_to_string(&trace).unwrap_or_else(|error| {
panic!(
"missing trace {}: {error}\nstderr:\n{}",
trace.display(),
String::from_utf8_lossy(&output.stderr)
)
});
let events = log
.lines()
.map(|line| {
let event: Value = serde_json::from_str(line)
.unwrap_or_else(|error| panic!("invalid trace record {line:?}: {error}"));
assert_ne!(event["event"], "trace_truncated", "{log}");
assert_ne!(event["fields"]["event"], "trace_truncated", "{log}");
event
})
.collect::<Vec<_>>();
assert!(!events.is_empty(), "trace must contain real child events");
Run { report, events }
}
fn nightly(&self, overrides: &[(&str, &str)]) -> Run {
let mut run = self.run(
&[
"schedule",
"run",
"--job",
"nightly",
"--force",
"--no-semantic",
"--json",
],
overrides,
);
assert_eq!(run.report["ok"], true, "{}", run.report);
let index_steps = run.report["steps"]
.as_array()
.expect("schedule steps")
.iter()
.filter(|step| {
step["argv"]
.as_array()
.is_some_and(|argv| argv.iter().any(|arg| arg == "index"))
})
.collect::<Vec<_>>();
assert_eq!(index_steps.len(), 1, "{}", run.report);
let step = index_steps[0];
assert_eq!(step["ok"], true, "{step}");
assert_eq!(step["exit_code"], 0, "{step}");
assert!(
step["argv"]
.as_array()
.unwrap()
.iter()
.any(|arg| arg == "--full"),
"nightly must retain full source census: {step}"
);
let result = step["result"].clone();
assert_eq!(result["success"], true, "{result}");
assert_eq!(result["full"], true, "{result}");
run.report = result;
run
}
fn seed(&self, day: &str, name: &str, needle: &str) -> PathBuf {
let filename = format!("rollout-{name}.jsonl");
make_codex_session(
&self.home.join(".codex"),
day,
&filename,
needle,
1_732_118_400_000,
);
self.home.join(".codex/sessions").join(day).join(filename)
}
fn archive(&self) -> Archive {
let storage = SqliteStorage::open_readonly(&self.data().join("agent_search.db"))
.expect("read canonical archive without changing its checkpoint fingerprint");
let mut archive = Archive::new();
for conversation in storage.list_conversations(100, 0).unwrap() {
let id = conversation.id.expect("canonical conversation ID");
let messages = storage
.fetch_messages(id)
.unwrap()
.into_iter()
.map(|message| {
json!({
"id": message.id.expect("canonical message ID"),
"idx": message.idx,
"role": message.role,
"content": message.content
})
})
.collect::<Vec<_>>();
assert!(
archive
.insert(
conversation.source_path,
json!({
"id": id,
"agent": conversation.agent_slug,
"external_id": conversation.external_id,
"title": conversation.title,
"workspace": conversation.workspace,
"source_id": conversation.source_id,
"origin_host": conversation.origin_host,
"messages": messages
})
)
.is_none(),
"fixture must not collapse distinct canonical source identities"
);
}
archive
}
fn baseline(&self) -> Archive {
let first = self.seed("2024/11/20", "first", "gh472firstanchorz");
let second = self.seed("2024/11/21", "second", "gh472secondanchorz");
let run = self.run(&["index", "--full", "--json"], &[]);
assert!(
run.rebuild_entries() > 0,
"positive trace control: {:?}",
run.events
);
run.assert_totals(2, 4);
let archive = self.archive();
assert_eq!(
archive.keys().cloned().collect::<Vec<_>>(),
vec![first.clone(), second.clone()],
"only the two generated sources may be admitted"
);
assert_codex_pair(&archive, &first, "gh472firstanchorz");
assert_codex_pair(&archive, &second, "gh472secondanchorz");
let storage =
SqliteStorage::open_readonly(&self.data().join("agent_search.db")).unwrap();
assert_eq!(storage.source_ingest_ledger_entries().unwrap().len(), 2);
drop(storage);
assert_eq!(self.checkpoint()["completed"], true);
assert_eq!(self.checkpoint()["indexed_docs"], 4);
archive
}
fn receipts(&self) -> Vec<Vec<u8>> {
[
".lexical-rebuild-equivalence.json",
".lexical-refresh-ledger.json",
]
.map(|name| fs::read(self.index().join(name)).expect("authoritative publish receipt"))
.into_iter()
.collect()
}
fn assert_search(&self, needle: &str, archive: &Archive) {
// These unique literal needles query both indexed title and content.
// Match canonical rows independently of search order, scores, or previews.
let mut expected = Vec::new();
for (source, conversation) in archive {
let title_matches = conversation["title"]
.as_str()
.is_some_and(|title| title.contains(needle));
for message in conversation["messages"].as_array().unwrap() {
if title_matches || message["content"].as_str().unwrap().contains(needle) {
expected.push(
json!({
"source_path": source,
"line_number": message["idx"].as_i64().unwrap() + 1,
"title": conversation["title"].as_str().unwrap_or(""),
"content": message["content"],
"agent": conversation["agent"],
"workspace": conversation["workspace"].as_str().unwrap_or(""),
"source_id": conversation["source_id"],
"origin_kind": "local",
"origin_host": conversation["origin_host"]
})
.to_string(),
);
}
}
}
let run = self.run(
&[
"search",
needle,
"--json",
"--mode",
"lexical",
"--no-maintenance",
"--limit",
"100",
],
&[],
);
assert_ne!(run.report.pointer("/budget/timed_out"), Some(&json!(true)));
let mut actual = run.report["hits"]
.as_array()
.expect("actual lexical hits")
.iter()
.map(|hit| {
json!({
"source_path": hit["source_path"],
"line_number": hit["line_number"],
"title": hit["title"],
"content": hit["content"],
"agent": hit["agent"],
"workspace": hit["workspace"],
"source_id": hit["source_id"],
"origin_kind": hit["origin_kind"],
"origin_host": hit["origin_host"]
})
.to_string()
})
.collect::<Vec<_>>();
expected.sort();
actual.sort();
assert_eq!(
actual, expected,
"missing/duplicate/misattributed hits: {}",
run.report
);
}
fn replace_with_sparse_donor(&self) {
let donor = Self::new("sparse_donor", self.streaming);
let source = donor.seed("2024/11/22", "donor", "gh472donoranchorz");
append_jsonl_lines(
&source,
&[json!({
"timestamp": "2024-11-20T12:00:00Z",
"type": "response_item",
"payload": {
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": "gh472donoranchorz third message"}]
}
})],
);
let run = donor.run(&["index", "--full", "--json"], &[]);
run.assert_totals(1, 3);
assert!(run.rebuild_entries() > 0);
assert_eq!(
raw_lexical_total_matches(&donor.index(), "gh472donoranchorz"),
3
);
let backup = self.data().join("before-sparse-donor");
fs::rename(self.index(), &backup).expect("retain original real generation");
fs::rename(donor.index(), self.index()).expect("install real three-document donor");
// This is the original archive's completed state, not the donor's
// unrelated checkpoint. Thus the sparse planner is admitted with
// expected=4/observed=3, above the separate 50% hollow threshold.
fs::copy(
backup.join(".lexical-rebuild-state.json"),
self.checkpoint_path(),
)
.expect("restore matching original archive checkpoint");
assert_eq!(self.checkpoint()["completed"], true);
assert_eq!(self.checkpoint()["indexed_docs"], 4);
assert_eq!(
raw_lexical_total_matches(&self.index(), "gh472firstanchorz"),
0
);
assert_eq!(
raw_lexical_total_matches(&self.index(), "gh472donoranchorz"),
3
);
}
}
fn assert_retained(before: &Archive, after: &Archive) {
for (source, original) in before {
assert_eq!(
after.get(source),
Some(original),
"canonical identity/content changed: {source:?}"
);
}
}
fn assert_codex_pair(archive: &Archive, source: &Path, needle: &str) {
let conversation = &archive[source];
assert_eq!(conversation["agent"], "codex");
assert_eq!(conversation["source_id"], "local");
assert!(conversation["id"].as_i64().unwrap() > 0);
let messages = conversation["messages"].as_array().unwrap();
assert_eq!(messages.len(), 2);
assert_eq!(messages[0]["idx"], 0);
assert_eq!(messages[1]["idx"], 1);
assert_eq!(messages[0]["content"], needle);
assert_eq!(messages[1]["content"], format!("{needle}_response"));
assert!(
messages
.iter()
.all(|message| message["id"].as_i64().unwrap() > 0)
);
assert_ne!(messages[0]["id"], messages[1]["id"]);
}
#[test]
fn nightly_healthy_reconciliation_preserves_ids_and_appends_inline() {
for streaming in ["0", "1"] {
let fixture = Fixture::new("healthy", streaming);
let original = fixture.baseline();
let receipts = fixture.receipts();
let checkpoint = fs::read(fixture.checkpoint_path()).unwrap();
let noop = fixture.nightly(&[]);
noop.assert_inline();
noop.assert_totals(2, 4);
noop.assert_codex_observations(2, 0);
assert_eq!(fixture.archive(), original);
assert_eq!(fs::read(fixture.checkpoint_path()).unwrap(), checkpoint);
let new_source = fixture.seed("2024/11/23", "third", "gh472thirdanchorz");
let appended = fixture.nightly(&[]);
appended.assert_inline();
appended.assert_codex_observations(2, 1);
let archive = fixture.archive();
assert_retained(&original, &archive);
assert_eq!(archive.len(), 3);
assert_codex_pair(&archive, &new_source, "gh472thirdanchorz");
assert_eq!(fixture.receipts(), receipts);
for needle in [
"gh472firstanchorz",
"gh472secondanchorz",
"gh472thirdanchorz",
] {
fixture.assert_search(needle, &archive);
}
// Growing a known source must preserve its old row IDs as well.
let first = original.keys().next().unwrap();
append_codex_session(first, "gh472tailanchorz", 1_732_118_500_000);
let grown = fixture.nightly(&[]);
grown.assert_inline();
grown.assert_codex_observations(2, 1);
let after_growth = fixture.archive();
let old = &archive[first];
let new = &after_growth[first];
assert_eq!(new["id"], old["id"]);
assert_eq!(new["messages"].as_array().unwrap().len(), 4);
assert_eq!(
&new["messages"].as_array().unwrap()[..2],
old["messages"].as_array().unwrap().as_slice()
);
assert_eq!(new["messages"][2]["content"], "gh472tailanchorz");
assert_eq!(new["messages"][3]["content"], "gh472tailanchorz_response");
for (source, row) in &archive {
if source != first {
assert_eq!(after_growth.get(source), Some(row));
}
}
fixture.assert_search("gh472tailanchorz", &after_growth);
fixture.assert_search("gh472firstanchorz", &after_growth);
let final_noop = fixture.nightly(&[]);
final_noop.assert_inline();
final_noop.assert_totals(3, 8);
final_noop.assert_codex_observations(3, 0);
assert_eq!(fixture.archive(), after_growth);
assert_eq!(fixture.receipts(), receipts);
}
}
#[test]
fn nightly_sparse_reconciliation_recounts_after_skipped_scan() {
for streaming in ["0", "1"] {
for add_source in [false, true] {
let fixture = Fixture::new(
if add_source {
"sparse_plus_new"
} else {
"sparse_no_new"
},
streaming,
);
let original = fixture.baseline();
fixture.replace_with_sparse_donor();
let new_source =
add_source.then(|| fixture.seed("2024/11/23", "third", "gh472thirdanchorz"));
let repaired = fixture.nightly(&[]);
assert_eq!(repaired.rebuild_entries(), 1, "{:?}", repaired.events);
let repair = &repaired.report["indexing_stats"]["lexical_repair"];
assert_eq!(
repair["kind"], "authoritative_canonical_db_rebuild",
"{repair}"
);
assert_eq!(
repair["reason"],
"incremental_index_repairs_sparse_tantivy_from_authoritative_canonical_db_before_scan",
"{repair}"
);
assert_eq!(repair["canonical_conversations"], 2);
assert_eq!(repair["canonical_messages"], 4);
assert_eq!(repair["observed_tantivy_docs"], 3);
repaired.assert_codex_observations(2, usize::from(add_source));
let conversations = 2 + usize::from(add_source);
let messages = 4 + 2 * usize::from(add_source);
repaired.assert_totals(conversations, messages);
let archive = fixture.archive();
assert_eq!(archive.len(), conversations);
assert_retained(&original, &archive);
if let Some(source) = &new_source {
assert_codex_pair(&archive, source, "gh472thirdanchorz");
}
let checkpoint = fixture.checkpoint();
assert_eq!(checkpoint["completed"], true);
assert_eq!(checkpoint["db"]["total_conversations"], conversations);
assert_eq!(checkpoint["db"]["total_messages"], messages);
assert_eq!(checkpoint["indexed_docs"], messages);
assert_eq!(
raw_lexical_total_matches(&fixture.index(), "gh472donoranchorz"),
0
);
for needle in [
"gh472firstanchorz",
"gh472secondanchorz",
"gh472thirdanchorz",
] {
fixture.assert_search(needle, &archive);
}
let unchanged = fixture.nightly(&[]);
unchanged.assert_inline();
unchanged.assert_totals(conversations, messages);
unchanged.assert_codex_observations(conversations, 0);
assert_eq!(fixture.archive(), archive);
}
}
}
#[test]
fn nightly_reconciliation_censuses_old_mtime_fallback_sources() {
let fixture = Fixture::new("fallback_census", "1");
let original = fixture.baseline();
let initial = fixture.home.join("aider/initial/.aider.chat.history.md");
fs::create_dir_all(initial.parent().unwrap()).unwrap();
let history = include_str!("fixtures/aider/.aider.chat.history.md");
fs::write(
&initial,
format!("{history}\n> gh472aiderinitialz\n\nRetain gh472aiderinitialz discussion.\n"),
)
.unwrap();
fixture.run(&["index", "--json"], &[]);
let with_aider = fixture.archive();
assert_retained(&original, &with_aider);
assert_eq!(with_aider.len(), 3);
assert_eq!(with_aider[&initial]["agent"], "aider");
fixture.assert_search("gh472aiderinitialz", &with_aider);
let storage =
SqliteStorage::open_readonly(&fixture.data().join("agent_search.db")).unwrap();
assert!(
storage
.get_connector_last_scan_ts("aider")
.unwrap()
.is_some_and(|watermark| watermark > 101_000),
"establish this fallback provider's own watermark before adding an old file"
);
drop(storage);
let old_source = fixture.home.join("aider/late/.aider.chat.history.md");
fs::create_dir_all(old_source.parent().unwrap()).unwrap();
fs::write(
&old_source,
format!("{history}\n> gh472aideroldz\n\nRecover gh472aideroldz discussion.\n"),
)
.unwrap();
let old_time = std::time::UNIX_EPOCH + Duration::from_secs(100);
fs::File::open(&old_source)
.unwrap()
.set_times(fs::FileTimes::new().set_modified(old_time))
.unwrap();
assert_eq!(
fs::metadata(&old_source).unwrap().modified().unwrap(),
old_time
);
let incremental = fixture.run(&["index", "--json"], &[]);
incremental.assert_codex_observations(2, 0);
assert_eq!(fixture.archive(), with_aider);
assert_eq!(
raw_lexical_total_matches(&fixture.index(), "gh472aideroldz"),
0,
"ordinary timestamp-filtered discovery must demonstrate the negative branch"
);
let nightly = fixture.nightly(&[]);
nightly.assert_inline();
nightly.assert_codex_observations(2, 0);
let archive = fixture.archive();
assert_retained(&with_aider, &archive);
assert_eq!(archive.len(), 4);
assert_eq!(archive[&old_source]["agent"], "aider");
assert!(
archive[&old_source]["messages"]
.as_array()
.unwrap()
.iter()
.any(|message| message["content"]
.as_str()
.unwrap()
.contains("gh472aideroldz"))
);
fixture.assert_search("gh472aideroldz", &archive);
fixture.assert_search("gh472firstanchorz", &archive);
assert_eq!(
fs::metadata(&old_source).unwrap().modified().unwrap(),
old_time
);
let noop = fixture.nightly(&[]);
noop.assert_inline();
assert_eq!(fixture.archive(), archive);
fixture.assert_search("gh472aideroldz", &archive);
}
#[test]
fn nightly_reconciliation_uncertain_admission_keeps_full_repair() {
for case in [
"deferred",
"count_skipped",
"checkpoint_missing",
"checkpoint_malformed",
"checkpoint_foreign",
] {
let fixture = Fixture::new(case, "1");
let original = fixture.baseline();
let checkpoint = fixture.checkpoint_path();
let mut overrides = Vec::new();
match case {
"deferred" => overrides.push(("CASS_DEFER_LEXICAL_UPDATES", "1")),
"count_skipped" => {
fixture.replace_with_sparse_donor();
overrides.push(("CASS_SKIP_PREFLIGHT_COUNT_TOTAL_MESSAGES", "1"));
}
"checkpoint_missing" => {
fs::rename(&checkpoint, fixture.data().join("retained-checkpoint.json"))
.unwrap();
}
"checkpoint_malformed" => {
fs::rename(&checkpoint, fixture.data().join("retained-checkpoint.json"))
.unwrap();
fs::write(&checkpoint, b"{incomplete checkpoint").unwrap();
}
"checkpoint_foreign" => {
let mut state = fixture.checkpoint();
state["db"]["db_path"] = json!(fixture.home.join("foreign/agent_search.db"));
fs::rename(&checkpoint, fixture.data().join("retained-checkpoint.json"))
.unwrap();
fs::write(&checkpoint, serde_json::to_vec(&state).unwrap()).unwrap();
}
_ => unreachable!("closed table of admission controls"),
}
// Keep the count-skip case at 3/4 with no new rows, so the
// test cannot pass merely because an unrelated append repaired it.
let new_source = (case != "count_skipped")
.then(|| fixture.seed("2024/11/23", "third", "gh472thirdanchorz"));
let repaired = fixture.nightly(&overrides);
assert!(
repaired.rebuild_entries() > 0,
"uncertain admission must retain authoritative repair: case={case}, {:?}",
repaired.events
);
assert_eq!(
repaired.report["indexing_stats"]["lexical_strategy"],
"deferred_authoritative_db_rebuild",
"case={case}, {}",
repaired.report
);
assert_eq!(
repaired.report["indexing_stats"]["lexical_strategy_reason"],
"full_refresh_defers_inline_lexical_writes_to_authoritative_db_rebuild",
"case={case}, {}",
repaired.report
);
let conversations = if case == "count_skipped" { 2 } else { 3 };
repaired.assert_totals(conversations, conversations * 2);
let archive = fixture.archive();
assert_eq!(archive.len(), conversations, "case={case}");
assert_retained(&original, &archive);
if let Some(source) = &new_source {
assert_codex_pair(&archive, source, "gh472thirdanchorz");
}
assert_eq!(fixture.checkpoint()["completed"], true);
assert_eq!(fixture.checkpoint()["indexed_docs"], conversations * 2);
assert_eq!(
raw_lexical_total_matches(&fixture.index(), "gh472donoranchorz"),
0
);
for needle in [
"gh472firstanchorz",
"gh472secondanchorz",
"gh472thirdanchorz",
] {
fixture.assert_search(needle, &archive);
}
// Removing the child-only override permits healthy reconciliation
// after repair; no inherited env or persistent config is changed.
let healthy = fixture.nightly(&[]);
healthy.assert_inline();
assert_eq!(fixture.archive(), archive);
}
}
#[test]
fn operator_full_and_force_ignore_nightly_optimization() {
let fixture = Fixture::new("operator_controls", "1");
let original = fixture.baseline();
let staged = fixture.seed("2024/11/23", "third", "gh472thirdanchorz");
let force = fixture.run(
&["index", "--full", "--force-rebuild", "--json"],
&[("CASS_NIGHTLY_RECONCILIATION", "1")],
);
assert_eq!(force.report["force_rebuild"], true);
assert_eq!(force.report["full"], true);
assert!(force.rebuild_entries() > 0, "{:?}", force.events);
force.assert_totals(2, 4);
force.assert_codex_observations(0, 0);
assert_eq!(fixture.archive(), original);
assert!(!fixture.archive().contains_key(&staged));
assert_eq!(
raw_lexical_total_matches(&fixture.index(), "gh472thirdanchorz"),
0,
"force-rebuild intentionally rebuilds the canonical archive without rescanning raw logs"
);
fixture.assert_search("gh472firstanchorz", &original);
// Fixture::run clears the environment, so this operator invocation
// has no nightly marker. A real append defeats the legitimate noop
// optimization already present in ordinary unchanged --full runs.
let full = fixture.run(&["index", "--full", "--json"], &[]);
assert_eq!(full.report["force_rebuild"], false);
assert!(full.rebuild_entries() > 0, "{:?}", full.events);
full.assert_totals(3, 6);
let archive = fixture.archive();
assert_retained(&original, &archive);
assert_eq!(archive.len(), 3);
assert_codex_pair(&archive, &staged, "gh472thirdanchorz");
fixture.assert_search("gh472thirdanchorz", &archive);
fixture.assert_search("gh472firstanchorz", &archive);
}
#[test]
fn nightly_truncated_and_disappeared_sources_preserve_canonical_history() {
for streaming in ["0", "1"] {
let fixture = Fixture::new("retained_history", streaming);
let original = fixture.baseline();
let source = original.keys().next().unwrap();
append_codex_session(source, "gh472retainedtailz", 1_732_118_500_000);
fixture.nightly(&[]).assert_inline();
let grown = fixture.archive();
assert_eq!(grown[source]["id"], original[source]["id"]);
assert_eq!(grown[source]["messages"].as_array().unwrap().len(), 4);
assert_eq!(
grown[source]["messages"][2]["content"],
"gh472retainedtailz"
);
assert_eq!(
grown[source]["messages"][3]["content"],
"gh472retainedtailz_response"
);
let receipts = fixture.receipts();
// Retain the original raw bytes outside discovery; truncating an
// append-oriented log must not delete already-canonical history.
let raw = fs::read_to_string(source).unwrap();
fs::write(fixture.home.join("retained-original-source.txt"), &raw).unwrap();
let short = raw.lines().take(2).collect::<Vec<_>>().join("\n") + "\n";
fs::write(source, short).unwrap();
fs::File::open(source)
.unwrap()
.set_times(
fs::FileTimes::new()
.set_modified(std::time::UNIX_EPOCH + Duration::from_secs(100)),
)
.unwrap();
let truncated = fixture.nightly(&[]);
truncated.assert_inline();
truncated.assert_codex_observations(1, 1);
truncated.assert_totals(2, 6);
assert_eq!(fixture.archive(), grown);
fixture.assert_search("gh472retainedtailz", &grown);
// Disappearance is archival retention, not a request to erase
// canonical messages. Rename rather than delete the fixture.
fs::rename(source, fixture.home.join("disappeared-source.jsonl")).unwrap();
let absent = fixture.nightly(&[]);
absent.assert_inline();
absent.assert_codex_observations(1, 0);
absent.assert_totals(2, 6);
assert_eq!(fixture.archive(), grown);
assert_eq!(fixture.receipts(), receipts);
fixture.assert_search("gh472retainedtailz", &grown);
fixture.assert_search("gh472firstanchorz", &grown);
}
}
}
fn tracker_for(test_name: &str) -> PhaseTracker {
PhaseTracker::new("e2e_search_index", test_name)
}
fn codex_iso_timestamp(ts_millis: u64) -> String {
chrono::DateTime::<Utc>::from_timestamp_millis(ts_millis as i64)
.expect("valid millis timestamp for codex fixture")
.to_rfc3339_opts(SecondsFormat::Millis, true)
}
fn write_jsonl_lines(file: &Path, lines: &[serde_json::Value]) {
let mut sample = String::new();
for line in lines {
sample.push_str(&serde_json::to_string(line).unwrap());
sample.push('\n');
}
fs::write(file, sample).unwrap();
}
fn append_jsonl_lines(file: &Path, lines: &[serde_json::Value]) {
use std::io::Write;
let mut handle = std::fs::OpenOptions::new()
.append(true)
.open(file)
.expect("open rollout for append");
for line in lines {
handle
.write_all(serde_json::to_string(line).unwrap().as_bytes())
.unwrap();
handle.write_all(b"\n").unwrap();
}
}
/// Helper to create Codex session with modern envelope format.
fn make_codex_session(root: &Path, date_path: &str, filename: &str, content: &str, ts: u64) {
let sessions = root.join(format!("sessions/{date_path}"));
fs::create_dir_all(&sessions).unwrap();
let file = sessions.join(filename);
let workspace = root.to_string_lossy();
write_jsonl_lines(
&file,
&[
serde_json::json!({
"timestamp": codex_iso_timestamp(ts),
"type": "session_meta",
"payload": {
"id": filename,
"cwd": workspace,
"cli_version": "0.42.0"
}
}),
serde_json::json!({
"timestamp": codex_iso_timestamp(ts + 1_000),
"type": "response_item",
"payload": {
"type": "message",
"role": "user",
"content": [{ "type": "input_text", "text": content }]
}
}),
serde_json::json!({
"timestamp": codex_iso_timestamp(ts + 2_000),
"type": "response_item",
"payload": {
"type": "message",
"role": "assistant",
"content": [{ "type": "text", "text": format!("{content}_response") }]
}
}),
],
);
}
/// Helper to create Claude Code session.
fn make_claude_session(root: &Path, project: &str, filename: &str, content: &str, ts: &str) {
let project_dir = root.join(format!("projects/{project}"));
fs::create_dir_all(&project_dir).unwrap();
let file = project_dir.join(filename);
let sample = format!(
r#"{{"type": "user", "timestamp": "{ts}", "message": {{"role": "user", "content": "{content}"}}}}
{{"type": "assistant", "timestamp": "{ts}", "message": {{"role": "assistant", "content": "{content}_response"}}}}"#
);
fs::write(file, sample).unwrap();
}
/// Append an additional Codex message pair (user + assistant) to an existing rollout file.
fn append_codex_session(file: &Path, content: &str, ts: u64) {
append_jsonl_lines(
file,
&[
serde_json::json!({
"timestamp": codex_iso_timestamp(ts),
"type": "response_item",
"payload": {
"type": "message",
"role": "user",
"content": [{ "type": "input_text", "text": content }]
}
}),
serde_json::json!({
"timestamp": codex_iso_timestamp(ts + 1_000),
"type": "response_item",
"payload": {
"type": "message",
"role": "assistant",
"content": [{ "type": "text", "text": format!("{content}_response") }]
}
}),
],
);
}
fn make_codex_session_with_turns(
root: &Path,
date_path: &str,
filename: &str,
common_token: &str,
unique_suffix: &str,
ts: u64,
turns: usize,
) {
let sessions = root.join(format!("sessions/{date_path}"));
fs::create_dir_all(&sessions).unwrap();
let file = sessions.join(filename);
let workspace = root.to_string_lossy();
let mut lines = vec![serde_json::json!({
"timestamp": codex_iso_timestamp(ts),
"type": "session_meta",
"payload": {
"id": filename,
"cwd": workspace,
"cli_version": "0.42.0"
}
})];
for turn in 0..turns {
let turn_ts = ts + ((turn as u64) + 1) * 3_000;
lines.push(serde_json::json!({
"timestamp": codex_iso_timestamp(turn_ts),
"type": "response_item",
"payload": {
"type": "message",
"role": "user",
"content": [{
"type": "input_text",
"text": format!("{common_token} {unique_suffix} user_turn_{turn}")
}]
}
}));
lines.push(serde_json::json!({
"timestamp": codex_iso_timestamp(turn_ts + 1_000),
"type": "response_item",
"payload": {
"type": "message",
"role": "assistant",
"content": [{
"type": "text",
"text": format!("{common_token} {unique_suffix} assistant_turn_{turn}")
}]
}
}));
}
write_jsonl_lines(&file, &lines);
}
fn make_bulk_codex_sessions(
root: &Path,
date_path: &str,
batch_prefix: &str,
common_token: &str,
start_ts: u64,
session_count: usize,
turns_per_session: usize,
) {
for idx in 0..session_count {
make_codex_session_with_turns(
root,
date_path,
&format!("{batch_prefix}-{idx:03}.jsonl"),
common_token,
&format!("session_{idx:03}"),
start_ts + (idx as u64) * 50_000,
turns_per_session,
);
}
}
fn count_messages(db_path: &Path) -> i64 {
let storage = SqliteStorage::open(db_path).expect("open sqlite");
storage
.raw()
.query_row_map("SELECT COUNT(*) FROM messages", &[], |r| r.get_typed(0))
.expect("count messages")
}
fn total_matches_from_search_output(output: &[u8]) -> u64 {
let json: serde_json::Value = serde_json::from_slice(output).expect("parse search json");
json.get("total_matches")
.and_then(|matches| matches.as_u64())
.unwrap_or_else(|| {
json.get("hits")
.and_then(|hits| hits.as_array())
.map(|hits| hits.len() as u64)
.unwrap_or(0)
})
}
fn command_output_kind_is(output: &[u8], expected_kind: &str) -> bool {
let Ok(json) = serde_json::from_slice::<serde_json::Value>(output) else {
return false;
};
json.get("kind")
.and_then(|kind| kind.as_str())
.is_some_and(|actual_kind| actual_kind.eq(expected_kind))
}
fn raw_lexical_total_matches(index_path: &Path, query: &str) -> u64 {
let mut total = 0usize;
if let Some(readers) = open_federated_search_readers(index_path)
.expect("open federated lexical readers for raw count")
{
for (reader, fields) in readers {
total = total.saturating_add(raw_lexical_reader_matches(&reader, &fields, query));
}
} else {
let reader = coding_agent_search::search::quill_bridge::open_cass_reader(index_path)
.expect("open lexical reader");
let fields = coding_agent_search::search::quill_bridge::QuillCassFields::compiled();
total = total.saturating_add(raw_lexical_reader_matches(&reader, &fields, query));
}
total as u64
}
/// Independent match count, built from the engine's own parser rather than
/// from the code under test.
fn raw_lexical_reader_matches(
reader: &frankensearch::quill::QuillSearchIndex,
_fields: &Fields,
query: &str,
) -> usize {
let parser = frankensearch::quill::query::CassQueryParser::new(
frankensearch::quill::schema::CASS_SEMANTIC_SCHEMA,
)
.expect("build the CASS query parser");
let parsed = parser.parse(
query,
&frankensearch::quill::query::CassQueryFilters::default(),
);
// The exact total is computed independently of the requested page, so ask
// for the smallest page: a huge limit only sizes the top-k heap.
coding_agent_search::search::quill_bridge::search_paginated(reader, &parsed.query, 1, 0, true)
.expect("count raw lexical matches")
.total_count
.unwrap_or(0)
}
fn force_federated_publish_env(cmd: &mut assert_cmd::Command) {
cmd.env("CASS_TANTIVY_REBUILD_WORKERS", "6");
cmd.env("CASS_TANTIVY_MAX_WRITER_THREADS", "2");
cmd.env("CASS_TANTIVY_REBUILD_BATCH_FETCH_CONVERSATIONS", "1");
cmd.env(
"CASS_TANTIVY_REBUILD_INITIAL_BATCH_FETCH_CONVERSATIONS",
"1",
);
cmd.env("CASS_TANTIVY_REBUILD_COMMIT_EVERY_CONVERSATIONS", "1");
cmd.env(
"CASS_TANTIVY_REBUILD_INITIAL_COMMIT_EVERY_CONVERSATIONS",
"1",
);
cmd.env("CASS_TANTIVY_REBUILD_COMMIT_EVERY_MESSAGES", "2");
cmd.env("CASS_TANTIVY_REBUILD_INITIAL_COMMIT_EVERY_MESSAGES", "2");
cmd.env("CASS_TANTIVY_REBUILD_COMMIT_EVERY_MESSAGE_BYTES", "4096");
cmd.env(
"CASS_TANTIVY_REBUILD_INITIAL_COMMIT_EVERY_MESSAGE_BYTES",
"4096",
);
}
#[cfg(target_os = "linux")]
fn cass_std_cmd(command_env: &E2eCommandEnvironment, home: &Path) -> StdCommand {
let mut cmd = command_env.cass_std_command();
cmd.current_dir(home);
cmd.env("CODING_AGENT_SEARCH_NO_UPDATE_PROMPT", "1");
cmd.env("CASS_IGNORE_SOURCES_CONFIG", "1");
cmd
}
#[cfg(target_os = "linux")]
fn lexical_publish_in_progress_backup_path(index_path: &Path) -> std::path::PathBuf {
let file_name = index_path
.file_name()
.expect("live index path should have a file name")
.to_string_lossy();
index_path.with_file_name(format!(".{file_name}.publish-in-progress.bak"))
}
#[cfg(target_os = "linux")]
fn lexical_publish_backups_dir(index_path: &Path) -> std::path::PathBuf {
index_path
.parent()
.expect("live index path should have a parent directory")
.join(".lexical-publish-backups")
}
#[cfg(target_os = "linux")]
fn wait_for_publish_kill_relaunch_sentinel(path: &Path, timeout: Duration) -> serde_json::Value {
let deadline = Instant::now() + timeout;
loop {
match fs::read(path) {
Ok(bytes) => {
return serde_json::from_slice(&bytes)
.expect("parse lexical publish kill-relaunch sentinel json");
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound && Instant::now() < deadline => {
std::thread::sleep(Duration::from_millis(20));
}
Err(err) => {
panic!(
"expected lexical publish kill-relaunch sentinel at {}: {err}",
path.display()
);
}
}
assert!(
Instant::now() < deadline,
"timed out waiting for lexical publish kill-relaunch sentinel at {}",
path.display()
);
}
}
#[derive(Debug, Default)]
struct SearchLoopStats {
attempts: usize,
successes: usize,
max_duration_ms: u64,
failures: Vec<String>,
}
#[test]
#[serial]
fn duplicate_fts_schema_rows_do_not_block_cli_reads_and_writes() {
let sqlite3_available = std::process::Command::new("sqlite3")
.arg("-version")
.output()
.is_ok_and(|output| output.status.success());
if !sqlite3_available {
eprintln!(
"skipping duplicate_fts_schema_rows_do_not_block_cli_reads_and_writes: \
duplicate sqlite_master repair requires the sqlite3 CLI"
);
return;
}
let tracker = tracker_for("duplicate_fts_schema_rows_do_not_block_cli_reads_and_writes");
let tmp = tempfile::TempDir::new().unwrap();
let home = tmp.path();
let codex_home = home.to_path_buf();
let data_dir = home.join("cass_data");
fs::create_dir_all(&data_dir).unwrap();
let command_env = tracker
.command_environment()
.with_home(home)
.with_codex_home(&codex_home);
let ts = 1_732_118_400_000u64;
make_codex_session(
&codex_home,
"2024/11/20",
"rollout-fts-repair.jsonl",
"fts_repair_initial_token",
ts,
);
let session_file = codex_home.join("sessions/2024/11/20/rollout-fts-repair.jsonl");
command_env
.cass_assert_command()
.args(["index", "--full", "--data-dir"])
.arg(&data_dir)
.current_dir(home)
.env("CODEX_HOME", &codex_home)
.env("HOME", home)
.assert()
.success();
let db_path = data_dir.join("agent_search.db");
let baseline_messages = count_messages(&db_path);
assert_eq!(
baseline_messages, 2,
"initial full index should ingest both messages"
);
let duplicate_legacy_fts_sql = "CREATE VIRTUAL TABLE fts_messages USING fts5(content, title, agent, workspace, source_path, created_at UNINDEXED, message_id UNINDEXED, tokenize='porter')";
let injection =
RusqliteConnection::open(&db_path).expect("open db for writable_schema fixture");
injection
.execute_batch(
"CREATE VIRTUAL TABLE IF NOT EXISTS fts_messages USING fts5(
content,
title,
agent,
workspace,
source_path,
created_at UNINDEXED,
message_id UNINDEXED,
tokenize='porter'
);",
)
.expect("materialize canonical fts schema before duplicate injection");
injection
.execute_batch("PRAGMA writable_schema = ON;")
.expect("enable writable_schema");
injection
.execute(
"INSERT INTO sqlite_master(type, name, tbl_name, rootpage, sql)
VALUES('table', 'fts_messages', 'fts_messages', 0, ?1)",
[duplicate_legacy_fts_sql],
)
.expect("inject duplicate fts schema row");
injection
.execute(
"DELETE FROM meta WHERE key = ?1",
["fts_frankensqlite_rebuild_generation"],
)
.expect("delete stale fts generation marker");
injection
.execute_batch("PRAGMA writable_schema = OFF;")
.expect("disable writable_schema");
drop(injection);
let broken_read = RusqliteConnection::open(&db_path)
.expect("reopen db for broken-read assertion")
.query_row("SELECT COUNT(*) FROM fts_messages", [], |row| {
row.get::<_, i64>(0)
});
assert!(
broken_read.is_err(),
"the injected duplicate schema row should reproduce the unreadable pre-fix SQLite state"
);
let existing_search = command_env
.cass_assert_command()
.args([
"search",
"fts_repair_initial_token",
"--robot",
"--data-dir",
])
.arg(&data_dir)
.current_dir(home)
.env("CODEX_HOME", &codex_home)
.env("HOME", home)
.output()
.expect("search for existing content after duplicate schema injection");
assert!(
existing_search.status.success(),
"search should continue to succeed even when the fallback SQLite FTS table is malformed\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&existing_search.stdout),
String::from_utf8_lossy(&existing_search.stderr)
);
let existing_hits = serde_json::from_slice::<serde_json::Value>(&existing_search.stdout)
.expect("parse existing search json")
.get("hits")
.and_then(|hits| hits.as_array())
.map(|hits| hits.len())
.unwrap_or(0);
assert!(
existing_hits >= 1,
"the Tantivy index should remain authoritative for search results"
);
let incremental_index = command_env
.cass_assert_command()
.args(["index", "--data-dir"])
.arg(&data_dir)
.current_dir(home)
.env("CODEX_HOME", &codex_home)
.env("HOME", home)
.output()
.expect("run index after duplicate schema injection");
assert!(
incremental_index.status.success(),
"incremental index should succeed even when the fallback SQLite FTS table is malformed\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&incremental_index.stdout),
String::from_utf8_lossy(&incremental_index.stderr)
);
let health = command_env
.cass_assert_command()
.args(["health", "--json", "--data-dir"])
.arg(&data_dir)
.current_dir(home)
.env("CODEX_HOME", &codex_home)
.env("HOME", home)
.output()
.expect("run health after duplicate schema repair");
assert!(
health.status.success(),
"health should report the repaired database as healthy\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&health.stdout),
String::from_utf8_lossy(&health.stderr)
);
let health_json: serde_json::Value =
serde_json::from_slice(&health.stdout).expect("parse health json");
assert_eq!(
health_json["healthy"],
serde_json::Value::Bool(true),
"health should treat the canonical archive plus Tantivy index as healthy"
);
std::thread::sleep(std::time::Duration::from_millis(1200));
append_codex_session(&session_file, "fts_repair_appended_token", ts + 10_000);
std::thread::sleep(std::time::Duration::from_millis(50));
command_env
.cass_assert_command()
.args(["index", "--data-dir"])
.arg(&data_dir)
.current_dir(home)
.env("CODEX_HOME", &codex_home)
.env("HOME", home)
.assert()
.success();
let after_messages = count_messages(&db_path);
assert_eq!(
after_messages,
baseline_messages + 2,
"incremental writes should resume after repair and append the new turn"
);
let appended = command_env
.cass_assert_command()
.args([
"search",
"fts_repair_appended_token",
"--robot",
"--data-dir",
])
.arg(&data_dir)
.current_dir(home)
.env("CODEX_HOME", &codex_home)
.env("HOME", home)
.output()
.expect("search for appended content after repair");
assert!(
appended.status.success(),
"search should succeed after incremental write even with malformed fallback FTS\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&appended.stdout),
String::from_utf8_lossy(&appended.stderr)
);
let appended_hits = serde_json::from_slice::<serde_json::Value>(&appended.stdout)
.expect("parse appended search json")
.get("hits")
.and_then(|hits| hits.as_array())
.map(|hits| hits.len())
.unwrap_or(0);
assert!(
appended_hits >= 1,
"the post-index incremental content should be searchable"
);
tracker.flush();
}
#[test]
#[serial]
fn concurrent_search_processes_do_not_block_incremental_index_json() {
let tracker = tracker_for("concurrent_search_processes_do_not_block_incremental_index_json");
let tmp = tempfile::TempDir::new().unwrap();
let home = tmp.path().to_path_buf();
let codex_home = home.to_path_buf();
let data_dir = home.join("cass_data");
fs::create_dir_all(&data_dir).unwrap();
let command_env = tracker
.command_environment()
.with_home(&home)
.with_codex_home(&codex_home);
tracker.phase(
"seed_initial_fixture",
"Create baseline session and full index",
|| {
make_codex_session(
&codex_home,
"2024/11/20",
"rollout-baseline-lock-search.jsonl",
"baselinelockanchor",
1_732_118_400_000,
);
command_env
.cass_assert_command()
.args(["index", "--full", "--json", "--data-dir"])
.arg(&data_dir)
.current_dir(&home)
.env("CODEX_HOME", &codex_home)
.env("HOME", &home)
.timeout(Duration::from_secs(30))
.assert()
.success();
},
);
tracker.phase(
"verify_baseline_search_fixture",
"Confirm the baseline lexical query is searchable before starting concurrent readers",
|| {
let mut baseline_command = command_env.cass_assert_command();
baseline_command
.args([
"search",
"baselinelockanchor",
"--json",
"--mode",
"lexical",
"--fields",
"minimal",
"--limit",
"5",
"--data-dir",
])
.arg(&data_dir)
.current_dir(&home)
.env("CODEX_HOME", &codex_home)
.env("HOME", &home)
.timeout(Duration::from_secs(20));
let baseline_output = baseline_command
.output()
.expect("baseline lexical search should run");
assert!(
baseline_output.status.success(),
"baseline lexical search should succeed before concurrency begins\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&baseline_output.stdout),
String::from_utf8_lossy(&baseline_output.stderr)
);
let baseline_json: serde_json::Value =
serde_json::from_slice(&baseline_output.stdout).expect("parse baseline search JSON");
let baseline_hits = baseline_json
.get("total_matches")
.and_then(|matches| matches.as_u64())
.unwrap_or_else(|| {
baseline_json
.get("hits")
.and_then(|hits| hits.as_array())
.map(|hits| hits.len() as u64)
.unwrap_or(0)
});
assert!(
baseline_hits > 0,
"baseline lexical search fixture must be searchable before starting concurrent readers"
);
},
);
let stop_search = Arc::new(AtomicBool::new(false));
let index_running = Arc::new(AtomicBool::new(false));
let search_attempts_during_index = Arc::new(AtomicUsize::new(0));
let (ready_tx, ready_rx) = mpsc::channel();
let search_home = home.clone();
let search_codex_home = codex_home.clone();
let search_data_dir = data_dir.clone();
let stop_search_worker = Arc::clone(&stop_search);
let index_running_worker = Arc::clone(&index_running);
let search_attempts_during_index_worker = Arc::clone(&search_attempts_during_index);
let search_command_env = command_env.clone();
let search_handle = std::thread::spawn(move || {
let mut stats = SearchLoopStats::default();
let mut ready_sent = false;
loop {
if stop_search_worker.load(Ordering::Relaxed) {
break;
}
if index_running_worker.load(Ordering::Relaxed) {
search_attempts_during_index_worker.fetch_add(1, Ordering::Relaxed);
}
let search_start = Instant::now();
let mut search_command = search_command_env.cass_assert_command();
search_command
.args([
"search",
"baselinelockanchor",
"--json",
"--mode",
"lexical",
"--fields",
"minimal",
"--limit",
"5",
"--data-dir",
])
.arg(&search_data_dir)
.current_dir(&search_home)
.env("CODEX_HOME", &search_codex_home)
.env("HOME", &search_home)
.timeout(Duration::from_secs(20));
let output = search_command
.output()
.expect("spawn concurrent cass search");
let elapsed_ms = search_start.elapsed().as_millis() as u64;
stats.attempts += 1;
stats.max_duration_ms = stats.max_duration_ms.max(elapsed_ms);
if output.status.success() {
let parsed: serde_json::Value =
serde_json::from_slice(&output.stdout).expect("parse concurrent search JSON");
let hit_count = parsed
.get("total_matches")
.and_then(|matches| matches.as_u64())
.unwrap_or_else(|| {
parsed
.get("hits")
.and_then(|hits| hits.as_array())
.map(|hits| hits.len() as u64)
.unwrap_or(0)
});
if hit_count == 0 {
stats.failures.push(format!(
"concurrent search returned zero hits; stdout={}",
String::from_utf8_lossy(&output.stdout)
));
} else {
stats.successes += 1;
}
} else {
stats.failures.push(format!(
"concurrent search failed: status={:?}\nstdout:\n{}\nstderr:\n{}",
output.status.code(),
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
));
}
if !ready_sent {
ready_tx.send(()).ok();
ready_sent = true;
}
std::thread::sleep(Duration::from_millis(40));
}
stats
});
ready_rx
.recv_timeout(Duration::from_secs(10))
.expect("concurrent search should start promptly");
tracker.phase(
"stage_incremental_fixture",
"Create a substantial incremental batch while searches continue",
|| {
make_bulk_codex_sessions(
&codex_home,
"2024/11/21",
"rollout-incremental-lock-batch",
"incrementalloadanchor",
1_732_200_000_000,
40,
6,
);
},
);
let index_start = tracker.start(
"incremental_index_under_read_load",
Some("Run cass index --json while concurrent cass search processes read the same DB"),
);
index_running.store(true, Ordering::Relaxed);
let index_output = command_env
.cass_assert_command()
.args(["index", "--json", "--data-dir"])
.arg(&data_dir)
.current_dir(&home)
.env("CODEX_HOME", &codex_home)
.env("HOME", &home)
.timeout(Duration::from_secs(60))
.output()
.expect("run incremental index under concurrent search load");
index_running.store(false, Ordering::Relaxed);
let index_duration_ms = index_start.elapsed().as_millis() as u64;
tracker.end(
"incremental_index_under_read_load",
Some("Run cass index --json while concurrent cass search processes read the same DB"),
index_start,
);
stop_search.store(true, Ordering::Relaxed);
let search_stats = search_handle.join().expect("join concurrent search thread");
assert!(
index_output.status.success(),
"incremental index should succeed under concurrent search load\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&index_output.stdout),
String::from_utf8_lossy(&index_output.stderr)
);
let index_json: serde_json::Value =
serde_json::from_slice(&index_output.stdout).expect("parse index json");
assert_eq!(
index_json.get("success").and_then(|v| v.as_bool()),
Some(true),
"index --json should report success"
);
assert!(
search_stats.failures.is_empty(),
"concurrent searches should not fail while index runs:\n{}",
search_stats.failures.join("\n---\n")
);
assert!(
search_stats.successes > 0,
"expected at least one successful concurrent search attempt"
);
assert!(
search_attempts_during_index.load(Ordering::Relaxed) > 0,
"expected real search overlap while index was running"
);
let after_messages = count_messages(&data_dir.join("agent_search.db")) as u64;
let expected_min_messages = 2 + (40_u64 * 6 * 2);
assert!(
after_messages >= expected_min_messages,
"incremental index should ingest the staged batch: expected at least {expected_min_messages} messages, got {after_messages}"
);
let mut verify_command = command_env.cass_assert_command();
verify_command
.args([
"search",
"incrementalloadanchor",
"--json",
"--mode",
"lexical",
"--fields",
"minimal",
"--limit",
"10",
"--data-dir",
])
.arg(&data_dir)
.current_dir(&home)
.env("CODEX_HOME", &codex_home)
.env("HOME", &home)
.timeout(Duration::from_secs(20));
let verify_search = verify_command
.output()
.expect("search for newly indexed batch");
assert!(
verify_search.status.success(),
"search for newly indexed batch should succeed\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&verify_search.stdout),
String::from_utf8_lossy(&verify_search.stderr)
);
let verify_json: serde_json::Value =
serde_json::from_slice(&verify_search.stdout).expect("parse verification search JSON");
let verification_hits = verify_json
.get("total_matches")
.and_then(|matches| matches.as_u64())
.unwrap_or_else(|| {
verify_json
.get("hits")
.and_then(|hits| hits.as_array())
.map(|hits| hits.len() as u64)
.unwrap_or(0)
});
assert!(
verification_hits > 0,
"newly indexed batch should be searchable after concurrent index run"
);
tracker.metrics(
"concurrent_search_vs_index",
&E2ePerformanceMetrics::new()
.with_duration(index_duration_ms)
.with_custom("search_attempts", search_stats.attempts as u64)
.with_custom("search_successes", search_stats.successes as u64)
.with_custom(
"search_attempts_during_index",
search_attempts_during_index.load(Ordering::Relaxed) as u64,
)
.with_custom("max_search_duration_ms", search_stats.max_duration_ms)
.with_custom("messages_after_index", after_messages),
);
tracker.complete();
}
#[test]
#[serial]
fn force_rebuild_preserves_search_results_and_reader_surface_during_atomic_publish() {
let tracker = tracker_for(
"force_rebuild_preserves_search_results_and_reader_surface_during_atomic_publish",
);
let tmp = tempfile::TempDir::new().unwrap();
let home = tmp.path().to_path_buf();
let codex_home = home.to_path_buf();
let data_dir = home.join("cass_data");
fs::create_dir_all(&data_dir).unwrap();
let command_env = tracker
.command_environment()
.with_home(&home)
.with_codex_home(&codex_home);
const QUERY: &str = "atomicswapsearchanchor";
tracker.phase(
"seed_and_index_single_shard_fixture",
"Create a minimal fixture and build the baseline lexical index",
|| {
make_codex_session(
&codex_home,
"2024/11/22",
"rollout-atomic-search-consistency.jsonl",
QUERY,
1_732_300_000_000,
);
command_env
.cass_assert_command()
.args(["index", "--full", "--json", "--data-dir"])
.arg(&data_dir)
.current_dir(&home)
.env("CODEX_HOME", &codex_home)
.env("HOME", &home)
.timeout(Duration::from_secs(90))
.assert()
.success();
},
);
let live_index_path = index_dir(&data_dir).expect("resolve live Tantivy index path");
let before_summary = searchable_index_summary(&live_index_path)
.expect("read baseline searchable index summary")
.expect("baseline index should exist");
let before_docs = before_summary.docs;
assert!(
before_docs > 0,
"baseline index should contain at least one doc"
);
let mut baseline_command = command_env.cass_assert_command();
baseline_command
.args([
"search",
QUERY,
"--json",
"--mode",
"lexical",
"--fields",
"minimal",
"--limit",
"5",
"--data-dir",
])
.arg(&data_dir)
.current_dir(&home)
.env("CODEX_HOME", &codex_home)
.env("HOME", &home)
.timeout(Duration::from_secs(20));
let baseline_search = baseline_command
.output()
.expect("run baseline lexical search");
assert!(
baseline_search.status.success(),
"baseline lexical search should succeed before force rebuild\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&baseline_search.stdout),
String::from_utf8_lossy(&baseline_search.stderr)
);
let baseline_total_matches = total_matches_from_search_output(&baseline_search.stdout);
assert!(
baseline_total_matches > 0,
"baseline lexical search should return at least one hit before rebuild"
);
let stop = Arc::new(AtomicBool::new(false));
let rebuild_running = Arc::new(AtomicBool::new(false));
let search_in_flight = Arc::new(AtomicBool::new(false));
let reader_attempts_during_rebuild = Arc::new(AtomicUsize::new(0));
let search_attempts_during_rebuild = Arc::new(AtomicUsize::new(0));
let (ready_tx, ready_rx) = mpsc::channel();
let reader_ready_tx = ready_tx.clone();
let reader_stop = Arc::clone(&stop);
let reader_rebuild_running = Arc::clone(&rebuild_running);
let reader_overlap = Arc::clone(&reader_attempts_during_rebuild);
let reader_index_path = live_index_path.clone();
let reader_deadline = Instant::now() + Duration::from_secs(20);
let reader_handle = std::thread::spawn(move || {
let _ = reader_ready_tx.send("reader");
let mut observations: Vec<Result<Option<SearchableIndexSummary>, String>> = Vec::new();
while !reader_stop.load(Ordering::Relaxed) && Instant::now() < reader_deadline {
if reader_rebuild_running.load(Ordering::Relaxed) {
reader_overlap.fetch_add(1, Ordering::Relaxed);
}
let obs = searchable_index_summary(&reader_index_path).map_err(|e| format!("{e:#}"));
observations.push(obs);
std::thread::sleep(Duration::from_millis(1));
}
observations
});
let search_ready_tx = ready_tx.clone();
let search_stop = Arc::clone(&stop);
let search_rebuild_running = Arc::clone(&rebuild_running);
let search_in_flight_thread = Arc::clone(&search_in_flight);
let search_overlap = Arc::clone(&search_attempts_during_rebuild);
let search_home = home.clone();
let search_codex_home = codex_home.clone();
let search_data_dir = data_dir.clone();
let search_command_env = command_env.clone();
let search_handle = std::thread::spawn(move || {
let _ = search_ready_tx.send("search");
let mut stats = SearchLoopStats::default();
while !search_stop.load(Ordering::Relaxed) {
let search_started = Instant::now();
search_in_flight_thread.store(true, Ordering::Relaxed);
let started_during_rebuild = search_rebuild_running.load(Ordering::Relaxed);
let mut search_command = search_command_env.cass_assert_command();
search_command
.args([
"search",
QUERY,
"--json",
"--mode",
"lexical",
"--fields",
"minimal",
"--limit",
"5",
"--data-dir",
])
.arg(&search_data_dir)
.current_dir(&search_home)
.env("CODEX_HOME", &search_codex_home)
.env("HOME", &search_home)
.timeout(Duration::from_secs(20));
let output = search_command.output().expect("run concurrent cass search");
search_in_flight_thread.store(false, Ordering::Relaxed);
let search_finished = Instant::now();
if started_during_rebuild || search_rebuild_running.load(Ordering::Relaxed) {
search_overlap.fetch_add(1, Ordering::Relaxed);
}
let elapsed_ms = search_finished.duration_since(search_started).as_millis() as u64;
stats.attempts += 1;
stats.max_duration_ms = stats.max_duration_ms.max(elapsed_ms);
if output.status.success() {
let hit_count = total_matches_from_search_output(&output.stdout);
if hit_count != baseline_total_matches {
stats.failures.push(format!(
"concurrent lexical search returned {hit_count} hits; expected stable total_matches={baseline_total_matches}\nstdout:\n{}",
String::from_utf8_lossy(&output.stdout)
));
} else {
stats.successes += 1;
}
} else {
stats.failures.push(format!(
"concurrent lexical search failed: status={:?}\nstdout:\n{}\nstderr:\n{}",
output.status.code(),
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
));
}
std::thread::sleep(Duration::from_millis(40));
}
stats
});
drop(ready_tx);
for _ in 0..2 {
ready_rx
.recv_timeout(Duration::from_secs(10))
.expect("reader and search concurrency helpers should start promptly");
}
let rebuild_start = tracker.start(
"force_rebuild_under_concurrent_reader_and_search",
Some("Run cass index --full --force-rebuild while a direct reader and cass search poll the same live index"),
);
rebuild_running.store(true, Ordering::Relaxed);
if search_in_flight.load(Ordering::Relaxed) {
search_attempts_during_rebuild.fetch_add(1, Ordering::Relaxed);
}
let publish_pause_sentinel = home.join("atomic-publish-overlap-sentinel.json");
let mut attempt = 0usize;
let rebuild_output = loop {
let output = command_env
.cass_assert_command()
.args(["index", "--full", "--force-rebuild", "--json", "--data-dir"])
.arg(&data_dir)
.current_dir(&home)
.env("CODEX_HOME", &codex_home)
.env("HOME", &home)
.env(
"CASS_TEST_LEXICAL_PUBLISH_KILL_RELAUNCH_SENTINEL",
&publish_pause_sentinel,
)
.env("CASS_TEST_LEXICAL_PUBLISH_KILL_RELAUNCH_SLEEP_MS", "2000")
.timeout(Duration::from_secs(60))
.output()
.expect("run force rebuild under concurrent read/search load");
let retry_busy = !output.status.success()
&& command_output_kind_is(&output.stdout, "index-busy")
&& attempt < 4;
if !retry_busy {
break output;
}
attempt += 1;
std::thread::sleep(Duration::from_millis(200));
};
rebuild_running.store(false, Ordering::Relaxed);
let rebuild_duration_ms = rebuild_start.elapsed().as_millis() as u64;
tracker.end(
"force_rebuild_under_concurrent_reader_and_search",
Some("Run cass index --full --force-rebuild while a direct reader and cass search poll the same live index"),
rebuild_start,
);
stop.store(true, Ordering::Relaxed);
let reader_observations = reader_handle.join().expect("join reader thread");
let search_stats = search_handle.join().expect("join search thread");
assert!(
rebuild_output.status.success(),
"force rebuild should succeed under concurrent reader/search load\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&rebuild_output.stdout),
String::from_utf8_lossy(&rebuild_output.stderr)
);
let rebuild_json: serde_json::Value =
serde_json::from_slice(&rebuild_output.stdout).expect("parse force rebuild json");
assert_eq!(
rebuild_json
.get("success")
.and_then(|value| value.as_bool()),
Some(true),
"force rebuild should report success in --json output"
);
assert!(
!reader_observations.is_empty(),
"reader should collect at least one observation during the force rebuild window"
);
assert!(
reader_attempts_during_rebuild.load(Ordering::Relaxed) > 0,
"expected direct reader overlap while force rebuild was running"
);
for (idx, observation) in reader_observations.iter().enumerate() {
if let Ok(Some(summary)) = observation {
assert_eq!(
summary.docs, before_docs,
"reader observation #{idx} returned docs={} instead of the stable count {before_docs}; \
this indicates a half-torn lexical index surface during atomic publish",
summary.docs
);
}
}
assert!(
search_attempts_during_rebuild.load(Ordering::Relaxed) > 0,
"expected cass search overlap while force rebuild was running"
);
assert!(
search_stats.failures.is_empty(),
"concurrent cass search should stay logically stable during force rebuild:\n{}",
search_stats.failures.join("\n---\n")
);
assert!(
search_stats.successes > 0,
"expected at least one successful concurrent cass search attempt"
);
let after_summary = searchable_index_summary(&live_index_path)
.expect("read searchable summary after rebuild")
.expect("live index should still exist after rebuild");
assert_eq!(
after_summary.docs, before_docs,
"force rebuild on unchanged content should preserve the live doc count"
);
let mut after_command = command_env.cass_assert_command();
after_command
.args([
"search",
QUERY,
"--json",
"--mode",
"lexical",
"--fields",
"minimal",
"--limit",
"5",
"--data-dir",
])
.arg(&data_dir)
.current_dir(&home)
.env("CODEX_HOME", &codex_home)
.env("HOME", &home)
.timeout(Duration::from_secs(20));
let after_search = after_command
.output()
.expect("run post-rebuild lexical search");
assert!(
after_search.status.success(),
"post-rebuild lexical search should succeed\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&after_search.stdout),
String::from_utf8_lossy(&after_search.stderr)
);
let after_total_matches = total_matches_from_search_output(&after_search.stdout);
assert_eq!(
after_total_matches, baseline_total_matches,
"force rebuild on unchanged content should preserve the logical search result count"
);
tracker.metrics(
"force_rebuild_concurrency_surface",
&E2ePerformanceMetrics::new()
.with_duration(rebuild_duration_ms)
.with_custom(
"reader_attempts_during_rebuild",
reader_attempts_during_rebuild.load(Ordering::Relaxed) as u64,
)
.with_custom(
"search_attempts_during_rebuild",
search_attempts_during_rebuild.load(Ordering::Relaxed) as u64,
)
.with_custom("search_attempts_total", search_stats.attempts as u64)
.with_custom("search_successes_total", search_stats.successes as u64)
.with_custom("max_search_duration_ms", search_stats.max_duration_ms)
.with_custom("stable_doc_count", before_docs as u64)
.with_custom("stable_total_matches", baseline_total_matches),
);
tracker.complete();
}
#[test]
#[serial]
// Exercises the FEDERATED atomic-publish path, which is the Tantivy-only
// staged-shard machinery disabled for the Quill backend. The single-index
// atomic swap that actually runs is covered by
// tests/atomic_swap_publish_crash_window.rs. Ignored, not weakened.
#[ignore = "federated staged-shard publish is Tantivy-only; disabled on the Quill backend"]
fn force_rebuild_preserves_search_results_and_reader_surface_during_federated_atomic_publish() {
let tracker = tracker_for(
"force_rebuild_preserves_search_results_and_reader_surface_during_federated_atomic_publish",
);
let tmp = tempfile::TempDir::new().unwrap();
let home = tmp.path().to_path_buf();
let codex_home = home.to_path_buf();
let data_dir = home.join("cass_data");
fs::create_dir_all(&data_dir).unwrap();
let command_env = tracker
.command_environment()
.with_home(&home)
.with_codex_home(&codex_home);
const QUERY: &str = "federatedatomicswapsearchanchor";
for (filename, content, ts) in [
(
"rollout-fed-atomic-1.jsonl",
format!("{QUERY} session_alpha"),
1_732_310_000_000_u64,
),
(
"rollout-fed-atomic-2.jsonl",
format!("{QUERY} session_beta"),
1_732_310_100_000_u64,
),
(
"rollout-fed-atomic-3.jsonl",
format!("{QUERY} session_gamma"),
1_732_310_200_000_u64,
),
] {
make_codex_session(&codex_home, "2024/11/23", filename, &content, ts);
}
tracker.phase(
"seed_and_index_federated_fixture",
"Create three sessions and force a federated lexical publish bundle",
|| {
let mut initial_index = command_env.cass_assert_command();
force_federated_publish_env(&mut initial_index);
initial_index
.args(["index", "--full", "--json", "--data-dir"])
.arg(&data_dir)
.current_dir(&home)
.env("CODEX_HOME", &codex_home)
.env("HOME", &home)
.timeout(Duration::from_secs(30))
.assert()
.success();
},
);
let live_index_path = index_dir(&data_dir).expect("resolve live Tantivy index path");
let before_summary = searchable_index_summary(&live_index_path)
.expect("read baseline federated searchable index summary")
.expect("baseline federated index should exist");
let before_docs = before_summary.docs;
assert!(
before_docs >= 3,
"baseline federated index should contain multiple docs"
);
let before_federated_readers = open_federated_search_readers(&live_index_path)
.expect("load federated readers before rebuild")
.expect("baseline federated manifest should exist");
assert!(
before_federated_readers.len() > 1,
"forced shard planner settings should produce a federated live index"
);
let mut baseline_command = command_env.cass_assert_command();
baseline_command
.args([
"search",
QUERY,
"--json",
"--mode",
"lexical",
"--fields",
"minimal",
"--limit",
"10",
"--data-dir",
])
.arg(&data_dir)
.current_dir(&home)
.env("CODEX_HOME", &codex_home)
.env("HOME", &home)
.timeout(Duration::from_secs(20));
let baseline_search = baseline_command
.output()
.expect("run baseline federated lexical search");
assert!(
baseline_search.status.success(),
"baseline federated lexical search should succeed before force rebuild\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&baseline_search.stdout),
String::from_utf8_lossy(&baseline_search.stderr)
);
let baseline_total_matches = total_matches_from_search_output(&baseline_search.stdout);
assert!(
baseline_total_matches > 0,
"baseline federated lexical search should return at least one hit before rebuild"
);
let stop = Arc::new(AtomicBool::new(false));
let rebuild_running = Arc::new(AtomicBool::new(false));
let search_in_flight = Arc::new(AtomicBool::new(false));
let reader_attempts_during_rebuild = Arc::new(AtomicUsize::new(0));
let search_attempts_during_rebuild = Arc::new(AtomicUsize::new(0));
let (ready_tx, ready_rx) = mpsc::channel();
let reader_ready_tx = ready_tx.clone();
let reader_stop = Arc::clone(&stop);
let reader_rebuild_running = Arc::clone(&rebuild_running);
let reader_overlap = Arc::clone(&reader_attempts_during_rebuild);
let reader_index_path = live_index_path.clone();
let reader_deadline = Instant::now() + Duration::from_secs(20);
let reader_handle = std::thread::spawn(move || {
let _ = reader_ready_tx.send("reader");
let mut observations: Vec<Result<Option<SearchableIndexSummary>, String>> = Vec::new();
while !reader_stop.load(Ordering::Relaxed) && Instant::now() < reader_deadline {
if reader_rebuild_running.load(Ordering::Relaxed) {
reader_overlap.fetch_add(1, Ordering::Relaxed);
}
let obs = searchable_index_summary(&reader_index_path).map_err(|e| format!("{e:#}"));
observations.push(obs);
std::thread::sleep(Duration::from_millis(1));
}
observations
});
let search_ready_tx = ready_tx.clone();
let search_stop = Arc::clone(&stop);
let search_rebuild_running = Arc::clone(&rebuild_running);
let search_in_flight_thread = Arc::clone(&search_in_flight);
let search_overlap = Arc::clone(&search_attempts_during_rebuild);
let search_home = home.clone();
let search_codex_home = codex_home.clone();
let search_data_dir = data_dir.clone();
let search_command_env = command_env.clone();
let search_handle = std::thread::spawn(move || {
let _ = search_ready_tx.send("search");
let mut stats = SearchLoopStats::default();
while !search_stop.load(Ordering::Relaxed) {
let search_started = Instant::now();
search_in_flight_thread.store(true, Ordering::Relaxed);
let started_during_rebuild = search_rebuild_running.load(Ordering::Relaxed);
let output = search_command_env
.cass_assert_command()
.args([
"search",
QUERY,
"--json",
"--mode",
"lexical",
"--fields",
"minimal",
"--limit",
"10",
"--data-dir",
])
.arg(&search_data_dir)
.current_dir(&search_home)
.env("CODEX_HOME", &search_codex_home)
.env("HOME", &search_home)
.timeout(Duration::from_secs(20))
.output()
.expect("run concurrent federated cass search");
search_in_flight_thread.store(false, Ordering::Relaxed);
let search_finished = Instant::now();
if started_during_rebuild || search_rebuild_running.load(Ordering::Relaxed) {
search_overlap.fetch_add(1, Ordering::Relaxed);
}
let elapsed_ms = search_finished.duration_since(search_started).as_millis() as u64;
stats.attempts += 1;
stats.max_duration_ms = stats.max_duration_ms.max(elapsed_ms);
if output.status.success() {
let hit_count = total_matches_from_search_output(&output.stdout);
if hit_count != baseline_total_matches {
stats.failures.push(format!(
"concurrent federated lexical search returned {hit_count} hits; expected stable total_matches={baseline_total_matches}\nstdout:\n{}",
String::from_utf8_lossy(&output.stdout)
));
} else {
stats.successes += 1;
}
} else {
stats.failures.push(format!(
"concurrent federated lexical search failed: status={:?}\nstdout:\n{}\nstderr:\n{}",
output.status.code(),
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
));
}
std::thread::sleep(Duration::from_millis(40));
}
stats
});
drop(ready_tx);
for _ in 0..2 {
ready_rx
.recv_timeout(Duration::from_secs(10))
.expect("reader and search concurrency helpers should start promptly");
}
let rebuild_start = tracker.start(
"force_federated_rebuild_under_concurrent_reader_and_search",
Some("Run cass index --full --force-rebuild with forced multi-shard planning while a direct reader and cass search poll the same live index"),
);
rebuild_running.store(true, Ordering::Relaxed);
if search_in_flight.load(Ordering::Relaxed) {
search_attempts_during_rebuild.fetch_add(1, Ordering::Relaxed);
}
let mut rebuild = command_env.cass_assert_command();
force_federated_publish_env(&mut rebuild);
let publish_pause_sentinel = home.join("federated-atomic-publish-overlap-sentinel.json");
let rebuild_output = rebuild
.args(["index", "--full", "--force-rebuild", "--json", "--data-dir"])
.arg(&data_dir)
.current_dir(&home)
.env("CODEX_HOME", &codex_home)
.env("HOME", &home)
.env(
"CASS_TEST_LEXICAL_PUBLISH_KILL_RELAUNCH_SENTINEL",
&publish_pause_sentinel,
)
.env("CASS_TEST_LEXICAL_PUBLISH_KILL_RELAUNCH_SLEEP_MS", "2000")
.timeout(Duration::from_secs(60))
.output()
.expect("run federated force rebuild under concurrent read/search load");
rebuild_running.store(false, Ordering::Relaxed);
let rebuild_duration_ms = rebuild_start.elapsed().as_millis() as u64;
tracker.end(
"force_federated_rebuild_under_concurrent_reader_and_search",
Some("Run cass index --full --force-rebuild with forced multi-shard planning while a direct reader and cass search poll the same live index"),
rebuild_start,
);
stop.store(true, Ordering::Relaxed);
let reader_observations = reader_handle.join().expect("join federated reader thread");
let search_stats = search_handle.join().expect("join federated search thread");
assert!(
rebuild_output.status.success(),
"federated force rebuild should succeed under concurrent reader/search load\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&rebuild_output.stdout),
String::from_utf8_lossy(&rebuild_output.stderr)
);
let rebuild_json: serde_json::Value =
serde_json::from_slice(&rebuild_output.stdout).expect("parse federated force rebuild json");
assert_eq!(
rebuild_json
.get("success")
.and_then(|value| value.as_bool()),
Some(true),
"federated force rebuild should report success in --json output"
);
assert!(
!reader_observations.is_empty(),
"reader should collect at least one observation during the federated force rebuild window"
);
assert!(
reader_attempts_during_rebuild.load(Ordering::Relaxed) > 0,
"expected direct reader overlap while federated force rebuild was running"
);
for (idx, observation) in reader_observations.iter().enumerate() {
if let Ok(Some(summary)) = observation {
assert_eq!(
summary.docs, before_docs,
"federated reader observation #{idx} returned docs={} instead of the stable count {before_docs}; \
this indicates a half-torn federated lexical index surface during atomic publish",
summary.docs
);
}
}
assert!(
search_attempts_during_rebuild.load(Ordering::Relaxed) > 0,
"expected cass search overlap while federated force rebuild was running"
);
assert!(
search_stats.failures.is_empty(),
"concurrent cass search should stay logically stable during federated force rebuild:\n{}",
search_stats.failures.join("\n---\n")
);
assert!(
search_stats.successes > 0,
"expected at least one successful concurrent federated cass search attempt"
);
let after_summary = searchable_index_summary(&live_index_path)
.expect("read searchable summary after federated rebuild")
.expect("live index should still exist after federated rebuild");
assert_eq!(
after_summary.docs, before_docs,
"federated force rebuild on unchanged content should preserve the live doc count"
);
let after_federated_readers = open_federated_search_readers(&live_index_path)
.expect("load federated readers after rebuild")
.expect("federated manifest should still exist after rebuild");
assert!(
after_federated_readers.len() > 1,
"post-rebuild live surface should remain a federated lexical bundle"
);
let after_search = command_env
.cass_assert_command()
.args([
"search",
QUERY,
"--json",
"--mode",
"lexical",
"--fields",
"minimal",
"--limit",
"10",
"--data-dir",
])
.arg(&data_dir)
.current_dir(&home)
.env("CODEX_HOME", &codex_home)
.env("HOME", &home)
.timeout(Duration::from_secs(20))
.output()
.expect("run post-rebuild federated lexical search");
assert!(
after_search.status.success(),
"post-rebuild federated lexical search should succeed\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&after_search.stdout),
String::from_utf8_lossy(&after_search.stderr)
);
let after_total_matches = total_matches_from_search_output(&after_search.stdout);
assert_eq!(
after_total_matches, baseline_total_matches,
"federated force rebuild on unchanged content should preserve the logical search result count"
);
tracker.metrics(
"force_federated_rebuild_concurrency_surface",
&E2ePerformanceMetrics::new()
.with_duration(rebuild_duration_ms)
.with_custom(
"reader_attempts_during_rebuild",
reader_attempts_during_rebuild.load(Ordering::Relaxed) as u64,
)
.with_custom(
"search_attempts_during_rebuild",
search_attempts_during_rebuild.load(Ordering::Relaxed) as u64,
)
.with_custom("search_attempts_total", search_stats.attempts as u64)
.with_custom("search_successes_total", search_stats.successes as u64)
.with_custom("max_search_duration_ms", search_stats.max_duration_ms)
.with_custom("stable_doc_count", before_docs as u64)
.with_custom("stable_total_matches", baseline_total_matches)
.with_custom(
"federated_shard_count",
after_federated_readers.len() as u64,
),
);
tracker.complete();
}
#[test]
#[serial]
// Exercises the FEDERATED atomic-publish path, which is the Tantivy-only
// staged-shard machinery disabled for the Quill backend. The single-index
// atomic swap that actually runs is covered by
// tests/atomic_swap_publish_crash_window.rs. Ignored, not weakened.
#[ignore = "federated staged-shard publish is Tantivy-only; disabled on the Quill backend"]
fn repeated_force_rebuild_preserves_federated_reader_and_search_stability() {
let tracker =
tracker_for("repeated_force_rebuild_preserves_federated_reader_and_search_stability");
let tmp = tempfile::TempDir::new().unwrap();
let home = tmp.path().to_path_buf();
let codex_home = home.to_path_buf();
let data_dir = home.join("cass_data");
fs::create_dir_all(&data_dir).unwrap();
let command_env = tracker
.command_environment()
.with_home(&home)
.with_codex_home(&codex_home);
const QUERY: &str = "federatedrebuildstabilityanchor";
const REBUILD_CYCLES: usize = 20;
for (filename, content, ts) in [
(
"rollout-fed-stability-1.jsonl",
format!("{QUERY} stability_alpha"),
1_732_311_000_000_u64,
),
(
"rollout-fed-stability-2.jsonl",
format!("{QUERY} stability_beta"),
1_732_311_100_000_u64,
),
(
"rollout-fed-stability-3.jsonl",
format!("{QUERY} stability_gamma"),
1_732_311_200_000_u64,
),
] {
make_codex_session(&codex_home, "2024/11/24", filename, &content, ts);
}
tracker.phase(
"seed_and_index_repeated_federated_fixture",
"Create three sessions and force an initial federated lexical publish bundle",
|| {
let mut initial_index = command_env.cass_assert_command();
force_federated_publish_env(&mut initial_index);
initial_index
.args(["index", "--full", "--json", "--data-dir"])
.arg(&data_dir)
.current_dir(&home)
.env("CODEX_HOME", &codex_home)
.env("HOME", &home)
.timeout(Duration::from_secs(30))
.assert()
.success();
},
);
let live_index_path = index_dir(&data_dir).expect("resolve live Tantivy index path");
let before_summary = searchable_index_summary(&live_index_path)
.expect("read baseline federated searchable index summary")
.expect("baseline federated index should exist");
let before_docs = before_summary.docs;
assert!(
before_docs >= 3,
"baseline federated index should contain multiple docs"
);
let before_federated_readers = open_federated_search_readers(&live_index_path)
.expect("load federated readers before repeated rebuilds")
.expect("baseline federated manifest should exist");
let baseline_federated_reader_count = before_federated_readers.len();
assert!(
baseline_federated_reader_count > 1,
"forced shard planner settings should produce a federated live index"
);
let baseline_search = command_env
.cass_assert_command()
.args([
"search",
QUERY,
"--json",
"--mode",
"lexical",
"--fields",
"minimal",
"--limit",
"10",
"--data-dir",
])
.arg(&data_dir)
.current_dir(&home)
.env("CODEX_HOME", &codex_home)
.env("HOME", &home)
.timeout(Duration::from_secs(20))
.output()
.expect("run baseline repeated federated lexical search");
assert!(
baseline_search.status.success(),
"baseline repeated federated lexical search should succeed before force rebuild loop\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&baseline_search.stdout),
String::from_utf8_lossy(&baseline_search.stderr)
);
let baseline_total_matches = total_matches_from_search_output(&baseline_search.stdout);
assert!(
baseline_total_matches > 0,
"baseline repeated federated lexical search should return at least one hit before rebuild loop"
);
let repeated_rebuild_started = tracker.start(
"repeat_federated_force_rebuilds_and_validate_stability",
Some("Run repeated cass index --full --force-rebuild cycles with forced multi-shard planning and verify reader/search stability after every publish"),
);
let mut max_rebuild_duration_ms = 0_u64;
for cycle in 0..REBUILD_CYCLES {
let rebuild_started = Instant::now();
let mut rebuild = command_env.cass_assert_command();
force_federated_publish_env(&mut rebuild);
let rebuild_output = rebuild
.args(["index", "--full", "--force-rebuild", "--json", "--data-dir"])
.arg(&data_dir)
.current_dir(&home)
.env("CODEX_HOME", &codex_home)
.env("HOME", &home)
.timeout(Duration::from_secs(60))
.output()
.expect("run repeated federated force rebuild");
let rebuild_duration_ms = rebuild_started.elapsed().as_millis() as u64;
max_rebuild_duration_ms = max_rebuild_duration_ms.max(rebuild_duration_ms);
assert!(
rebuild_output.status.success(),
"repeated federated force rebuild cycle {cycle} should succeed\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&rebuild_output.stdout),
String::from_utf8_lossy(&rebuild_output.stderr)
);
let rebuild_json: serde_json::Value = serde_json::from_slice(&rebuild_output.stdout)
.expect("parse repeated federated force rebuild json");
assert_eq!(
rebuild_json
.get("success")
.and_then(|value| value.as_bool()),
Some(true),
"repeated federated force rebuild cycle {cycle} should report success in --json output"
);
let cycle_summary = searchable_index_summary(&live_index_path)
.expect("read searchable summary after repeated federated rebuild cycle")
.expect("live index should exist after repeated federated rebuild cycle");
assert_eq!(
cycle_summary.docs, before_docs,
"repeated federated force rebuild cycle {cycle} changed the live doc count from {before_docs} to {}; \
the publish path should remain stable for unchanged content",
cycle_summary.docs
);
let cycle_federated_readers = open_federated_search_readers(&live_index_path)
.expect("load federated readers after repeated rebuild cycle")
.expect("federated manifest should exist after repeated rebuild cycle");
assert_eq!(
cycle_federated_readers.len(),
baseline_federated_reader_count,
"repeated federated force rebuild cycle {cycle} changed the shard bundle width from {baseline_federated_reader_count} to {}; \
forced federated publish should remain structurally stable",
cycle_federated_readers.len()
);
let cycle_search = command_env
.cass_assert_command()
.args([
"search",
QUERY,
"--json",
"--mode",
"lexical",
"--fields",
"minimal",
"--limit",
"10",
"--data-dir",
])
.arg(&data_dir)
.current_dir(&home)
.env("CODEX_HOME", &codex_home)
.env("HOME", &home)
.timeout(Duration::from_secs(20))
.output()
.expect("run repeated post-rebuild federated lexical search");
assert!(
cycle_search.status.success(),
"repeated federated lexical search after cycle {cycle} should succeed\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&cycle_search.stdout),
String::from_utf8_lossy(&cycle_search.stderr)
);
let cycle_total_matches = total_matches_from_search_output(&cycle_search.stdout);
assert_eq!(
cycle_total_matches, baseline_total_matches,
"repeated federated force rebuild cycle {cycle} changed the logical lexical hit count from {baseline_total_matches} to {cycle_total_matches}"
);
}
tracker.end(
"repeat_federated_force_rebuilds_and_validate_stability",
Some("Run repeated cass index --full --force-rebuild cycles with forced multi-shard planning and verify reader/search stability after every publish"),
repeated_rebuild_started,
);
tracker.metrics(
"repeated_federated_rebuild_stability_surface",
&E2ePerformanceMetrics::new()
.with_duration(repeated_rebuild_started.elapsed().as_millis() as u64)
.with_custom("rebuild_cycles", REBUILD_CYCLES as u64)
.with_custom("max_rebuild_duration_ms", max_rebuild_duration_ms)
.with_custom("stable_doc_count", before_docs as u64)
.with_custom("stable_total_matches", baseline_total_matches)
.with_custom(
"federated_shard_count",
baseline_federated_reader_count as u64,
),
);
tracker.complete();
}
#[cfg(target_os = "linux")]
#[test]
#[serial]
// Exercises the FEDERATED atomic-publish path, which is the Tantivy-only
// staged-shard machinery disabled for the Quill backend. The single-index
// atomic swap that actually runs is covered by
// tests/atomic_swap_publish_crash_window.rs. Ignored, not weakened.
#[ignore = "federated staged-shard publish is Tantivy-only; disabled on the Quill backend"]
fn force_rebuild_recovers_cleanly_after_sigkill_between_linux_swap_and_retain() {
let tracker =
tracker_for("force_rebuild_recovers_cleanly_after_sigkill_between_linux_swap_and_retain");
let tmp = tempfile::TempDir::new().unwrap();
let home = tmp.path().to_path_buf();
let codex_home = home.to_path_buf();
let data_dir = home.join("cass_data");
fs::create_dir_all(&data_dir).unwrap();
let command_env = tracker
.command_environment()
.with_home(&home)
.with_codex_home(&codex_home);
const QUERY: &str = "killrelaunchpublishanchor";
tracker.phase(
"seed_and_index_single_shard_fixture",
"Create a minimal fixture and build the baseline lexical index",
|| {
make_codex_session(
&codex_home,
"2024/11/24",
"rollout-kill-relaunch.jsonl",
QUERY,
1_732_320_000_000,
);
command_env
.cass_assert_command()
.args(["index", "--full", "--json", "--data-dir"])
.arg(&data_dir)
.current_dir(&home)
.env("CODEX_HOME", &codex_home)
.env("HOME", &home)
.timeout(Duration::from_secs(30))
.assert()
.success();
},
);
let live_index_path = index_dir(&data_dir).expect("resolve live Tantivy index path");
let canonical_sidecar = lexical_publish_in_progress_backup_path(&live_index_path);
let backups_dir = lexical_publish_backups_dir(&live_index_path);
let before_summary = searchable_index_summary(&live_index_path)
.expect("read baseline searchable index summary")
.expect("baseline index should exist");
let before_docs = before_summary.docs;
assert!(
before_docs > 0,
"baseline index should contain at least one doc"
);
let baseline_search = command_env
.cass_assert_command()
.args([
"search",
QUERY,
"--json",
"--mode",
"lexical",
"--fields",
"minimal",
"--limit",
"5",
"--data-dir",
])
.arg(&data_dir)
.current_dir(&home)
.env("CODEX_HOME", &codex_home)
.env("HOME", &home)
.timeout(Duration::from_secs(20))
.output()
.expect("run baseline lexical search");
assert!(
baseline_search.status.success(),
"baseline lexical search should succeed before kill/relaunch test\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&baseline_search.stdout),
String::from_utf8_lossy(&baseline_search.stderr)
);
let baseline_total_matches = total_matches_from_search_output(&baseline_search.stdout);
assert!(
baseline_total_matches > 0,
"baseline lexical search should return at least one hit before kill/relaunch"
);
let sentinel_path = home.join("publish-kill-relaunch-sentinel.json");
let rebuild_start = tracker.start(
"sigkill_force_rebuild_in_linux_publish_window",
Some(
"Spawn cass index --full --force-rebuild, pause after NEW is live and OLD is parked, then SIGKILL the process",
),
);
let mut child = cass_std_cmd(&command_env, &home);
child.env(
"CASS_TEST_LEXICAL_PUBLISH_KILL_RELAUNCH_SENTINEL",
&sentinel_path,
);
child.env("CASS_TEST_LEXICAL_PUBLISH_KILL_RELAUNCH_SLEEP_MS", "30000");
child.args(["index", "--full", "--force-rebuild", "--json", "--data-dir"]);
child.arg(&data_dir);
let mut child = child.spawn().expect("spawn force rebuild child process");
let sentinel = wait_for_publish_kill_relaunch_sentinel(&sentinel_path, Duration::from_secs(20));
assert_eq!(
sentinel.get("stage").and_then(|value| value.as_str()),
Some("linux_swap_committed_prior_live_parked"),
"sentinel must prove the child paused after NEW went live and OLD was parked"
);
assert_eq!(
sentinel
.get("live_index_path")
.and_then(|value| value.as_str()),
Some(live_index_path.to_string_lossy().as_ref()),
"sentinel should describe the live index path under test"
);
assert_eq!(
sentinel
.get("canonical_sidecar_path")
.and_then(|value| value.as_str()),
Some(canonical_sidecar.to_string_lossy().as_ref()),
"sentinel should describe the canonical sidecar path under test"
);
assert!(
live_index_path.exists(),
"live lexical index must still exist while the child is paused in the publish window"
);
assert!(
canonical_sidecar.exists(),
"prior live generation must be parked at the canonical sidecar before SIGKILL"
);
let paused_summary = searchable_index_summary(&live_index_path)
.expect("read live summary while child is paused")
.expect("live index should remain readable while paused");
assert_eq!(
paused_summary.docs, before_docs,
"paused publish window must still expose the stable live doc count"
);
let paused_search = command_env
.cass_assert_command()
.args([
"search",
QUERY,
"--json",
"--mode",
"lexical",
"--fields",
"minimal",
"--limit",
"5",
"--data-dir",
])
.arg(&data_dir)
.current_dir(&home)
.env("CODEX_HOME", &codex_home)
.env("HOME", &home)
.timeout(Duration::from_secs(20))
.output()
.expect("run lexical search while child is paused");
assert!(
paused_search.status.success(),
"lexical search should still succeed while the child is paused in the publish window\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&paused_search.stdout),
String::from_utf8_lossy(&paused_search.stderr)
);
assert_eq!(
total_matches_from_search_output(&paused_search.stdout),
baseline_total_matches,
"paused publish window must preserve stable search results"
);
child.kill().expect("send SIGKILL to paused rebuild child");
let child_status = child.wait().expect("wait for killed rebuild child");
tracker.end(
"sigkill_force_rebuild_in_linux_publish_window",
Some(
"Spawn cass index --full --force-rebuild, pause after NEW is live and OLD is parked, then SIGKILL the process",
),
rebuild_start,
);
assert!(
!child_status.success(),
"SIGKILLed rebuild child must not report success"
);
assert!(
live_index_path.exists(),
"live lexical index must still exist immediately after SIGKILL"
);
assert!(
canonical_sidecar.exists(),
"SIGKILL should strand the canonical sidecar for restart recovery"
);
let relaunch_start = tracker.start(
"relaunch_force_rebuild_and_recover_sidecar",
Some(
"Relaunch cass index --full --force-rebuild and prove recovery finalizes the stranded sidecar cleanly",
),
);
let relaunch_output = command_env
.cass_assert_command()
.args(["index", "--full", "--force-rebuild", "--json", "--data-dir"])
.arg(&data_dir)
.current_dir(&home)
.env("CODEX_HOME", &codex_home)
.env("HOME", &home)
.timeout(Duration::from_secs(60))
.output()
.expect("relaunch force rebuild after SIGKILL");
tracker.end(
"relaunch_force_rebuild_and_recover_sidecar",
Some(
"Relaunch cass index --full --force-rebuild and prove recovery finalizes the stranded sidecar cleanly",
),
relaunch_start,
);
assert!(
relaunch_output.status.success(),
"relaunch after SIGKILL should succeed\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&relaunch_output.stdout),
String::from_utf8_lossy(&relaunch_output.stderr)
);
let relaunch_json: serde_json::Value =
serde_json::from_slice(&relaunch_output.stdout).expect("parse relaunch index json");
assert_eq!(
relaunch_json
.get("success")
.and_then(|value| value.as_bool()),
Some(true),
"relaunch force rebuild should report success in --json output"
);
assert!(
!canonical_sidecar.exists(),
"relaunch recovery must consume the stranded canonical sidecar"
);
let retained_backup_count = fs::read_dir(&backups_dir)
.map(|entries| entries.filter_map(Result::ok).count())
.unwrap_or(0);
assert!(
retained_backup_count >= 1,
"relaunch recovery should retain at least one prior-live artifact after cleaning the stranded sidecar"
);
let after_summary = searchable_index_summary(&live_index_path)
.expect("read live summary after relaunch recovery")
.expect("live index should remain readable after relaunch recovery");
assert_eq!(
after_summary.docs, before_docs,
"relaunch recovery must preserve the stable live doc count"
);
let after_search = command_env
.cass_assert_command()
.args([
"search",
QUERY,
"--json",
"--mode",
"lexical",
"--fields",
"minimal",
"--limit",
"5",
"--data-dir",
])
.arg(&data_dir)
.current_dir(&home)
.env("CODEX_HOME", &codex_home)
.env("HOME", &home)
.timeout(Duration::from_secs(20))
.output()
.expect("run lexical search after relaunch recovery");
assert!(
after_search.status.success(),
"lexical search should succeed after relaunch recovery\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&after_search.stdout),
String::from_utf8_lossy(&after_search.stderr)
);
assert_eq!(
total_matches_from_search_output(&after_search.stdout),
baseline_total_matches,
"relaunch recovery must preserve stable lexical search results"
);
tracker.metrics(
"kill_relaunch_publish_recovery",
&E2ePerformanceMetrics::new()
.with_custom("stable_doc_count", before_docs as u64)
.with_custom("stable_total_matches", baseline_total_matches)
.with_custom(
"retained_backup_count_after_relaunch",
retained_backup_count as u64,
),
);
tracker.complete();
}
/// Test: Full index pipeline - index --full creates DB and index
#[test]
#[serial]
fn index_full_creates_artifacts() {
verbose!("Starting index_full_creates_artifacts test");
let tracker = tracker_for("index_full_creates_artifacts");
let tmp = tempfile::TempDir::new().unwrap();
let home = tmp.path();
verbose!("Created temp directory at {:?}", home);
let codex_home = home.to_path_buf();
let command_env = tracker
.command_environment()
.with_home(home)
.with_codex_home(&codex_home);
let data_dir = home.join("cass_data");
fs::create_dir_all(&data_dir).unwrap();
verbose!("Data directory: {:?}", data_dir);
// Create fixture data
let phase_start = tracker.start("create_fixtures", Some("Create Codex session fixture"));
make_codex_session(
&codex_home,
"2024/11/20",
"rollout-1.jsonl",
"hello world",
1732118400000,
);
tracker.end(
"create_fixtures",
Some("Create Codex session fixture"),
phase_start,
);
// Capture memory/IO before indexing (for delta calculation)
let mem_before = E2ePerformanceMetrics::capture_memory();
let io_before = E2ePerformanceMetrics::capture_io();
// Run index --full
let phase_start = tracker.start("index_full", Some("Execute full index command"));
command_env
.cass_assert_command()
.args(["index", "--full", "--data-dir"])
.arg(&data_dir)
// Avoid connector detection from the repository CWD (e.g. `.aider.chat.history.md`).
.current_dir(home)
.env("CODEX_HOME", &codex_home)
.env("HOME", home)
.assert()
.success();
let index_duration_ms = phase_start.elapsed().as_millis() as u64;
tracker.end(
"index_full",
Some("Execute full index command"),
phase_start,
);
// Capture memory/IO after indexing
let mem_after = E2ePerformanceMetrics::capture_memory();
let io_after = E2ePerformanceMetrics::capture_io();
verbose!("Index completed in {}ms", index_duration_ms);
// Verify artifacts created
let phase_start = tracker.start("verify_artifacts", Some("Verify database and index exist"));
verbose!("Verifying artifacts at {:?}", data_dir);
assert!(
data_dir.join("agent_search.db").exists(),
"SQLite DB should be created"
);
assert!(
data_dir.join("index").exists(),
"Tantivy index directory should exist"
);
tracker.end(
"verify_artifacts",
Some("Verify database and index exist"),
phase_start,
);
// Count messages and emit performance metrics
let msg_count = count_messages(&data_dir.join("agent_search.db")) as u64;
verbose!("Indexed {} messages", msg_count);
let mut metrics = E2ePerformanceMetrics::new()
.with_duration(index_duration_ms)
.with_throughput(msg_count, index_duration_ms);
// Add memory delta if available
if let (Some(before), Some(after)) = (mem_before, mem_after) {
metrics = metrics.with_memory(after.saturating_sub(before));
}
// Add I/O delta if available
if let (Some((rb, wb)), Some((ra, wa))) = (io_before, io_after) {
metrics = metrics.with_io(0, 0, ra.saturating_sub(rb), wa.saturating_sub(wb));
}
tracker.metrics("index_full", &metrics);
tracker.flush();
verbose!("Test index_full_creates_artifacts completed successfully");
}
/// Incremental re-index must preserve existing messages and ingest new ones from the same file.
#[test]
#[serial]
fn incremental_reindex_preserves_and_appends_messages() {
let tracker = tracker_for("incremental_reindex_preserves_and_appends_messages");
let tmp = tempfile::TempDir::new().unwrap();
let home = tmp.path();
let codex_home = home.to_path_buf();
let command_env = tracker
.command_environment()
.with_home(home)
.with_codex_home(&codex_home);
let data_dir = home.join("cass_data");
fs::create_dir_all(&data_dir).unwrap();
// Initial session
let phase_start = tracker.start(
"create_initial_fixture",
Some("Create initial session with test content"),
);
let ts = 1_732_118_400_000u64; // stable timestamp
make_codex_session(
&codex_home,
"2024/11/20",
"rollout-incremental.jsonl",
"initial_keep_token",
ts,
);
let session_file = codex_home.join("sessions/2024/11/20/rollout-incremental.jsonl");
tracker.end(
"create_initial_fixture",
Some("Create initial session with test content"),
phase_start,
);
// Full index
let phase_start = tracker.start("index_full", Some("Run initial full index"));
command_env
.cass_assert_command()
.args(["index", "--full", "--data-dir"])
.arg(&data_dir)
// Avoid connector detection from the repository CWD (e.g. `.aider.chat.history.md`).
.current_dir(home)
.env("CODEX_HOME", &codex_home)
.env("HOME", home)
.assert()
.success();
tracker.end("index_full", Some("Run initial full index"), phase_start);
// Ensure subsequent writes get a later mtime than the recorded scan start
std::thread::sleep(std::time::Duration::from_millis(1200));
// Baseline search should find the initial content
let phase_start = tracker.start(
"search_baseline",
Some("Verify initial content is searchable"),
);
let baseline = command_env
.cass_assert_command()
.args(["search", "initial_keep_token", "--robot", "--data-dir"])
.arg(&data_dir)
// Avoid connector detection from the repository CWD (e.g. `.aider.chat.history.md`).
.current_dir(home)
.env("HOME", home)
.output()
.expect("baseline search");
assert!(baseline.status.success());
let baseline_json: serde_json::Value =
serde_json::from_slice(&baseline.stdout).expect("baseline json");
let baseline_hits = baseline_json
.get("hits")
.and_then(|h| h.as_array())
.map(|v| v.len())
.unwrap_or(0);
assert!(baseline_hits >= 1, "initial content should be indexed");
tracker.end(
"search_baseline",
Some("Verify initial content is searchable"),
phase_start,
);
// Append new content to the same file (simulates conversation growth)
let phase_start = tracker.start(
"append_content",
Some("Append new messages to session file"),
);
append_codex_session(&session_file, "appended_token_beta", ts + 10_000);
tracker.end(
"append_content",
Some("Append new messages to session file"),
phase_start,
);
// On some filesystems, mtime resolution is 1s; give a small buffer before reindex
std::thread::sleep(std::time::Duration::from_millis(50));
// Incremental re-index (no --full)
let phase_start = tracker.start("index_incremental", Some("Run incremental reindex"));
command_env
.cass_assert_command()
.args(["index", "--data-dir"])
.arg(&data_dir)
// Avoid connector detection from the repository CWD (e.g. `.aider.chat.history.md`).
.current_dir(home)
.env("CODEX_HOME", &codex_home)
.env("HOME", home)
.assert()
.success();
tracker.end(
"index_incremental",
Some("Run incremental reindex"),
phase_start,
);
// Original content must still be present
let phase_start = tracker.start(
"search_preserved",
Some("Verify original content preserved"),
);
let preserved = command_env
.cass_assert_command()
.args(["search", "initial_keep_token", "--robot", "--data-dir"])
.arg(&data_dir)
// Avoid connector detection from the repository CWD (e.g. `.aider.chat.history.md`).
.current_dir(home)
.env("HOME", home)
.output()
.expect("preserved search");
assert!(preserved.status.success());
let preserved_hits = serde_json::from_slice::<serde_json::Value>(&preserved.stdout)
.unwrap()
.get("hits")
.and_then(|h| h.as_array())
.map(|v| v.len())
.unwrap_or(0);
assert!(
preserved_hits >= baseline_hits,
"existing messages should not be dropped on reindex"
);
tracker.end(
"search_preserved",
Some("Verify original content preserved"),
phase_start,
);
// New content must be discoverable
let phase_start = tracker.start("search_appended", Some("Verify appended content indexed"));
let appended = command_env
.cass_assert_command()
.args(["search", "appended_token_beta", "--robot", "--data-dir"])
.arg(&data_dir)
// Avoid connector detection from the repository CWD (e.g. `.aider.chat.history.md`).
.current_dir(home)
.env("HOME", home)
.output()
.expect("appended search");
assert!(appended.status.success());
let appended_hits = serde_json::from_slice::<serde_json::Value>(&appended.stdout)
.unwrap()
.get("hits")
.and_then(|h| h.as_array())
.map(|v| v.len())
.unwrap_or(0);
assert!(
appended_hits >= 1,
"appended content should be indexed during incremental run"
);
tracker.end(
"search_appended",
Some("Verify appended content indexed"),
phase_start,
);
tracker.flush();
}
/// Reindexing must never drop previously ingested messages in SQLite or Tantivy.
#[test]
#[serial]
fn reindex_does_not_drop_messages_in_db_or_search() {
let tracker = tracker_for("reindex_does_not_drop_messages_in_db_or_search");
let tmp = tempfile::TempDir::new().unwrap();
let home = tmp.path();
let codex_home = home.to_path_buf();
let command_env = tracker
.command_environment()
.with_home(home)
.with_codex_home(&codex_home);
let data_dir = home.join("cass_data");
fs::create_dir_all(&data_dir).unwrap();
let xdg_data = home.join(".local/share");
let xdg_config = home.join(".config");
fs::create_dir_all(&xdg_data).unwrap();
fs::create_dir_all(&xdg_config).unwrap();
// Seed a rollout with two messages
let ts = 1_732_118_400_000u64;
make_codex_session(
&codex_home,
"2024/11/20",
"rollout-drop-guard.jsonl",
"persist_me",
ts,
);
let session_file = codex_home.join("sessions/2024/11/20/rollout-drop-guard.jsonl");
command_env
.cass_assert_command()
.args(["index", "--full", "--data-dir"])
.arg(&data_dir)
// Avoid connector detection from the repository CWD (e.g. `.aider.chat.history.md`).
.current_dir(home)
.env("CODEX_HOME", &codex_home)
.env("HOME", home)
.env("XDG_DATA_HOME", &xdg_data)
.env("XDG_CONFIG_HOME", &xdg_config)
.assert()
.success();
// Ensure next write has strictly newer mtime than initial scan start
std::thread::sleep(std::time::Duration::from_millis(1200));
let db_path = data_dir.join("agent_search.db");
let baseline_count = count_messages(&db_path);
assert_eq!(baseline_count, 2, "initial two messages recorded");
// Append another turn and reindex incrementally
append_codex_session(&session_file, "persist_me_again", ts + 5_000);
std::thread::sleep(std::time::Duration::from_millis(50));
command_env
.cass_assert_command()
.args(["index", "--data-dir"])
.arg(&data_dir)
// Avoid connector detection from the repository CWD (e.g. `.aider.chat.history.md`).
.current_dir(home)
.env("CODEX_HOME", &codex_home)
.env("HOME", home)
.env("XDG_DATA_HOME", &xdg_data)
.env("XDG_CONFIG_HOME", &xdg_config)
.assert()
.success();
let after_count = count_messages(&db_path);
assert_eq!(
after_count,
baseline_count + 2,
"messages should only grow after reindex"
);
// Verify both old and new content are searchable (Tantivy layer)
for term in ["persist_me", "persist_me_again"] {
let out = command_env
.cass_assert_command()
.args(["search", term, "--robot", "--data-dir"])
.arg(&data_dir)
// Avoid connector detection from the repository CWD (e.g. `.aider.chat.history.md`).
.current_dir(home)
.env("HOME", home)
.env("XDG_DATA_HOME", &xdg_data)
.env("XDG_CONFIG_HOME", &xdg_config)
.output()
.expect("search");
assert!(out.status.success(), "search should succeed for {term}");
let hits = serde_json::from_slice::<serde_json::Value>(&out.stdout)
.unwrap()
.get("hits")
.and_then(|h| h.as_array())
.map(|v| v.len())
.unwrap_or(0);
assert!(hits >= 1, "{term} should remain indexed");
}
}
/// Test: Search returns hits with correct match_type
#[test]
#[serial]
fn search_returns_hits_with_match_type() {
let tracker = tracker_for("search_returns_hits_with_match_type");
let tmp = tempfile::TempDir::new().unwrap();
let home = tmp.path();
let codex_home = home.to_path_buf();
let command_env = tracker
.command_environment()
.with_home(home)
.with_codex_home(&codex_home);
let data_dir = home.join("cass_data");
fs::create_dir_all(&data_dir).unwrap();
// Create fixture with unique content
make_codex_session(
&codex_home,
"2024/11/20",
"rollout-1.jsonl",
"unique_search_term_alpha",
1732118400000,
);
// Index first
command_env
.cass_assert_command()
.args(["index", "--full", "--data-dir"])
.arg(&data_dir)
.env("CODEX_HOME", &codex_home)
.env("HOME", home)
.assert()
.success();
// Search and verify JSON output
let output = command_env
.cass_assert_command()
.args([
"search",
"unique_search_term_alpha",
"--robot",
"--data-dir",
])
.arg(&data_dir)
.env("HOME", home)
.output()
.expect("search command");
assert!(output.status.success(), "Search should succeed");
let json: serde_json::Value =
serde_json::from_slice(&output.stdout).expect("valid JSON output");
// Verify hits array exists
let hits = json
.get("hits")
.and_then(|h| h.as_array())
.expect("hits array should exist");
assert!(!hits.is_empty(), "Should find at least one hit");
// Verify match_type field
let first_hit = &hits[0];
assert!(
first_hit.get("match_type").is_some(),
"Hit should have match_type field"
);
let match_type = first_hit["match_type"].as_str().unwrap();
assert!(
["exact", "prefix", "wildcard", "fuzzy", "wildcard_fallback"].contains(&match_type),
"match_type should be a known type, got: {}",
match_type
);
// Verify content contains search term
let content = first_hit["content"].as_str().unwrap_or("");
assert!(
content.contains("unique_search_term_alpha"),
"Content should contain search term"
);
}
/// Test: Search aggregations include agent buckets
#[test]
#[serial]
fn search_aggregations_include_agents() {
let tracker = tracker_for("search_aggregations_include_agents");
let tmp = tempfile::TempDir::new().unwrap();
let home = tmp.path();
let codex_home = home.to_path_buf();
let command_env = tracker
.command_environment()
.with_home(home)
.with_codex_home(&codex_home);
let claude_home = home.join(".claude");
let data_dir = home.join("cass_data");
fs::create_dir_all(&data_dir).unwrap();
// Create fixtures from multiple connectors
make_codex_session(
&codex_home,
"2024/11/20",
"rollout-agg.jsonl",
"aggregation_test_content",
1732118400000,
);
make_claude_session(
&claude_home,
"agg-project",
"session-agg.jsonl",
"aggregation_test_content",
"2024-11-20T10:00:00Z",
);
// Index
command_env
.cass_assert_command()
.args(["index", "--full", "--data-dir"])
.arg(&data_dir)
.env("CODEX_HOME", &codex_home)
.env("HOME", home)
.assert()
.success();
// Search with aggregations
let output = command_env
.cass_assert_command()
.args([
"search",
"aggregation_test_content",
"--aggregate",
"agent",
"--robot",
"--data-dir",
])
.arg(&data_dir)
.env("HOME", home)
.output()
.expect("search command");
assert!(output.status.success(), "Search should succeed");
let json: serde_json::Value = serde_json::from_slice(&output.stdout).expect("valid JSON");
// Verify aggregations
let aggregations = json
.get("aggregations")
.expect("aggregations field should exist");
let agent_agg = aggregations.get("agent").expect("agent aggregation");
let buckets = agent_agg
.get("buckets")
.and_then(|b| b.as_array())
.expect("buckets array");
let agent_keys: std::collections::HashSet<_> = buckets
.iter()
.filter_map(|b| b.get("key").and_then(|k| k.as_str()))
.collect();
// At least one of our fixtures should be found in aggregations
// (Claude works reliably via HOME; Codex via CODEX_HOME may vary by platform)
assert!(
agent_keys.contains("codex") || agent_keys.contains("claude_code"),
"Should include at least one expected agent. Found: {:?}",
agent_keys
);
}
/// Test: Watch-once mode indexes specific paths
#[test]
#[serial]
fn watch_once_indexes_specified_path() {
let tracker = tracker_for("watch_once_indexes_specified_path");
let tmp = tempfile::TempDir::new().unwrap();
let home = tmp.path();
let codex_home = home.to_path_buf();
let command_env = tracker
.command_environment()
.with_home(home)
.with_codex_home(&codex_home);
let data_dir = home.join("cass_data");
fs::create_dir_all(&data_dir).unwrap();
// Create initial data
make_codex_session(
&codex_home,
"2024/11/20",
"rollout-watch.jsonl",
"watch_once_initial",
1732118400000,
);
// Initial index
command_env
.cass_assert_command()
.args(["index", "--full", "--data-dir"])
.arg(&data_dir)
.env("CODEX_HOME", &codex_home)
.env("HOME", home)
.assert()
.success();
// Create new file to watch
let watch_file = codex_home.join("sessions/2024/11/21/rollout-new.jsonl");
fs::create_dir_all(watch_file.parent().unwrap()).unwrap();
// Use current timestamp so message is indexed
let now_ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis() as u64;
let sample = format!(
r#"{{"type": "event_msg", "timestamp": {now_ts}, "payload": {{"type": "user_message", "message": "watch_once_new_content"}}}}
{{"type": "response_item", "timestamp": {}, "payload": {{"role": "assistant", "content": "watch_once_response"}}}}"#,
now_ts + 1000
);
fs::write(&watch_file, sample).unwrap();
// Run watch-once with specific path
command_env
.cass_assert_command()
.args(["index", "--watch-once"])
.arg(&watch_file)
.arg("--data-dir")
.arg(&data_dir)
.env("CODEX_HOME", &codex_home)
.env("HOME", home)
.assert()
.success();
// Verify new content is searchable
let output = command_env
.cass_assert_command()
.args(["search", "watch_once_new_content", "--robot", "--data-dir"])
.arg(&data_dir)
.env("HOME", home)
.output()
.expect("search command");
assert!(output.status.success());
let json: serde_json::Value = serde_json::from_slice(&output.stdout).expect("valid JSON");
let hits = json.get("hits").and_then(|h| h.as_array()).expect("hits");
assert!(
!hits.is_empty(),
"Should find the newly indexed watch-once content"
);
}
/// Test: Search with filters (agent, time range)
#[test]
#[serial]
fn search_with_filters() {
let tracker = tracker_for("search_with_filters");
let tmp = tempfile::TempDir::new().unwrap();
let home = tmp.path();
let codex_home = home.to_path_buf();
let command_env = tracker
.command_environment()
.with_home(home)
.with_codex_home(&codex_home);
let data_dir = home.join("cass_data");
fs::create_dir_all(&data_dir).unwrap();
// Create multiple sessions with distinct content
make_codex_session(
&codex_home,
"2024/11/20",
"rollout-filter1.jsonl",
"filter_test_content",
1732118400000, // Nov 20, 2024
);
make_codex_session(
&codex_home,
"2024/11/21",
"rollout-filter2.jsonl",
"filter_test_content",
1732204800000, // Nov 21, 2024
);
// Index
command_env
.cass_assert_command()
.args(["index", "--full", "--data-dir"])
.arg(&data_dir)
.env("CODEX_HOME", &codex_home)
.env("HOME", home)
.assert()
.success();
// Search with agent filter
let output = command_env
.cass_assert_command()
.args([
"search",
"filter_test_content",
"--agent",
"codex",
"--robot",
"--data-dir",
])
.arg(&data_dir)
.env("HOME", home)
.output()
.expect("search command");
assert!(output.status.success());
let json: serde_json::Value = serde_json::from_slice(&output.stdout).expect("valid JSON");
let hits = json.get("hits").and_then(|h| h.as_array()).expect("hits");
// All hits should be from codex agent
for hit in hits {
assert_eq!(
hit["agent"].as_str().unwrap(),
"codex",
"All hits should be from codex agent"
);
}
}
/// Test: Search returns total_matches and pagination info
#[test]
#[serial]
fn search_returns_pagination_info() {
let tracker = tracker_for("search_returns_pagination_info");
let tmp = tempfile::TempDir::new().unwrap();
let home = tmp.path();
let codex_home = home.to_path_buf();
let command_env = tracker
.command_environment()
.with_home(home)
.with_codex_home(&codex_home);
let data_dir = home.join("cass_data");
fs::create_dir_all(&data_dir).unwrap();
// Create multiple sessions
for i in 1..=5 {
make_codex_session(
&codex_home,
"2024/11/20",
&format!("rollout-page{i}.jsonl"),
"pagination_test_term",
1732118400000 + (i as u64 * 1000),
);
}
// Index
command_env
.cass_assert_command()
.args(["index", "--full", "--data-dir"])
.arg(&data_dir)
.env("CODEX_HOME", &codex_home)
.env("HOME", home)
.assert()
.success();
// Search with limit
let output = command_env
.cass_assert_command()
.args([
"search",
"pagination_test_term",
"--limit",
"3",
"--robot",
"--data-dir",
])
.arg(&data_dir)
.env("HOME", home)
.output()
.expect("search command");
assert!(output.status.success());
let json: serde_json::Value = serde_json::from_slice(&output.stdout).expect("valid JSON");
// Verify pagination fields
let total = json
.get("total_matches")
.and_then(|t| t.as_u64())
.expect("total_matches");
let limit = json.get("limit").and_then(|l| l.as_u64()).expect("limit");
let hits = json
.get("hits")
.and_then(|h| h.as_array())
.expect("hits")
.len();
// We created 5 sessions, each with 2 messages (user + response), so we expect >= 5 hits
// But some may not match the search term exactly
assert!(
total >= 1,
"Should have at least 1 total match. Got: {}",
total
);
assert_eq!(limit, 3, "Limit should be 3");
assert!(hits <= 3, "Returned hits should be <= limit");
}
/// Test: Force rebuild recreates index
#[test]
#[serial]
fn force_rebuild_recreates_index() {
let tracker = tracker_for("force_rebuild_recreates_index");
let tmp = tempfile::TempDir::new().unwrap();
let home = tmp.path();
let codex_home = home.to_path_buf();
let command_env = tracker
.command_environment()
.with_home(home)
.with_codex_home(&codex_home);
let data_dir = home.join("cass_data");
fs::create_dir_all(&data_dir).unwrap();
// Create initial data
make_codex_session(
&codex_home,
"2024/11/20",
"rollout-rebuild.jsonl",
"rebuild_test_initial",
1732118400000,
);
// Initial index
command_env
.cass_assert_command()
.args(["index", "--full", "--data-dir"])
.arg(&data_dir)
.env("CODEX_HOME", &codex_home)
.env("HOME", home)
.assert()
.success();
// Bead cxhqb: capture a content-based fingerprint of the index
// tree before the force-rebuild. Previously this compared a single
// directory-mtime before/after with a 1-second sleep in between —
// fragile on filesystems with ≥2s mtime granularity (FAT32, some
// NFS setups) and wasteful wall-clock time on every test run.
// Listing every file under the index tree with its size is
// independent of filesystem mtime precision: a real rebuild writes
// new Tantivy segments (new UUIDs, different sizes), so the set is
// guaranteed to change even when mtime cannot.
fn index_fingerprint(root: &Path) -> Vec<(String, u64)> {
let mut entries: Vec<(String, u64)> = walkdir::WalkDir::new(root)
.sort_by_file_name()
.into_iter()
.filter_map(Result::ok)
.filter(|e| e.file_type().is_file())
.map(|e| {
let rel = e
.path()
.strip_prefix(root)
.unwrap_or(e.path())
.to_string_lossy()
.into_owned();
let len = e.metadata().map(|m| m.len()).unwrap_or(0);
(rel, len)
})
.collect();
entries.sort();
entries
}
let index_dir = data_dir.join("index");
let initial_fingerprint = index_fingerprint(&index_dir);
assert!(
!initial_fingerprint.is_empty(),
"precondition: initial index tree at {} must contain files",
index_dir.display()
);
// Force rebuild
command_env
.cass_assert_command()
.args(["index", "--force-rebuild", "--data-dir"])
.arg(&data_dir)
.env("CODEX_HOME", &codex_home)
.env("HOME", home)
.assert()
.success();
// Verify index was rebuilt: the set of files (names + sizes) under
// the index tree must differ from the pre-rebuild snapshot. A
// regression where --force-rebuild silently no-ops would leave the
// same Tantivy segments in place and this assertion would fire.
let new_fingerprint = index_fingerprint(&index_dir);
assert_ne!(
initial_fingerprint,
new_fingerprint,
"index tree content must change after --force-rebuild; \
before ({} entries) == after ({} entries)",
initial_fingerprint.len(),
new_fingerprint.len()
);
// Verify content is still searchable
let output = command_env
.cass_assert_command()
.args(["search", "rebuild_test_initial", "--robot", "--data-dir"])
.arg(&data_dir)
.env("HOME", home)
.output()
.expect("search command");
assert!(output.status.success());
let json: serde_json::Value = serde_json::from_slice(&output.stdout).expect("valid JSON");
let hits = json.get("hits").and_then(|h| h.as_array()).expect("hits");
// Bead 7k7pl: pin EXACT content — at least one hit must carry
// the seeded token `rebuild_test_initial` in its content field.
// A regression that returned unrelated hits after force-rebuild
// would slip past `!is_empty()` while breaking searchability.
assert!(
!hits.is_empty(),
"Content should still be searchable after force-rebuild"
);
let seeded_hit = hits.iter().find(|hit| {
hit.get("content")
.and_then(|c| c.as_str())
.is_some_and(|c| c.contains("rebuild_test_initial"))
});
assert!(
seeded_hit.is_some(),
"at least one hit must contain the seeded token `rebuild_test_initial`; \
got hits={hits:?}"
);
}
/// Test: JSON output mode (--json) for index command
#[test]
#[serial]
fn index_json_output_mode() {
let tracker = tracker_for("index_json_output_mode");
let tmp = tempfile::TempDir::new().unwrap();
let home = tmp.path();
let codex_home = home.to_path_buf();
let command_env = tracker
.command_environment()
.with_home(home)
.with_codex_home(&codex_home);
let data_dir = home.join("cass_data");
fs::create_dir_all(&data_dir).unwrap();
// Create fixture
make_codex_session(
&codex_home,
"2024/11/20",
"rollout-json.jsonl",
"json_output_test",
1732118400000,
);
// Index with --json
let output = command_env
.cass_assert_command()
.args(["index", "--full", "--json", "--data-dir"])
.arg(&data_dir)
.env("CODEX_HOME", &codex_home)
.env("HOME", home)
.output()
.expect("index command");
assert!(output.status.success());
// Debug: print actual output
eprintln!(
"Index JSON output: {}",
String::from_utf8_lossy(&output.stdout)
);
// Verify JSON output structure - index --json outputs various fields
let json: serde_json::Value =
serde_json::from_slice(&output.stdout).expect("valid JSON output");
// Index JSON output should be a valid JSON object
assert!(
json.is_object(),
"JSON output should be an object. Got: {}",
json
);
}
/// Test: Help text includes expected options
#[test]
#[serial]
fn index_help_includes_options() {
let tracker = tracker_for("index_help_includes_options");
let command_env = tracker.command_environment();
let output = command_env
.cass_assert_command()
.args(["index", "--help"])
.output()
.expect("help command");
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("--full"), "Help should mention --full");
assert!(stdout.contains("--watch"), "Help should mention --watch");
assert!(
stdout.contains("--force-rebuild"),
"Help should mention --force-rebuild"
);
assert!(
stdout.contains("--semantic"),
"Help should mention --semantic"
);
assert!(
stdout.contains("--embedder"),
"Help should mention --embedder"
);
assert!(
stdout.contains("--data-dir"),
"Help should mention --data-dir"
);
}
/// Test: Search help includes expected options
#[test]
#[serial]
fn search_help_includes_options() {
let tracker = tracker_for("search_help_includes_options");
let command_env = tracker.command_environment();
let output = command_env
.cass_assert_command()
.args(["search", "--help"])
.output()
.expect("help command");
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("--robot"), "Help should mention --robot");
assert!(stdout.contains("--limit"), "Help should mention --limit");
assert!(stdout.contains("--agent"), "Help should mention --agent");
assert!(
stdout.contains("--aggregate"),
"Help should mention --aggregate"
);
}
/// Test: Search with wildcard query
#[test]
#[serial]
fn search_wildcard_query() {
let tracker = tracker_for("search_wildcard_query");
let tmp = tempfile::TempDir::new().unwrap();
let home = tmp.path();
let codex_home = home.to_path_buf();
let command_env = tracker
.command_environment()
.with_home(home)
.with_codex_home(&codex_home);
let data_dir = home.join("cass_data");
fs::create_dir_all(&data_dir).unwrap();
// Create fixture with unique prefix
make_codex_session(
&codex_home,
"2024/11/20",
"rollout-wild.jsonl",
"wildcardtest_unique_suffix",
1732118400000,
);
// Index
command_env
.cass_assert_command()
.args(["index", "--full", "--data-dir"])
.arg(&data_dir)
.env("CODEX_HOME", &codex_home)
.env("HOME", home)
.assert()
.success();
// Search with wildcard prefix
let output = command_env
.cass_assert_command()
.args(["search", "wildcardtest*", "--robot", "--data-dir"])
.arg(&data_dir)
.env("HOME", home)
.output()
.expect("search command");
assert!(output.status.success());
let json: serde_json::Value = serde_json::from_slice(&output.stdout).expect("valid JSON");
let hits = json.get("hits").and_then(|h| h.as_array()).expect("hits");
// Bead 7k7pl: pin EXACT content — the wildcard `wildcardtest*`
// must match the seeded token `wildcardtest_unique_suffix` in at
// least one hit's content. A regression that returned unrelated
// hits (wildcard falling back to match-all) would slip past
// `!is_empty()`.
assert!(
!hits.is_empty(),
"Wildcard prefix search should find results"
);
let matched_seed = hits.iter().any(|hit| {
hit.get("content")
.and_then(|c| c.as_str())
.is_some_and(|c| c.contains("wildcardtest_unique_suffix"))
});
assert!(
matched_seed,
"wildcard search must return the seeded `wildcardtest_unique_suffix` token; \
got hits={hits:?}"
);
}
/// Test: Trace logging works when enabled
#[test]
#[serial]
fn trace_logging_to_file() {
let tracker = tracker_for("trace_logging_to_file");
let tmp = tempfile::TempDir::new().unwrap();
let home = tmp.path();
let codex_home = home.to_path_buf();
let command_env = tracker
.command_environment()
.with_home(home)
.with_codex_home(&codex_home);
let data_dir = home.join("cass_data");
let trace_dir = home.join("traces");
fs::create_dir_all(&data_dir).unwrap();
fs::create_dir_all(&trace_dir).unwrap();
// Create fixture
make_codex_session(
&codex_home,
"2024/11/20",
"rollout-trace.jsonl",
"trace_test_content",
1732118400000,
);
// Index with tracing enabled
command_env
.cass_assert_command()
.args(["index", "--full", "--data-dir"])
.arg(&data_dir)
.env("CODEX_HOME", &codex_home)
.env("HOME", home)
.env("CASS_TRACE_DIR", &trace_dir)
.assert()
.success();
// Note: Trace file creation depends on tracing-appender setup in the binary
// This test verifies the env var is recognized without crashing
}
/// Test: Empty query returns recent results
#[test]
#[serial]
fn empty_query_returns_recent() {
let tracker = tracker_for("empty_query_returns_recent");
let tmp = tempfile::TempDir::new().unwrap();
let home = tmp.path();
let codex_home = home.to_path_buf();
let command_env = tracker
.command_environment()
.with_home(home)
.with_codex_home(&codex_home);
let data_dir = home.join("cass_data");
fs::create_dir_all(&data_dir).unwrap();
// Create fixture
make_codex_session(
&codex_home,
"2024/11/20",
"rollout-recent.jsonl",
"recent_results_test",
1732118400000,
);
// Index
command_env
.cass_assert_command()
.args(["index", "--full", "--data-dir"])
.arg(&data_dir)
.env("CODEX_HOME", &codex_home)
.env("HOME", home)
.assert()
.success();
// Search with empty query (should show recent)
let output = command_env
.cass_assert_command()
.args(["search", "", "--robot", "--data-dir"])
.arg(&data_dir)
.env("HOME", home)
.output()
.expect("search command");
assert!(
output.status.success(),
"Empty query should succeed after a successful index: {}",
String::from_utf8_lossy(&output.stderr)
);
let json: serde_json::Value =
serde_json::from_slice(&output.stdout).expect("empty-query search JSON");
let hits = json["hits"].as_array().expect("hits array");
assert!(
!hits.is_empty(),
"Empty query should return recent indexed conversations"
);
// Bead 7k7pl: pin the SHAPE of every returned hit. Empty-query
// returns recency-sorted conversations, so every hit must be a
// proper hit object with string `content` + `source_path` fields.
// A regression that emitted malformed hits (null content, missing
// source) would slip past `!is_empty()` while breaking consumers.
for hit in hits {
assert!(
hit.get("content").and_then(|c| c.as_str()).is_some(),
"every empty-query hit must have a string `content` field; got {hit}"
);
assert!(
hit.get("source_path").and_then(|s| s.as_str()).is_some(),
"every empty-query hit must have a string `source_path` field; got {hit}"
);
}
}
#[test]
#[serial]
fn large_message_minimal_search_stays_on_the_tantivy_fast_path() {
let tracker = tracker_for("large_message_minimal_search_stays_on_the_tantivy_fast_path");
let tmp = tempfile::TempDir::new().unwrap();
let home = tmp.path();
let codex_home = home.to_path_buf();
let command_env = tracker
.command_environment()
.with_home(home)
.with_codex_home(&codex_home);
let data_dir = home.join("cass_data");
fs::create_dir_all(&data_dir).unwrap();
let large_content = format!(
"tantivy_large_anchor {}",
"overflowpayload ".repeat(180_000)
);
tracker.phase(
"seed_large_message_fixture",
"Create a real Codex rollout with a multi-megabyte message body",
|| {
make_codex_session(
&codex_home,
"2024/11/22",
"rollout-large-tantivy-fast-path.jsonl",
&large_content,
1_732_300_000_000,
);
},
);
tracker.phase(
"index_large_message_fixture",
"Build the real index before searching the large message",
|| {
command_env
.cass_assert_command()
.args(["index", "--full", "--json", "--data-dir"])
.arg(&data_dir)
.current_dir(home)
.env("CODEX_HOME", &codex_home)
.env("HOME", home)
.timeout(Duration::from_secs(90))
.assert()
.success();
},
);
let search_started = tracker.start(
"search_large_message_minimal",
Some("Run a real lexical cass search against the multi-megabyte session"),
);
let output = command_env
.cass_assert_command()
.args([
"search",
"tantivy_large_anchor",
"--json",
"--mode",
"lexical",
"--fields",
"minimal",
"--limit",
"5",
"--data-dir",
])
.arg(&data_dir)
.current_dir(home)
.env("CODEX_HOME", &codex_home)
.env("HOME", home)
.timeout(Duration::from_secs(20))
.output()
.expect("search large indexed message");
tracker.end(
"search_large_message_minimal",
Some("Run a real lexical cass search against the multi-megabyte session"),
search_started,
);
assert!(
output.status.success(),
"large-message lexical search should stay healthy after indexing\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let json: serde_json::Value =
serde_json::from_slice(&output.stdout).expect("large-message search JSON");
let hits = json
.get("total_matches")
.and_then(|matches| matches.as_u64())
.unwrap_or_else(|| {
json.get("hits")
.and_then(|hits| hits.as_array())
.map(|hits| hits.len() as u64)
.unwrap_or(0)
});
assert!(
hits > 0,
"large indexed message should remain searchable with minimal lexical fields"
);
tracker.flush();
}
#[test]
#[serial]
fn incremental_index_repairs_sparse_tantivy_from_canonical_db_before_scanning_new_files() {
let tracker = tracker_for(
"incremental_index_repairs_sparse_tantivy_from_canonical_db_before_scanning_new_files",
);
let tmp = tempfile::TempDir::new().unwrap();
let home = tmp.path().to_path_buf();
let codex_home = home.to_path_buf();
let command_env = tracker
.command_environment()
.with_home(&home)
.with_codex_home(&codex_home);
let data_dir = home.join("cass_data");
fs::create_dir_all(&data_dir).unwrap();
tracker.phase(
"seed_baseline_archive",
"Create a real multi-session Codex archive and build the canonical DB plus lexical index",
|| {
make_bulk_codex_sessions(
&codex_home,
"2024/11/23",
"rollout-repair-baseline",
"repairoldanchor",
1_732_400_000_000,
5,
4,
);
command_env
.cass_assert_command()
.args(["index", "--full", "--json", "--data-dir"])
.arg(&data_dir)
.current_dir(&home)
.env("CODEX_HOME", &codex_home)
.env("HOME", &home)
.timeout(Duration::from_secs(60))
.assert()
.success();
},
);
let db_path = data_dir.join("agent_search.db");
let baseline_messages = count_messages(&db_path) as u64;
assert!(
baseline_messages >= 40,
"baseline archive should populate the canonical DB with many messages"
);
tracker.phase(
"swap_in_sparse_real_tantivy_index",
"Replace the healthy lexical index with a real but sparse one built from a different archive",
|| {
let sparse_home = home.join("sparse_home");
let sparse_codex_home = sparse_home.clone();
let sparse_data_dir = sparse_home.join("cass_data");
fs::create_dir_all(&sparse_data_dir).unwrap();
make_codex_session(
&sparse_codex_home,
"2024/11/23",
"rollout-sparse-replacement.jsonl",
"sparseanchoronly",
1_732_450_000_000,
);
command_env
.cass_assert_command()
.args(["index", "--full", "--json", "--data-dir"])
.arg(&sparse_data_dir)
.current_dir(&sparse_home)
.env("CODEX_HOME", &sparse_codex_home)
.env("HOME", &sparse_home)
.timeout(Duration::from_secs(60))
.assert()
.success();
let live_index = expected_index_dir(&data_dir);
let backup_name = live_index
.file_name()
.map(|name| format!("{}.baseline-backup", name.to_string_lossy()))
.unwrap_or_else(|| "lexical-index.baseline-backup".to_string());
let backup_index = live_index.with_file_name(backup_name);
let sparse_index = expected_index_dir(&sparse_data_dir);
fs::rename(&live_index, &backup_index).expect("move healthy index aside");
fs::rename(&sparse_index, &live_index)
.expect("replace healthy index with sparse real tantivy index");
},
);
assert_eq!(
raw_lexical_total_matches(&expected_index_dir(&data_dir), "repairoldanchor"),
0,
"the swapped-in sparse index should not contain the baseline token before repair; \
use a raw lexical reader here so cass search cannot self-heal the fixture early"
);
tracker.phase(
"stage_new_incremental_session",
"Add a brand-new session after the sparse index swap so plain cass index must both repair and ingest",
|| {
make_codex_session(
&codex_home,
"2024/11/24",
"rollout-repair-new-session.jsonl",
"repairnewanchor",
1_732_500_000_000,
);
},
);
let repair_started = tracker.start(
"repair_sparse_tantivy_then_incremental_scan",
Some("Run plain cass index --json and require canonical repair plus new-session ingestion"),
);
let repair_output = command_env
.cass_assert_command()
.args(["index", "--json", "--data-dir"])
.arg(&data_dir)
.current_dir(&home)
.env("CODEX_HOME", &codex_home)
.env("HOME", &home)
.timeout(Duration::from_secs(60))
.output()
.expect("run repairing incremental index");
let repair_duration_ms = repair_started.elapsed().as_millis() as u64;
tracker.end(
"repair_sparse_tantivy_then_incremental_scan",
Some("Run plain cass index --json and require canonical repair plus new-session ingestion"),
repair_started,
);
assert!(
repair_output.status.success(),
"plain index should repair the sparse Tantivy index and ingest new sessions\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&repair_output.stdout),
String::from_utf8_lossy(&repair_output.stderr)
);
let repair_json: serde_json::Value =
serde_json::from_slice(&repair_output.stdout).expect("parse repair index json");
let repair_stats = repair_json
.get("indexing_stats")
.and_then(|value| value.as_object())
.expect("indexing_stats object");
assert_eq!(
repair_stats
.get("lexical_strategy")
.and_then(|value| value.as_str()),
Some("deferred_authoritative_db_rebuild")
);
assert_eq!(
repair_stats
.get("lexical_strategy_reason")
.and_then(|value| value.as_str()),
Some(
"incremental_index_repairs_sparse_tantivy_from_authoritative_canonical_db_before_scan"
)
);
let after_messages = count_messages(&db_path) as u64;
assert_eq!(
after_messages,
baseline_messages + 2,
"plain incremental index should still ingest the newly added session after repairing Tantivy"
);
let repaired_old_search = command_env
.cass_assert_command()
.args([
"search",
"repairoldanchor",
"--json",
"--mode",
"lexical",
"--fields",
"minimal",
"--limit",
"5",
"--data-dir",
])
.arg(&data_dir)
.current_dir(&home)
.env("CODEX_HOME", &codex_home)
.env("HOME", &home)
.timeout(Duration::from_secs(20))
.output()
.expect("search repaired baseline token");
assert!(
repaired_old_search.status.success(),
"search should succeed after canonical lexical repair\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&repaired_old_search.stdout),
String::from_utf8_lossy(&repaired_old_search.stderr)
);
assert!(
total_matches_from_search_output(&repaired_old_search.stdout) > 0,
"repair should restore baseline archive hits from the canonical DB"
);
let repaired_new_search = command_env
.cass_assert_command()
.args([
"search",
"repairnewanchor",
"--json",
"--mode",
"lexical",
"--fields",
"minimal",
"--limit",
"5",
"--data-dir",
])
.arg(&data_dir)
.current_dir(&home)
.env("CODEX_HOME", &codex_home)
.env("HOME", &home)
.timeout(Duration::from_secs(20))
.output()
.expect("search new incremental token after repair");
assert!(
repaired_new_search.status.success(),
"new incremental content should be searchable after the repair-first index run\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&repaired_new_search.stdout),
String::from_utf8_lossy(&repaired_new_search.stderr)
);
assert!(
total_matches_from_search_output(&repaired_new_search.stdout) > 0,
"repair-first incremental index should still ingest the newly added session"
);
tracker.metrics(
"repair_sparse_tantivy_then_incremental_scan",
&E2ePerformanceMetrics::new()
.with_duration(repair_duration_ms)
.with_custom("baseline_messages", baseline_messages)
.with_custom("messages_after_repair", after_messages),
);
tracker.flush();
}