Files
cairnobs/search/src/offsets.rs
T
jcoffey-dev cd8aa290ca Phase 1: Windows log collection + full-text search
Extends the agent, ingest, storage, api, and web with Windows Event
Log/ETW sourcing and Tantivy-backed free-text search, per the approved
Phase 1 plan.

- CLAUDE.md: materialized on disk (never existed as a file before) with
  a new Phase 1 "done looks like" section.
- agent: Windows Event Log (EvtSubscribe) and ETW sources, Windows
  service wrapper (install/uninstall/run-service), both feature- and
  target_os-gated so Linux builds/tests/clippy stay unaffected. Also
  fixed two pre-existing Phase 0 clippy gaps (dead-code on
  default-features-only builds, a type-inference edge case) found while
  testing every feature combination properly for the first time.
  UNVERIFIED on real Windows -- no Windows toolchain existed anywhere in
  the build environment; flagged prominently in three places.
- proto/ingest: new record_id field, assigned once server-side in
  ingest's gRPC front end so ClickHouse and Tantivy agree on the same ID
  for the same record.
- storage: record_id column + bloom filter index, verified against a
  live ClickHouse.
- search: new service, Tantivy index, rskafka consumer as an independent
  second consumer group on the same Redpanda topic ingest already reads.
- api/web: new /search endpoint and page, sharing the query page's
  result-table shape and component.
- hack/windows-fixture: sends realistic Windows-shaped data straight to
  ingest, so the pipeline's handling of it is verifiable without a
  Windows host.

Verified end-to-end on the live docker-compose stack: the same record_id
comes back from both /query and /search for the same log line, including
for windows-fixture's synthetic Windows Event Log data. Real bugs found
and fixed along the way: api/Dockerfile missing proto/ in its build
context, search's logs being completely silent (RUST_LOG gap), and
search/target/ missing from .gitignore/.dockerignore.
2026-08-13 11:27:35 -07:00

94 lines
3.4 KiB
Rust

use anyhow::{Context, Result};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
/// Tracks per-partition offsets in a plain JSON file next to the Tantivy
/// index, since rskafka is a low-level client with no built-in consumer-
/// group coordination/offset-commit protocol (unlike kafka-go on the
/// ingest side) -- there's no broker-side group to commit to here, so
/// this service owns its own offset bookkeeping.
///
/// Best-effort, not exactly-once: if the process dies between processing
/// a record and persisting its offset, that record gets reprocessed on
/// restart. This is fine because `SearchIndex::upsert` is delete-then-add
/// on `record_id` -- reprocessing the same record overwrites the same
/// document rather than duplicating it.
#[derive(Debug)]
pub struct OffsetStore {
path: PathBuf,
offsets: HashMap<i32, i64>,
}
impl OffsetStore {
pub async fn load(path: &Path) -> Result<Self> {
let offsets = match tokio::fs::read(path).await {
Ok(bytes) => serde_json::from_slice(&bytes)
.with_context(|| format!("parsing offsets file {}", path.display()))?,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => HashMap::new(),
Err(e) => return Err(e).with_context(|| format!("reading offsets file {}", path.display())),
};
Ok(Self {
path: path.to_path_buf(),
offsets,
})
}
/// Next offset to fetch for a partition -- 0 (earliest) if never
/// recorded before.
pub fn get(&self, partition: i32) -> i64 {
self.offsets.get(&partition).copied().unwrap_or(0)
}
pub fn set(&mut self, partition: i32, offset: i64) {
self.offsets.insert(partition, offset);
}
pub async fn persist(&self) -> Result<()> {
if let Some(parent) = self.path.parent() {
tokio::fs::create_dir_all(parent)
.await
.with_context(|| format!("creating offsets directory {}", parent.display()))?;
}
let bytes = serde_json::to_vec_pretty(&self.offsets).context("serializing offsets")?;
tokio::fs::write(&self.path, bytes)
.await
.with_context(|| format!("writing offsets file {}", self.path.display()))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn get_defaults_to_zero_for_unknown_partition() {
let dir = tempfile::tempdir().unwrap();
let store = OffsetStore::load(&dir.path().join("offsets.json")).await.unwrap();
assert_eq!(store.get(0), 0);
}
#[tokio::test]
async fn missing_file_loads_as_empty_not_an_error() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("does-not-exist.json");
let store = OffsetStore::load(&path).await;
assert!(store.is_ok(), "expected a missing offsets file to load as empty, got {store:?}");
}
#[tokio::test]
async fn persist_and_reload_round_trips() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("offsets.json");
let mut store = OffsetStore::load(&path).await.unwrap();
store.set(0, 42);
store.set(1, 7);
store.persist().await.unwrap();
let reloaded = OffsetStore::load(&path).await.unwrap();
assert_eq!(reloaded.get(0), 42);
assert_eq!(reloaded.get(1), 7);
assert_eq!(reloaded.get(2), 0, "unset partition should still default to 0");
}
}