agentmux_srv/backend/blockcontroller/shell/indexing.rs
1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! `output.idx` rebuild: a lazily-built, self-validating line-offset cache.
5
6use crate::backend::storage::filestore::FileStore;
7
8/// Magic header size for `output.idx`: the first 8 bytes are the `output` byte-size
9/// the index was built for. The index is valid iff this equals `output`'s current
10/// size; otherwise it is stale and must be rebuilt.
11pub(crate) const OUTPUT_IDX_HEADER_LEN: i64 = 8;
12
13/// Key in `output.idx`'s file metadata naming the `output` generation the
14/// index was built for (Phase 5a-2b; `filestore/counter.rs`). Written in the
15/// same transaction as the index, so the label always describes the bytes.
16const IDX_GEN_META: &str = "for_gen";
17
18const IDX: &str = "output.idx";
19
20/// `output`'s size and valid generation, from the database — never this
21/// process's `stat` cache, which another srv instance's append makes stale.
22/// A counter only behind (an older build appended since) is caught up first,
23/// so its generation holds across those appends (`filestore/counter.rs`).
24pub(crate) fn output_now(fs: &FileStore, zone: &str) -> Option<(u64, Option<String>)> {
25 let state = fs.catch_up_line_counter(zone, "output").ok()??;
26 Some((state.size.max(0) as u64, state.counted.map(|c| c.gen)))
27}
28
29/// `output` and its `output.idx` as they were at ONE moment — one database
30/// snapshot (Codex on #3634): separate reads of the index's size, header and
31/// label could each see a different version of an index another srv instance
32/// is rebuilding, and pass an old count off as fresh.
33pub(crate) struct OutputIndex {
34 pub output_size: u64,
35 /// `output`'s valid generation; `None` when it is not counted.
36 #[cfg(test)]
37 pub output_gen: Option<String>,
38 /// The line count `output.idx` gives, if it describes `output` exactly:
39 /// its covered size is `output_size` and it is labelled with
40 /// `output_gen`. `None`: missing, stale, or another generation's.
41 pub fresh_lines: Option<u64>,
42}
43
44/// See [`OutputIndex`]. `None` when there is no `output`.
45pub(crate) fn output_index(fs: &FileStore, zone: &str) -> Option<OutputIndex> {
46 let snap = fs.derived_snapshot(zone, "output", IDX, Some(OUTPUT_IDX_HEADER_LEN)).ok()??;
47 let output_size = snap.output_size.max(0) as u64;
48 let fresh_lines = snap.derived.as_ref().and_then(|idx| {
49 let header = idx.bytes.as_deref()?;
50 let covered = u64::from_le_bytes(header.get(..8)?.try_into().ok()?);
51 (idx.size >= OUTPUT_IDX_HEADER_LEN
52 && covered == output_size
53 && labelled_for(&idx.meta, snap.output_gen.as_deref()))
54 .then_some(((idx.size - OUTPUT_IDX_HEADER_LEN) / 8) as u64)
55 });
56 Some(OutputIndex {
57 output_size,
58 #[cfg(test)]
59 output_gen: snap.output_gen,
60 fresh_lines,
61 })
62}
63
64/// Whether an index with metadata `meta` may describe `output` of generation
65/// `output_gen`, apart from its covered size.
66///
67/// A size match alone is not proof: `output` replaced or restored with
68/// content that happens to reach the old covered size would have its lines
69/// read at another file's offsets. When `output` has a valid generation the
70/// index must be labelled with it; any mismatch — unlabelled (older build),
71/// or built for another generation — means rebuild, which is always safe.
72/// An uncounted `output` (legacy or written by an older build) keeps the
73/// covered-size rule alone, as before.
74fn labelled_for(meta: &crate::backend::storage::filestore::FileMeta, output_gen: Option<&str>) -> bool {
75 match output_gen {
76 None => true,
77 Some(gen) => meta.get(IDX_GEN_META).and_then(|v| v.as_str()) == Some(gen),
78 }
79}
80
81/// Lines `[offset, offset + limit)` of `output`, read through `output.idx`.
82pub(crate) struct IndexedRead {
83 /// Total line count of `output` in the snapshot.
84 pub total: u64,
85 /// The bytes of the requested lines (from the first line's offset up to
86 /// the next line's, or the end of `output`).
87 pub raw: Vec<u8>,
88 /// Byte offset in `output` of each returned line, in order.
89 pub line_offsets: Vec<u64>,
90 /// Receive-time stamps for the returned lines (unix ms, `0` = unknown),
91 /// from the `output.tsidx` sidecar in the same snapshot, so they are joined
92 /// to the lines they were written for (Codex on #3634). Only a window of
93 /// the sidecar around the lines is read (`stamps.rs`). `None`: no sidecar,
94 /// no records near the lines, or bytes not fully stored.
95 pub stamps: Option<Vec<i64>>,
96}
97
98/// Read lines `[offset, offset + limit)` of `output` through a fresh
99/// `output.idx`, with the freshness check, the index entries and the output
100/// bytes all from ONE database snapshot (Codex on #3634): validating in one
101/// read and fetching entries and bytes in later ones would let another srv
102/// instance replace `output` and rebuild the index in between — the answer
103/// would mix the new file's lines with the old file's total.
104///
105/// `None` when the index doesn't describe `output` in that snapshot (missing,
106/// stale, another generation's) or a read hits bytes not stored yet: the
107/// caller rebuilds the index and retries once, or takes its slow path.
108pub(crate) fn read_via_index(fs: &FileStore, zone: &str, offset: u64, limit: u64) -> Option<IndexedRead> {
109 read_indexed(fs, zone, offset, limit, false)
110}
111
112/// [`read_via_index`] from an index that covers only a prefix of `output`:
113/// lines appended since it was built aren't returned (`total` is the
114/// index's count), nor is the last indexed line, whose end the index can't
115/// give. For the read right after extending the index, when an append
116/// landed in between — before this, that read fell back to loading the
117/// whole transcript (1.3 GB, ~15 s, under the store's lock) whenever an
118/// agent's spawn was writing to the transcript its pane was opening
119/// (docs/reports/REPORT_AGENT_OPEN_STALL_RCA_2026_10_05.md). The lines it
120/// leaves out are the newest few, which the pane's live stream delivers.
121/// `None` under the same conditions as [`read_via_index`], and when nothing
122/// requested is within the indexed prefix.
123pub(crate) fn read_via_stale_index(fs: &FileStore, zone: &str, offset: u64, limit: u64) -> Option<IndexedRead> {
124 read_indexed(fs, zone, offset, limit, true)
125}
126
127fn read_indexed(fs: &FileStore, zone: &str, offset: u64, limit: u64, stale_ok: bool) -> Option<IndexedRead> {
128 const H: i64 = OUTPUT_IDX_HEADER_LEN;
129 fs.read_snapshot(|snap| {
130 let Some(out) = snap.file(zone, "output")? else { return Ok(None) };
131 let Some(idx) = snap.file(zone, IDX)? else { return Ok(None) };
132 let Some(header) = snap.bytes(zone, IDX, 0, H)? else { return Ok(None) };
133 let covered = u64::from_le_bytes(header.as_slice().try_into().unwrap_or([0; 8]));
134 let output_size = out.size.max(0) as u64;
135 // A prefix only counts for the same generation: a replaced `output`
136 // fails the label check whatever its size.
137 let fresh = covered == output_size;
138 let usable_prefix = stale_ok && covered < output_size && out.gen.is_some();
139 if idx.size < H || !(fresh || usable_prefix) || !labelled_for(&idx.meta, out.gen.as_deref()) {
140 return Ok(None);
141 }
142 let entries_total = ((idx.size - H) / 8) as u64;
143 // From a prefix, the last indexed line may have grown since: serve
144 // only lines whose end is the next entry.
145 let total = if fresh { entries_total } else { entries_total.saturating_sub(1) };
146 if !fresh && (limit == 0 || offset >= total) {
147 return Ok(None);
148 }
149 let mut read = IndexedRead {
150 total,
151 raw: Vec::new(),
152 line_offsets: Vec::new(),
153 stamps: None,
154 };
155 if limit == 0 || offset >= total {
156 return Ok(Some(read));
157 }
158 let count = limit.min(total - offset);
159 // Entries for the returned lines, plus the next line's (the end of
160 // the last returned line) when there is one.
161 let want = count + u64::from(offset + count < entries_total);
162 let Some(entries) = snap.bytes(zone, IDX, H + (offset * 8) as i64, (want * 8) as i64)? else { return Ok(None) };
163 let at = |k: u64| u64::from_le_bytes(entries[(k * 8) as usize..(k * 8 + 8) as usize].try_into().unwrap());
164 let start = at(0);
165 let end = if want > count { at(count) } else { output_size };
166 if start > end || end > output_size {
167 return Ok(None);
168 }
169 let Some(raw) = snap.bytes(zone, "output", start as i64, (end - start) as i64)? else { return Ok(None) };
170 read.raw = raw;
171 read.line_offsets = (0..count).map(at).collect();
172 const TS: &str = crate::backend::agent_session::TSIDX_FILE;
173 read.stamps = match snap.file(zone, TS)? {
174 Some(ts) if ts.size > 0 => {
175 let bytes = |off: i64, len: i64| snap.bytes(zone, TS, off, len);
176 super::stamps::stamps_for(&bytes, ts.size, &read.line_offsets)?
177 }
178 _ => None,
179 };
180 Ok(Some(read))
181 })
182 .ok()
183 .flatten()
184}
185
186/// Rebuild `output.idx` from `output` in a single streaming scan and atomically
187/// replace it. The index is the byte offset of every **non-blank** line, matching
188/// the reader's line addressing (`String::lines().filter(!trim().is_empty())`).
189///
190/// Layout: `[covered_size: u64-LE][offset_0: u64-LE][offset_1]...`. `covered_size`
191/// records the `output` size this index reflects so the read path can detect
192/// staleness in O(1) and rebuild only when `output` actually grew.
193///
194/// The scan streams `output` in 1 MiB windows so memory stays O(one line + offsets)
195/// rather than loading the whole (potentially multi-GB) file. Line splitting is
196/// done on raw bytes; since UTF-8 continuation bytes never collide with `\n`, lines
197/// are never split mid-codepoint, so per-line `from_utf8_lossy` matches the reader.
198///
199/// `output_size` and `output_gen` must be read together (`output_index`, or
200/// `output_now`): the index is published only if `output` is still
201/// `output_gen`, so a size taken from one file and a generation from its
202/// replacement would publish a partial index under the new generation (Codex
203/// on #3634).
204///
205/// Returns the number of indexed (non-blank) lines on success, or `None` if
206/// `output` is unreadable, was replaced during the scan, or the index write
207/// fails (caller falls back to slow path).
208#[cfg(test)]
209pub(crate) fn rebuild_output_idx(
210 fs: &FileStore,
211 block_id: &str,
212 output_size: u64,
213 output_gen: Option<String>,
214) -> Option<u64> {
215 build_output_idx_from(fs, block_id, output_size, 0, Vec::new(), 0, output_gen, None)
216}
217
218/// Bring an existing `output.idx` up to `output`'s current size by scanning
219/// ONLY the appended bytes, instead of rescanning from byte zero.
220///
221/// Falls back to a full rebuild whenever the existing index can't be trusted
222/// as a base: missing, header unreadable, no entries yet, built for another
223/// generation, or `output` shrank below what the index already covers
224/// (rotation/truncation).
225///
226/// Why this exists: the index is a pure cache with no incremental mutation, so
227/// a stale one used to mean a full O(file-size) rescan. On a live agent
228/// `output` grows continuously, so "stale" was the steady state — a 30-second
229/// line-count poll drove 37 full rebuilds in 19 minutes on a 759 MB
230/// transcript, mean 3168 ms, ~10% duty cycle
231/// (docs/reports/REPORT_AGENT_PANE_LOAD_RENDER_ARCHITECTURE_2026_08_27.md §5).
232///
233/// Returning the stale count instead was tried and is WRONG: `line_count`
234/// feeds `useHistoryPagination`'s tail window (`offset = total - PAGE_SIZE`),
235/// so an undercount silently drops the most recent history on reopen — codex
236/// P1 on PR #2838. The count has to stay exact; only the cost of keeping it
237/// exact is negotiable.
238pub(crate) fn extend_output_idx(fs: &FileStore, block_id: &str) -> Option<u64> {
239 // A counter merely behind (an older build appended) is caught up first:
240 // otherwise the snapshot below sees no generation, the index is published
241 // unlabelled, and the next reader — once the counter has caught up —
242 // rejects it and rebuilds the whole index.
243 let _ = fs.catch_up_line_counter(block_id, "output");
244 // An append that lost a race (the index changed under it, e.g. another
245 // srv instance extended it first) gets one more go from a fresh snapshot,
246 // then a full rebuild — never a failure, which would send a history read
247 // to the whole-file fallback.
248 for _ in 0..2 {
249 match try_extend(fs, block_id) {
250 Extend::Done(lines) => return lines,
251 Extend::Raced => continue,
252 }
253 }
254 let (output_size, output_gen) = output_now(fs, block_id)?;
255 build_output_idx_from(fs, block_id, output_size, 0, Vec::new(), 0, output_gen, None)
256}
257
258enum Extend {
259 Done(Option<u64>),
260 /// The index changed between the snapshot and the append.
261 Raced,
262}
263
264fn try_extend(fs: &FileStore, block_id: &str) -> Extend {
265 const H: i64 = OUTPUT_IDX_HEADER_LEN;
266 // Only what extending needs, in ONE snapshot: `output`'s size and
267 // generation, and the index's size, label, header and last entry. Reading
268 // the whole index here (15 MB for a 1.9 M-line transcript) and rewriting
269 // it was most of the cost of every pane open.
270 let base = fs.read_snapshot(|snap| {
271 let Some(out) = snap.file(block_id, "output")? else { return Ok(None) };
272 let idx = snap.file(block_id, IDX)?;
273 let ends = match &idx {
274 Some(i) if i.size >= H + 8 && (i.size - H) % 8 == 0 => {
275 Some((snap.bytes(block_id, IDX, 0, H)?, snap.bytes(block_id, IDX, i.size - 8, 8)?))
276 }
277 _ => None,
278 };
279 Ok(Some((out, idx, ends)))
280 });
281 let Ok(Some((out, idx, ends))) = base else { return Extend::Done(None) };
282 let output_size = out.size.max(0) as u64;
283 let output_gen = out.gen;
284 let full = || Extend::Done(build_output_idx_from(fs, block_id, output_size, 0, Vec::new(), 0, output_gen.clone(), None));
285
286 let Some(idx) = idx else { return full() };
287 // Extending reuses every entry but the last, so it needs proof that the
288 // index describes a prefix of THIS `output`: a valid generation, and the
289 // index labelled with it. An uncounted `output` has no such proof — an
290 // older build may have rewritten it (its `rev` moved, the epoch with it)
291 // to something longer than the index covers (Codex on #3663) — and an
292 // index built for another generation is no base at all. Both rebuild.
293 if output_gen.is_none() || !labelled_for(&idx.meta, output_gen.as_deref()) {
294 return full();
295 }
296 // No entries, a torn size, or bytes not stored: nothing to anchor on.
297 let Some((Some(header), Some(last))) = ends else { return full() };
298 let entry_count = ((idx.size - H) / 8) as u64;
299 let covered = u64::from_le_bytes(header.as_slice().try_into().unwrap_or([0; 8]));
300
301 if covered == output_size {
302 return Extend::Done(Some(entry_count)); // already current
303 }
304 // Shrank (rotated/truncated): a base we can't trust.
305 if covered > output_size {
306 return full();
307 }
308
309 // Re-scan from the START of the last indexed line, not from `covered`. The
310 // previous build may have indexed a trailing line that had no newline yet;
311 // bytes appended since continue THAT line rather than starting a new one,
312 // so its entry has to be re-derived (it may also have been blank then and
313 // non-blank now). Everything before it is settled and stays as it is.
314 let seed_entries = entry_count - 1;
315 let scan_start = u64::from_le_bytes(last.as_slice().try_into().unwrap_or([0; 8]));
316 if scan_start > output_size {
317 return full();
318 }
319 match build_output_idx_from(fs, block_id, output_size, scan_start, Vec::new(), seed_entries, output_gen.clone(), Some(idx.size)) {
320 Some(lines) => Extend::Done(Some(lines)),
321 None => Extend::Raced,
322 }
323}
324
325/// Shared scanner behind both entry points.
326///
327/// `scan_start` MUST be the byte offset of a line start (0, or an offset read
328/// back out of the index). `seed` is the raw offset bytes for the lines before
329/// it and `seed_count` how many — they are copied through untouched, so the
330/// blank/CRLF rules only ever get applied by the one loop below and the two
331/// paths can't drift apart.
332#[allow(clippy::too_many_arguments)]
333fn build_output_idx_from(
334 fs: &FileStore,
335 block_id: &str,
336 output_size: u64,
337 scan_start: u64,
338 seed: Vec<u8>,
339 seed_count: u64,
340 for_gen: Option<String>,
341 // `Some(index size)`: append to the existing index instead of replacing
342 // it — keep its first `seed_count` entries where they are, write the new
343 // ones after them and the new covered size in its header, only if it is
344 // still that size (`FileStore::patch_derived_if`). `seed` is then empty.
345 append_to: Option<i64>,
346) -> Option<u64> {
347 const WIN: i64 = 1 << 20; // 1 MiB read window
348
349 // Start/duration logging (docs/status/STATUS_CROSS_CHANNEL_AGENT_OPEN_FULL_APP_FREEZE_2026_08_22.md
350 // §7.1): the prior completion-only log recorded a rebuild happened but
351 // not how long it ran for or when it started, so a live incident
352 // couldn't be checked for whether a rebuild's execution window actually
353 // overlapped some other stalled RPC — only that both happened "around
354 // the same time." Kept as a lasting diagnostic, not a one-off debug
355 // print — a slow rebuild is exactly the kind of thing worth being able
356 // to correlate after the fact, the same way `mem_attribution` already is.
357 let started = std::time::Instant::now();
358 // `for_gen`: the generation of `output` this index is built for, taken by
359 // the caller BEFORE anything it builds on (seed entries, scanned bytes)
360 // was read. The write below only lands if it is still current.
361 tracing::info!(
362 block_id = %block_id,
363 covered = output_size,
364 scan_start,
365 scanned_bytes = output_size.saturating_sub(scan_start),
366 incremental = scan_start > 0,
367 "output.idx rebuild starting"
368 );
369
370 // Offsets buffer starts with the covered-size header, then any reused
371 // entries for the region before `scan_start`.
372 let mut buf: Vec<u8> = Vec::new();
373 if append_to.is_none() {
374 buf.extend_from_slice(&output_size.to_le_bytes());
375 buf.extend_from_slice(&seed);
376 }
377
378 let mut line_count: u64 = seed_count;
379 let mut cursor: u64 = scan_start; // byte offset where the current line begins
380 let mut line_buf: Vec<u8> = Vec::new(); // bytes of the current line, excluding '\n'
381 let mut read_pos: i64 = scan_start as i64;
382
383 let flush_line = |line_buf: &mut Vec<u8>,
384 cursor: &mut u64,
385 buf: &mut Vec<u8>,
386 line_count: &mut u64,
387 had_newline: bool| {
388 // The reader strips a trailing '\r' (CRLF) and treats trim-empty as
389 // blank. Shared with the FileStore line counter, which must agree.
390 let is_blank = crate::backend::storage::filestore::is_blank_line(line_buf);
391 if !is_blank {
392 buf.extend_from_slice(&cursor.to_le_bytes());
393 *line_count += 1;
394 }
395 // Advance cursor past this line's bytes (+1 for the consumed '\n').
396 *cursor += line_buf.len() as u64 + if had_newline { 1 } else { 0 };
397 line_buf.clear();
398 };
399
400 while read_pos < output_size as i64 {
401 // From the database, bounded by `output_size`: `read_at` clamps to
402 // this process's cached size, which another instance's append can
403 // leave behind — the index would then claim lines it never scanned.
404 let len = WIN.min(output_size as i64 - read_pos);
405 let chunk = match fs.read_bytes_db(block_id, "output", read_pos, len) {
406 Ok(v) => v,
407 Err(e) => {
408 // codex P2 on #2724: every exit path must log a terminal
409 // event (with duration) — an unmatched "starting" event with
410 // no completion reads as "still running," easy to mistake
411 // for a rebuild that was active throughout an incident when
412 // it actually failed fast and returned immediately.
413 tracing::warn!(
414 block_id = %block_id,
415 error = %e,
416 duration_ms = started.elapsed().as_millis() as u64,
417 "output.idx rebuild failed: read_at error"
418 );
419 return None;
420 }
421 };
422 if chunk.is_empty() {
423 break;
424 }
425 for &b in &chunk {
426 if b == b'\n' {
427 flush_line(&mut line_buf, &mut cursor, &mut buf, &mut line_count, true);
428 } else {
429 line_buf.push(b);
430 }
431 }
432 read_pos += chunk.len() as i64;
433 }
434 // Trailing line with no final '\n'.
435 if !line_buf.is_empty() {
436 flush_line(&mut line_buf, &mut cursor, &mut buf, &mut line_count, false);
437 }
438
439 // Index and its generation label in one transaction (created if missing),
440 // and only if `output` is still that generation (Codex on #3634): a
441 // replace during the scan must neither publish an index of the old bytes
442 // nor hand this caller offsets it would then apply to the new file.
443 let mut label = std::collections::HashMap::new();
444 label.insert(
445 IDX_GEN_META.to_string(),
446 for_gen.clone().map_or(serde_json::Value::Null, serde_json::Value::String),
447 );
448 let written = match (append_to, for_gen.as_deref()) {
449 (None, _) => fs.put_file_with_meta_if(block_id, IDX, &buf, label, "output", for_gen.as_deref()),
450 (Some(idx_size), Some(gen)) => fs.patch_derived_if(
451 block_id,
452 IDX,
453 &output_size.to_le_bytes(),
454 OUTPUT_IDX_HEADER_LEN + seed_count as i64 * 8,
455 &buf,
456 "output",
457 gen,
458 idx_size,
459 ),
460 // Appending needs a generation to guard on (`try_extend` checks).
461 (Some(_), None) => Ok(false),
462 };
463 match written {
464 Ok(false) => {
465 tracing::info!(
466 block_id = %block_id,
467 duration_ms = started.elapsed().as_millis() as u64,
468 "output.idx rebuild discarded: output was replaced, or the index changed, during the scan"
469 );
470 None
471 }
472 Ok(true) => {
473 tracing::info!(
474 block_id = %block_id,
475 lines = line_count,
476 covered = output_size,
477 duration_ms = started.elapsed().as_millis() as u64,
478 "output.idx rebuilt"
479 );
480 Some(line_count)
481 }
482 Err(e) => {
483 tracing::warn!(
484 block_id = %block_id,
485 error = %e,
486 duration_ms = started.elapsed().as_millis() as u64,
487 "output.idx rebuild write failed"
488 );
489 None
490 }
491 }
492}
493
494
495#[cfg(test)]
496mod tests {
497 use super::*;
498 use crate::backend::storage::filestore::{FileMeta, FileOpts};
499
500 #[test]
501 fn a_rebuild_for_a_generation_that_is_no_longer_current_publishes_nothing() {
502 // Codex P2 on #3634: `output` replaced after the builder captured its
503 // generation. The index of the old bytes must not be written, and the
504 // caller must not get a count (it would apply those offsets to the new
505 // file).
506 let fs = FileStore::open_in_memory().unwrap();
507 let zone = "blk-idx-guard";
508 fs.make_file(zone, "output", FileMeta::default(), FileOpts::default()).unwrap();
509 fs.append_lines(zone, "output", b"a\nb\nc\n").unwrap();
510 let old = output_now(&fs, zone).and_then(|(_, g)| g);
511 fs.write_file(zone, "output", b"x\n").unwrap();
512
513 assert_eq!(build_output_idx_from(&fs, zone, 6, 0, Vec::new(), 0, old, None), None);
514 assert!(fs.line_state(zone, "output.idx").unwrap().is_none(), "no index may be published");
515
516 // With the current generation it builds and publishes.
517 assert_eq!(rebuild_output_idx(&fs, zone, 2, output_now(&fs, zone).and_then(|(_, g)| g)), Some(1));
518 assert!(fs.line_state(zone, "output.idx").unwrap().is_some());
519 }
520
521 #[test]
522 fn a_read_through_the_index_comes_from_one_snapshot_or_not_at_all() {
523 let fs = FileStore::open_in_memory().unwrap();
524 let zone = "blk-idx-read";
525 fs.make_file(zone, "output", FileMeta::default(), FileOpts::default()).unwrap();
526 fs.append_lines(zone, "output", b"{\"a\":1}\n{\"b\":2}\n{\"c\":3}\n").unwrap();
527 // No index yet: nothing to read through.
528 assert!(read_via_index(&fs, zone, 0, 10).is_none());
529 let view = output_index(&fs, zone).unwrap();
530 assert_eq!(rebuild_output_idx(&fs, zone, view.output_size, view.output_gen), Some(3));
531
532 let r = read_via_index(&fs, zone, 1, 10).unwrap();
533 assert_eq!((r.total, r.line_offsets.clone()), (3, vec![8, 16]));
534 assert_eq!(r.raw, b"{\"b\":2}\n{\"c\":3}\n");
535 assert_eq!(r.stamps, None, "no stamp sidecar yet");
536 // Stamps come from the sidecar in the same snapshot as the lines: per
537 // line, the newest record at or before its offset.
538 let stamps: &[u8] = b"{\"off\":0,\"ms\":7}\n{\"off\":16,\"ms\":9}\n";
539 fs.make_file(zone, crate::backend::agent_session::TSIDX_FILE, FileMeta::default(), FileOpts::default()).unwrap();
540 fs.append_data(zone, crate::backend::agent_session::TSIDX_FILE, stamps).unwrap();
541 assert_eq!(read_via_index(&fs, zone, 1, 10).unwrap().stamps, Some(vec![7, 9]));
542 let r = read_via_index(&fs, zone, 0, 1).unwrap();
543 assert_eq!((r.line_offsets.clone(), r.raw), (vec![0], b"{\"a\":1}\n".to_vec()));
544 assert!(read_via_index(&fs, zone, 5, 10).unwrap().line_offsets.is_empty());
545
546 // Replaced by content of the same size and a different layout: the
547 // index no longer describes it, so nothing is read through it.
548 fs.write_file(zone, "output", b"{\"x\":11111}\n{\"y\":22222}\n").unwrap();
549 assert_eq!(fs.line_state(zone, "output").unwrap().unwrap().size, 24);
550 assert!(read_via_index(&fs, zone, 0, 10).is_none());
551 }
552
553 /// An agent's spawn appending to the transcript between the index being
554 /// extended and the read: the strict read gives up (the index no longer
555 /// covers `output`), and the stale read serves the indexed lines whose end
556 /// is known — never the last indexed line, which the append may continue.
557 #[test]
558 fn a_read_outrun_by_an_append_serves_the_indexed_prefix() {
559 let fs = FileStore::open_in_memory().unwrap();
560 let zone = "blk-idx-stale";
561 fs.make_file(zone, "output", FileMeta::default(), FileOpts::default()).unwrap();
562 fs.append_lines(zone, "output", b"{\"a\":1}\n{\"b\":2}\n{\"c\":3}\n").unwrap();
563 let view = output_index(&fs, zone).unwrap();
564 assert_eq!(rebuild_output_idx(&fs, zone, view.output_size, view.output_gen), Some(3));
565 fs.append_lines(zone, "output", b"{\"d\":4}\n").unwrap();
566
567 assert!(read_via_index(&fs, zone, 0, 10).is_none(), "the index no longer covers output");
568 let r = read_via_stale_index(&fs, zone, 0, 10).unwrap();
569 assert_eq!(r.total, 2, "lines 0 and 1; line 2's end isn't in the index");
570 assert_eq!(r.line_offsets, vec![0, 8]);
571 assert_eq!(r.raw, b"{\"a\":1}\n{\"b\":2}\n");
572 let r = read_via_stale_index(&fs, zone, 1, 10).unwrap();
573 assert_eq!((r.line_offsets.clone(), r.raw), (vec![8], b"{\"b\":2}\n".to_vec()));
574 assert!(read_via_stale_index(&fs, zone, 2, 10).is_none(), "nothing requested is in the usable prefix");
575 assert!(read_via_stale_index(&fs, zone, 0, 0).is_none());
576 }
577
578 #[test]
579 fn a_stale_read_refuses_a_replaced_output() {
580 let fs = FileStore::open_in_memory().unwrap();
581 let zone = "blk-idx-stale-replaced";
582 fs.make_file(zone, "output", FileMeta::default(), FileOpts::default()).unwrap();
583 fs.append_lines(zone, "output", b"{\"a\":1}\n{\"b\":2}\n").unwrap();
584 let view = output_index(&fs, zone).unwrap();
585 assert_eq!(rebuild_output_idx(&fs, zone, view.output_size, view.output_gen), Some(2));
586 // Replaced by longer content: a prefix of the old file is no prefix of this one.
587 fs.replace_file(zone, "output", b"{\"x\":11111}\n{\"y\":22222}\n{\"z\":3}\n", &[]).unwrap();
588 assert!(read_via_stale_index(&fs, zone, 0, 10).is_none());
589 }
590
591 /// Extending in place (header patched, new entries appended) must leave
592 /// exactly the bytes a full rebuild of the same `output` writes — through
593 /// blank lines, a line still open when the index was last built, and
594 /// enough entries to cross a 64 KB part.
595 #[test]
596 fn extending_in_place_matches_a_full_rebuild() {
597 let fs = FileStore::open_in_memory().unwrap();
598 let (zone, twin) = ("blk-idx-extend", "blk-idx-extend-twin");
599 for z in [zone, twin] {
600 fs.make_file(z, "output", FileMeta::default(), FileOpts::default()).unwrap();
601 }
602 let many: String = (0..9000).map(|i| format!("{{\"n\":{i}}}\n")).collect();
603 let chunks: Vec<&[u8]> = vec![b"{\"a\":1}\n\n{\"b\":", b"2}\n{\"c\":3}\n \n", many.as_bytes(), b"{\"tail\":"];
604 for chunk in chunks {
605 for z in [zone, twin] {
606 fs.append_data(z, "output", chunk).unwrap();
607 }
608 assert!(output_now(&fs, zone).unwrap().1.is_some(), "output must stay counted, or extending only ever rebuilds");
609 let extended = extend_output_idx(&fs, zone).unwrap();
610 let view = output_index(&fs, twin).unwrap();
611 let rebuilt = rebuild_output_idx(&fs, twin, view.output_size, view.output_gen).unwrap();
612 assert_eq!(extended, rebuilt);
613 assert_eq!(
614 fs.read_file(zone, IDX).unwrap(),
615 fs.read_file(twin, IDX).unwrap(),
616 "extended index differs from a rebuild after {} lines",
617 rebuilt
618 );
619 }
620 assert_eq!(output_index(&fs, zone).unwrap().fresh_lines, Some(9004));
621 }
622
623 #[test]
624 fn a_fresh_index_reads_the_same_through_either_door() {
625 let fs = FileStore::open_in_memory().unwrap();
626 let zone = "blk-idx-fresh-both";
627 fs.make_file(zone, "output", FileMeta::default(), FileOpts::default()).unwrap();
628 fs.append_lines(zone, "output", b"{\"a\":1}\n{\"b\":2}\n").unwrap();
629 let view = output_index(&fs, zone).unwrap();
630 rebuild_output_idx(&fs, zone, view.output_size, view.output_gen).unwrap();
631 let strict = read_via_index(&fs, zone, 0, 10).unwrap();
632 let stale = read_via_stale_index(&fs, zone, 0, 10).unwrap();
633 assert_eq!((strict.total, strict.raw), (stale.total, stale.raw));
634 }
635}