Skip to main content

agentmux_srv/backend/storage/filestore/
counter.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Per-file line counter and generation (Phase 5a-2,
5//! SPEC_AGENT_PANE_BOUNDED_LIVE_WINDOW_MIGRATION_2026_09_23.md §6.3.7).
6//!
7//! A counted file's row carries `gen`, a random nonce, and `lines`, its line
8//! count under the reader's rule (`lines.rs`), so `(gen, line)` addresses a
9//! transcript record: the same line index never names different content
10//! under the same `gen`.
11//!
12//! The five columns (`gen`, `lines`, `lines_size`, `lines_tail`,
13//! `lines_modts`) are one **epoch**: all valid, or all NULL. An epoch starts
14//! with a fresh `gen` whenever this code can vouch for the count from byte 0:
15//! `make_file` (empty), `write_file` (counts the data) and
16//! [`FileStore::init_line_counter`] (scans a file that has none). Appends
17//! advance it in the same transaction as the write.
18//!
19//! Older builds share the global transcript store and write without
20//! maintaining the epoch. What they can't avoid is the database's own
21//! triggers (`run_filestore_migrations`): any write that changes bytes
22//! already in a file — by any connection, any build — bumps `rev`, while
23//! appends don't. An epoch records the `rev` it was counted at
24//! (`lines_rev`) and the size it covers (`lines_size`):
25//!
26//! - **Rewritten** (`rev` moved): the counted bytes may have changed. The
27//!   epoch is dropped (NULL), never patched, so the next one gets a new `gen`
28//!   and no line index is ever reused for different content.
29//! - **Only appended to** (`rev` unchanged, the size grew; or just `modts`
30//!   moved): the counted bytes are provably the same — anything else would
31//!   have bumped `rev` — so the epoch is **caught up**: only the bytes from
32//!   the start of its last open line are counted, and `gen` is kept. Every
33//!   line keeps its index. This is the normal case while builds are mixed:
34//!   an agent running in an older build appends to its shared zone all the
35//!   time, and without catching up each of its appends cost a newer reader a
36//!   re-count of the whole file and a new `gen` (its index rebuilt with it).
37//!
38//! (`lines_modts` alone can't prove anything — two writes can share a
39//! millisecond, Codex on #3631 — so it only marks an epoch as needing a
40//! check.)
41
42use rusqlite::{params, Connection, OptionalExtension, Transaction};
43
44use super::core::{FileStore, PART_DATA_SIZE};
45use super::lines::{count_append, is_blank_line, LineCount};
46use crate::backend::storage::error::StoreError;
47
48/// A fresh generation nonce: 16 hex digits (64 random bits).
49pub(super) fn new_gen() -> String {
50    let mut s = uuid::Uuid::new_v4().simple().to_string();
51    s.truncate(16);
52    s
53}
54
55/// Where a counted file stands.
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct Counted {
58    pub gen: String,
59    /// Line count; the next complete line appended gets this index.
60    pub lines: u64,
61}
62
63/// A file's size and, if it is counted, its epoch.
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub struct LineState {
66    pub size: i64,
67    pub counted: Option<Counted>,
68}
69
70/// Where an append landed.
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub struct AppendPos {
73    /// Byte offset of the first appended byte.
74    pub offset: i64,
75    /// Byte offset where the appended records start: `offset`, or one past
76    /// it when [`FileStore::append_lines`] first wrote a `\n` to close a torn
77    /// last line. The records themselves are [`normalized_records`] of the
78    /// input, so a reader holding those bytes knows exactly where they sit.
79    pub records_offset: i64,
80    /// `None` when the file is not counted (see the module doc).
81    pub counted: Option<CountedAppend>,
82}
83
84#[derive(Debug, Clone, PartialEq, Eq)]
85pub struct CountedAppend {
86    pub gen: String,
87    /// Raw appends: index of the line the first appended byte belongs to
88    /// (it may continue an unterminated line). [`FileStore::append_lines`]:
89    /// index of the first appended record.
90    pub first_line: u64,
91    /// Line count after the append.
92    pub lines: u64,
93}
94
95/// How [`FileStore::append_inner`] treats the data.
96#[derive(Clone, Copy, PartialEq, Eq)]
97pub(super) enum AppendMode {
98    /// Append the bytes as given.
99    Raw,
100    /// Complete, non-blank, `\n`-terminated lines only; a torn tail is closed
101    /// first (see [`FileStore::append_lines`]).
102    Lines,
103}
104
105/// A `db_wave_file` row's size and counter columns.
106pub(super) struct Row {
107    pub size: i64,
108    pub modts: i64,
109    /// Random per-row identity (insert trigger); differs across a delete +
110    /// re-create even when every other column matches.
111    incarnation: Option<Vec<u8>>,
112    /// Bumped by every write that rewrites existing bytes (not appends).
113    rev: i64,
114    gen: Option<String>,
115    lines: Option<i64>,
116    lines_size: Option<i64>,
117    lines_tail: Option<i64>,
118    lines_modts: Option<i64>,
119    lines_rev: Option<i64>,
120}
121
122impl Row {
123    /// The epoch, if it is valid: present, and the file has not been written
124    /// since by anything that doesn't maintain it.
125    pub fn counter(&self) -> Option<(String, LineCount)> {
126        let gen = self.gen.clone()?;
127        let (lines, tail) = (self.lines?, self.lines_tail?);
128        if self.lines_size != Some(self.size)
129            || self.lines_rev != Some(self.rev)
130            || self.lines_modts != Some(self.modts)
131        {
132            return None;
133        }
134        if lines < 0 || tail < 0 || tail > self.size {
135            return None;
136        }
137        Some((gen, LineCount { lines: lines as u64, tail_start: tail as u64 }))
138    }
139
140    /// An epoch that is behind but still describes the file's start: nothing
141    /// rewrote a byte since it was counted (`rev` unchanged), and the file
142    /// only grew past `lines_size`. Such an epoch is caught up, not dropped
143    /// (see the module doc). `None` for a missing, torn or rewritten epoch.
144    pub fn behind(&self) -> Option<Behind> {
145        let gen = self.gen.clone()?;
146        let (lines, tail, lines_size) = (self.lines?, self.lines_tail?, self.lines_size?);
147        if self.lines_rev != Some(self.rev) {
148            return None;
149        }
150        if lines < 0 || tail < 0 || tail > lines_size || lines_size > self.size {
151            return None;
152        }
153        Some(Behind { gen, count: LineCount { lines: lines as u64, tail_start: tail as u64 }, lines_size })
154    }
155
156    /// Whether any counter column is set — an epoch to drop when invalid.
157    pub fn has_epoch_columns(&self) -> bool {
158        self.gen.is_some()
159            || self.lines.is_some()
160            || self.lines_size.is_some()
161            || self.lines_tail.is_some()
162            || self.lines_modts.is_some()
163            || self.lines_rev.is_some()
164    }
165
166    fn state(&self) -> LineState {
167        LineState {
168            size: self.size,
169            counted: self.counter().map(|(gen, c)| Counted { gen, lines: c.lines }),
170        }
171    }
172}
173
174/// An epoch that can be caught up ([`Row::behind`]).
175#[derive(Debug, Clone, PartialEq, Eq)]
176pub(super) struct Behind {
177    pub gen: String,
178    /// The count at `lines_size`.
179    pub count: LineCount,
180    pub lines_size: i64,
181}
182
183/// Whether catching an epoch up over `appended` bytes keeps every counted
184/// line's content, so it may keep its generation. Not when the counted
185/// prefix ends in an unterminated, counted line that the bytes continue: that
186/// line's index would name different content (`abc` → `abcdef`), and if the
187/// continuation makes it blank (a split UTF-8 sequence completed into
188/// whitespace) the line vanishes and its index is reused for a later record
189/// (Codex on #3663). The count is still caught up — under a new generation.
190fn catch_up_keeps_gen(open: &[u8], appended: bool) -> bool {
191    !appended || open.is_empty() || is_blank_line(open)
192}
193
194/// The epoch `behind` caught up to `row`'s size, counting only the bytes from
195/// its last open line on, and whether it may keep its generation
196/// ([`catch_up_keeps_gen`]). `None` if some of those bytes aren't stored yet
197/// (a build without transactions is mid-write): nothing is counted from bytes
198/// that would only read as zeros.
199fn catch_up(conn: &Connection, zone_id: &str, name: &str, row: &Row, behind: &Behind) -> Result<Option<(LineCount, bool)>, StoreError> {
200    let tail = behind.count.tail_start as i64;
201    let Some(bytes) = read_bytes_exact(conn, zone_id, name, tail, row.size - tail)? else { return Ok(None) };
202    let (open, appended) = bytes.split_at((behind.lines_size - tail) as usize);
203    let keeps_gen = catch_up_keeps_gen(open, !appended.is_empty());
204    Ok(Some((count_append(behind.count, open, behind.lines_size as u64, appended), keeps_gen)))
205}
206
207/// Most bytes an append catches an epoch up by inline, in its own write
208/// transaction. More than this (an older build appended a lot since this
209/// build last counted) is left to [`FileStore::init_line_counter`], which
210/// scans outside the lock.
211const INLINE_CATCH_UP_MAX: i64 = 1 << 20;
212
213pub(super) fn read_row(conn: &Connection, zone_id: &str, name: &str) -> Result<Option<Row>, StoreError> {
214    Ok(conn
215        .query_row(
216            "SELECT size, modts, gen, lines, lines_size, lines_tail, lines_modts, incarnation, rev, lines_rev
217             FROM db_wave_file WHERE zoneid = ?1 AND name = ?2",
218            params![zone_id, name],
219            |r| {
220                Ok(Row {
221                    size: r.get(0)?,
222                    modts: r.get(1)?,
223                    gen: r.get(2)?,
224                    lines: r.get(3)?,
225                    lines_size: r.get(4)?,
226                    lines_tail: r.get(5)?,
227                    lines_modts: r.get(6)?,
228                    incarnation: r.get(7)?,
229                    rev: r.get::<_, Option<i64>>(8)?.unwrap_or(0),
230                    lines_rev: r.get(9)?,
231                })
232            },
233        )
234        .optional()?)
235}
236
237/// SQL fragment clearing the epoch, for writes that can't maintain it.
238pub(super) const DROP_EPOCH_SQL: &str =
239    "gen = NULL, lines = NULL, lines_size = NULL, lines_tail = NULL, lines_modts = NULL, lines_rev = NULL";
240
241/// Bytes `[offset, offset + len)` of a file, straight from its parts (never
242/// the cache). A missing part reads as zeros, as `append_data_at` pads it.
243pub(super) fn read_bytes(
244    conn: &Connection,
245    zone_id: &str,
246    name: &str,
247    offset: i64,
248    len: i64,
249) -> Result<Vec<u8>, StoreError> {
250    Ok(read_bytes_covered(conn, zone_id, name, offset, len)?.0)
251}
252
253/// [`read_bytes`], but `None` if any byte of the range is not actually
254/// stored — a part missing inside the size the file claims. That is a file
255/// mid-write by a build without transactions (it raises `size` before
256/// inserting parts, Codex on #3631); the counter must never count bytes that
257/// read as zeros only because they aren't there yet.
258pub(super) fn read_bytes_exact(
259    conn: &Connection,
260    zone_id: &str,
261    name: &str,
262    offset: i64,
263    len: i64,
264) -> Result<Option<Vec<u8>>, StoreError> {
265    let (bytes, covered) = read_bytes_covered(conn, zone_id, name, offset, len)?;
266    Ok((covered == len.max(0) as usize).then_some(bytes))
267}
268
269/// The bytes, and how many of them came from stored parts.
270fn read_bytes_covered(
271    conn: &Connection,
272    zone_id: &str,
273    name: &str,
274    offset: i64,
275    len: i64,
276) -> Result<(Vec<u8>, usize), StoreError> {
277    if len <= 0 {
278        return Ok((Vec::new(), 0));
279    }
280    let pds = PART_DATA_SIZE as i64;
281    let (first, last) = ((offset / pds) as i32, ((offset + len - 1) / pds) as i32);
282    let mut out = vec![0u8; len as usize];
283    let mut covered = 0usize;
284    let mut stmt = conn.prepare_cached(
285        "SELECT partidx, data FROM db_file_data
286         WHERE zoneid = ?1 AND name = ?2 AND partidx BETWEEN ?3 AND ?4",
287    )?;
288    let rows = stmt.query_map(params![zone_id, name, first, last], |r| {
289        Ok((r.get::<_, i32>(0)?, r.get::<_, Vec<u8>>(1)?))
290    })?;
291    for row in rows {
292        let (idx, data) = row?;
293        let part_start = idx as i64 * pds;
294        // Overlap of this part's bytes with the requested range.
295        let from = offset.max(part_start);
296        let to = (offset + len).min(part_start + data.len() as i64);
297        if from < to {
298            out[(from - offset) as usize..(to - offset) as usize]
299                .copy_from_slice(&data[(from - part_start) as usize..(to - part_start) as usize]);
300            covered += (to - from) as usize;
301        }
302    }
303    Ok((out, covered))
304}
305
306/// The records [`FileStore::append_lines`] writes for `data`: its complete,
307/// non-blank lines, each ending in `\n` (the reader's rule). What a caller
308/// publishes alongside an append, so the bytes match what landed at
309/// [`AppendPos::records_offset`].
310pub fn normalized_records(data: &[u8]) -> Vec<u8> {
311    normalize_lines(data, false)
312}
313
314/// `data` as complete, non-blank lines, each ending in `\n`; prefixed with a
315/// `\n` when `close_tail`, so it can't continue a torn last line.
316fn normalize_lines(data: &[u8], close_tail: bool) -> Vec<u8> {
317    let mut out = Vec::with_capacity(data.len() + 2);
318    for line in data.split(|&b| b == b'\n') {
319        if !is_blank_line(line) {
320            out.extend_from_slice(line);
321            out.push(b'\n');
322        }
323    }
324    if close_tail && !out.is_empty() {
325        out.insert(0, b'\n');
326    }
327    out
328}
329
330/// How much of a scan's tail [`FileStore::init_line_counter`] re-checks
331/// before trusting it.
332const INIT_FINGERPRINT_BYTES: i64 = 64;
333/// Scan window for [`FileStore::init_line_counter`]; the connection lock is
334/// held for one window at a time.
335const INIT_SCAN_WINDOW: i64 = 1 << 20;
336
337impl FileStore {
338    /// The append behind `append_data_at` and [`Self::append_lines`]: writes
339    /// the parts, the size and the counter in one transaction.
340    #[track_caller]
341    pub(super) fn append_inner(
342        &self,
343        zone_id: &str,
344        name: &str,
345        data: &[u8],
346        mode: AppendMode,
347        now: i64,
348    ) -> Result<(AppendPos, i64), StoreError> {
349        self.write_txn(|tx| Self::append_in_tx(tx, zone_id, name, data, mode, now))
350    }
351
352    /// One append inside a caller's transaction: parts, size and counter.
353    /// Returns where it landed and the new size (equal to the offset when
354    /// nothing was written).
355    pub(super) fn append_in_tx(
356        tx: &Transaction<'_>,
357        zone_id: &str,
358        name: &str,
359        data: &[u8],
360        mode: AppendMode,
361        now: i64,
362    ) -> Result<(AppendPos, i64), StoreError> {
363        let row = read_row(tx, zone_id, name)?.ok_or(StoreError::NotFound)?;
364        let size = row.size;
365        // An epoch another build only appended past is caught up here, in
366        // this transaction, and keeps its generation (module doc).
367        // Further behind than that is left for `init_line_counter`: this
368        // append leaves such an epoch alone (it stays behind, not invalid),
369        // rather than dropping what a later scan can still catch up.
370        let mut keep_behind = false;
371        let counter = match row.counter() {
372            Some(c) => Some(c),
373            None => match row.behind() {
374                Some(b) if size - b.lines_size <= INLINE_CATCH_UP_MAX => match catch_up(tx, zone_id, name, &row, &b)? {
375                    Some((count, true)) => Some((b.gen.clone(), count)),
376                    // It needs a new generation: left behind for
377                    // `init_line_counter`, which starts one — this append
378                    // stays uncounted rather than extend the old epoch.
379                    Some((_, false)) => {
380                        keep_behind = true;
381                        None
382                    }
383                    None => None,
384                },
385                Some(_) => {
386                    keep_behind = true;
387                    None
388                }
389                None => None,
390            },
391        };
392
393        let normalized;
394        let (data, records_offset): (&[u8], i64) = match mode {
395            AppendMode::Raw => (data, size),
396            AppendMode::Lines => {
397                let torn = size > 0 && read_bytes(tx, zone_id, name, size - 1, 1)?[0] != b'\n';
398                normalized = normalize_lines(data, torn);
399                // The closing `\n` (if any) precedes the records.
400                let skip = i64::from(torn && !normalized.is_empty());
401                (&normalized, size + skip)
402            }
403        };
404        if data.is_empty() {
405            let counted = counter.map(|(gen, c)| CountedAppend { gen, first_line: c.lines, lines: c.lines });
406            return Ok((AppendPos { offset: size, records_offset: size, counted }, size));
407        }
408
409        // The counter needs the unterminated last line before the append
410        // (normally empty: transcript lines end in `\n`).
411        // If those bytes aren't all stored (a build without transactions
412        // is mid-write), the count can't be advanced: the epoch is
413        // dropped below, like any write it can't vouch for.
414        let counted_before = match &counter {
415            Some((gen, c)) => {
416                read_bytes_exact(tx, zone_id, name, c.tail_start as i64, size - c.tail_start as i64)?
417                    .map(|open| (gen.clone(), *c, open))
418            }
419            None => None,
420        };
421
422        let new_size = size + data.len() as i64;
423        let start_part = (size / PART_DATA_SIZE as i64) as i32;
424        let offset_in_part = (size % PART_DATA_SIZE as i64) as usize;
425        let mut data_offset = 0usize;
426        let mut current_part = start_part;
427        if offset_in_part > 0 {
428            // Keep only the bytes the size covers: anything past it (a
429            // torn append by a build without transactions) is not content.
430            let mut part: Vec<u8> = tx
431                .query_row(
432                    "SELECT data FROM db_file_data WHERE zoneid = ?1 AND name = ?2 AND partidx = ?3",
433                    params![zone_id, name, start_part],
434                    |r| r.get(0),
435                )
436                .optional()?
437                .unwrap_or_default();
438            part.resize(offset_in_part, 0);
439            let to_copy = (PART_DATA_SIZE - offset_in_part).min(data.len());
440            part.extend_from_slice(&data[..to_copy]);
441            data_offset = to_copy;
442            tx.execute(
443                "REPLACE INTO db_file_data (zoneid, name, partidx, data) VALUES (?1, ?2, ?3, ?4)",
444                params![zone_id, name, current_part, part],
445            )?;
446            current_part += 1;
447        }
448        while data_offset < data.len() {
449            let end = (data_offset + PART_DATA_SIZE).min(data.len());
450            tx.execute(
451                "REPLACE INTO db_file_data (zoneid, name, partidx, data) VALUES (?1, ?2, ?3, ?4)",
452                params![zone_id, name, current_part, &data[data_offset..end]],
453            )?;
454            data_offset = end;
455            current_part += 1;
456        }
457
458        let counted = match counted_before {
459            Some((gen, before, open)) => {
460                let after = count_append(before, &open, size as u64, data);
461                let open_counted = !open.is_empty() && !is_blank_line(&open);
462                tx.execute(
463                    "UPDATE db_wave_file SET size = ?1, modts = ?2,
464                         lines = ?3, lines_size = ?1, lines_tail = ?4, lines_modts = ?2,
465                         lines_rev = COALESCE(rev, 0)
466                     WHERE zoneid = ?5 AND name = ?6",
467                    params![new_size, now, after.lines as i64, after.tail_start as i64, zone_id, name],
468                )?;
469                // Raw: the first byte may continue the open line. Lines:
470                // any open line was closed first (it keeps its index, or
471                // was blank and has none), so the first record gets the
472                // next index.
473                let first_line = match mode {
474                    AppendMode::Raw => before.lines - u64::from(open_counted),
475                    AppendMode::Lines => before.lines,
476                };
477                Some(CountedAppend { gen, first_line, lines: after.lines })
478            }
479            None => {
480                if row.has_epoch_columns() && !keep_behind {
481                    tracing::warn!(
482                        zone = %zone_id, name = %name,
483                        "line counter dropped: the file was written, or is mid-write, by a build that doesn't maintain it"
484                    );
485                    tx.execute(
486                        &format!(
487                            "UPDATE db_wave_file SET size = ?1, modts = ?2, {DROP_EPOCH_SQL}
488                             WHERE zoneid = ?3 AND name = ?4"
489                        ),
490                        params![new_size, now, zone_id, name],
491                    )?;
492                } else {
493                    tx.execute(
494                        "UPDATE db_wave_file SET size = ?1, modts = ?2 WHERE zoneid = ?3 AND name = ?4",
495                        params![new_size, now, zone_id, name],
496                    )?;
497                }
498                None
499            }
500        };
501        Ok((AppendPos { offset: size, records_offset, counted }, new_size))
502    }
503
504    /// [`Self::append_lines`], plus one receive-time record appended to
505    /// `stamp_file` (`output.tsidx`: `{"off":<offset>,"ms":<now>}`), in the
506    /// SAME transaction — one commit where the line and its stamp took two
507    /// (Phase 5a-3: a transcript event now waits for its writes, so each
508    /// commit is latency). No stamp when nothing was appended.
509    #[track_caller]
510    pub fn append_lines_stamped(
511        &self,
512        zone_id: &str,
513        name: &str,
514        data: &[u8],
515        stamp_file: &str,
516    ) -> Result<AppendPos, StoreError> {
517        let now = agentmux_common::time::now_ms();
518        let opts_json = serde_json::to_string(&super::FileOpts::default())?;
519        let (pos, new_size, stamp_size) = self.write_txn(|tx| {
520            let (pos, new_size) = Self::append_in_tx(tx, zone_id, name, data, AppendMode::Lines, now)?;
521            if new_size == pos.offset {
522                return Ok((pos, new_size, None));
523            }
524            // The sidecar is created on first use, like `make_file` (an empty
525            // counted file); an existing one is left alone.
526            tx.execute(
527                "INSERT OR IGNORE INTO db_wave_file (zoneid, name, size, createdts, modts, opts, meta,
528                     gen, lines, lines_size, lines_tail, lines_modts, lines_rev)
529                 VALUES (?1, ?2, 0, ?3, ?3, ?4, '{}', ?5, 0, 0, 0, ?3, 0)",
530                params![zone_id, stamp_file, now, opts_json, new_gen()],
531            )?;
532            let stamp = format!("{{\"off\":{},\"ms\":{now}}}\n", pos.offset.max(0));
533            let (_, stamp_size) = Self::append_in_tx(tx, zone_id, stamp_file, stamp.as_bytes(), AppendMode::Raw, now)?;
534            Ok((pos, new_size, Some(stamp_size)))
535        })?;
536        if new_size > pos.offset {
537            self.note_appended(zone_id, name, new_size, now);
538        }
539        if let Some(size) = stamp_size {
540            self.note_appended(zone_id, stamp_file, size, now);
541        }
542        Ok(pos)
543    }
544
545    /// Append transcript lines. `data` is normalized to complete, non-blank,
546    /// `\n`-terminated lines (the reader's rule), and if the file ends
547    /// mid-line (a torn record) a `\n` closes it first: the torn line keeps
548    /// its index instead of fusing with the new record, which would make both
549    /// unparseable. On a counted file, `first_line` is the first appended
550    /// line's index and `lines - first_line` how many were appended.
551    #[allow(dead_code)] // wired into the transcript writers in 5a-3
552    #[track_caller]
553    pub fn append_lines(&self, zone_id: &str, name: &str, data: &[u8]) -> Result<AppendPos, StoreError> {
554        let now = agentmux_common::time::now_ms();
555        let (pos, new_size) = self.append_inner(zone_id, name, data, AppendMode::Lines, now)?;
556        // Blank-only input normalizes to nothing: no write, no cache update.
557        if new_size > pos.offset {
558            self.note_appended(zone_id, name, new_size, now);
559        }
560        Ok(pos)
561    }
562
563    /// A file's size and epoch, from the database (never the cache).
564    #[allow(dead_code)] // read by the transcript RPCs in 5a-3
565    #[track_caller]
566    pub fn line_state(&self, zone_id: &str, name: &str) -> Result<Option<LineState>, StoreError> {
567        self.read_conn(|conn| Ok(read_row(conn, zone_id, name)?.map(|r| r.state())))
568    }
569
570    /// Start an epoch for a file that has none (written before this code, or
571    /// by an older build): count it from byte 0 and mint a fresh `gen`.
572    ///
573    /// The scan takes the connection lock one window at a time, so appends
574    /// continue meanwhile; the final transaction counts what they added. It
575    /// gives up (`Ok(None)`, the file stays uncounted) if the scanned bytes
576    /// may have changed underneath it, and returns the existing epoch if
577    /// another writer started one first. Cost: one read of the file, once per
578    /// epoch.
579    #[allow(dead_code)] // called by the transcript RPCs in 5a-3
580    #[track_caller]
581    pub fn init_line_counter(&self, zone_id: &str, name: &str) -> Result<Option<LineState>, StoreError> {
582        match self.init_scan(zone_id, name)? {
583            InitScan::Done(state) => Ok(state),
584            InitScan::Scanned(scan) => self.init_finish(zone_id, name, scan),
585        }
586    }
587
588    /// A file's size and epoch, catching up an epoch that is only behind
589    /// (another build appended since it was counted; cheap — the appended
590    /// bytes) but never starting one from byte 0: a file without a usable
591    /// epoch is returned uncounted. For read paths that must not pay a
592    /// whole-file scan, but should see the generation an append by an older
593    /// build didn't change.
594    #[track_caller]
595    pub fn catch_up_line_counter(&self, zone_id: &str, name: &str) -> Result<Option<LineState>, StoreError> {
596        // The scan decides on the row it reads under its own lock: a separate
597        // check first could see an epoch behind, then a rewrite land before
598        // the scan reads the row again, and the scan would count from byte 0
599        // (ReAgent on #3663).
600        match self.init_scan_from(zone_id, name, ScanFrom::CatchUpOnly)? {
601            InitScan::Done(state) => Ok(state),
602            InitScan::Scanned(scan) => match self.init_finish(zone_id, name, scan)? {
603                Some(state) => Ok(Some(state)),
604                // Moved or rewritten during the scan: as it is now, uncounted
605                // if it can't be caught up.
606                None => self.line_state(zone_id, name),
607            },
608        }
609    }
610
611    /// Phase 1 of [`Self::init_line_counter`]: the windowed scan — from byte 0,
612    /// or, for an epoch that is only behind, from the start of its last open
613    /// line.
614    #[track_caller]
615    pub(super) fn init_scan(&self, zone_id: &str, name: &str) -> Result<InitScan, StoreError> {
616        self.init_scan_from(zone_id, name, ScanFrom::Anywhere)
617    }
618
619    /// [`Self::init_scan`], or with [`ScanFrom::CatchUpOnly`] only an epoch
620    /// that is behind is scanned; anything else is returned as it is,
621    /// uncounted, without reading the file.
622    #[track_caller]
623    pub(super) fn init_scan_from(&self, zone_id: &str, name: &str, from: ScanFrom) -> Result<InitScan, StoreError> {
624        let (row, base, open) = {
625            let conn = self.lock_conn();
626            let Some(row) = read_row(&conn, zone_id, name)? else { return Ok(InitScan::Done(None)) };
627            if row.counter().is_some() {
628                return Ok(InitScan::Done(Some(row.state())));
629            }
630            let behind = row.behind();
631            if behind.is_none() && from == ScanFrom::CatchUpOnly {
632                return Ok(InitScan::Done(Some(row.state())));
633            }
634            // The open line's bytes, which the appended ones continue.
635            let open = match &behind {
636                Some(b) => {
637                    let tail = b.count.tail_start as i64;
638                    read_bytes_exact(&conn, zone_id, name, tail, b.lines_size - tail)?
639                }
640                None => Some(Vec::new()),
641            };
642            let Some(open) = open else { return Ok(InitScan::Done(None)) };
643            (row, behind, open)
644        };
645        // Kept to decide, once the final size is known, whether the epoch may
646        // keep its generation (`catch_up_keeps_gen`).
647        let base_open = open.clone();
648        let scan_to = row.size;
649        let (mut count, mut open, mut pos) = match &base {
650            Some(b) => (b.count, open, b.lines_size),
651            None => (LineCount { lines: 0, tail_start: 0 }, open, 0i64),
652        };
653        while pos < scan_to {
654            let len = INIT_SCAN_WINDOW.min(scan_to - pos);
655            let chunk = {
656                let conn = self.lock_conn();
657                read_bytes_exact(&conn, zone_id, name, pos, len)?
658            };
659            // Bytes the file claims but doesn't store yet: not countable.
660            let Some(chunk) = chunk else { return Ok(InitScan::Done(None)) };
661            count = count_append(count, &open, pos as u64, &chunk);
662            match chunk.iter().rposition(|&b| b == b'\n') {
663                Some(nl) => open = chunk[nl + 1..].to_vec(),
664                None => open.extend_from_slice(&chunk),
665            }
666            pos += len;
667        }
668        let fp_len = INIT_FINGERPRINT_BYTES.min(scan_to);
669        let fingerprint = {
670            let conn = self.lock_conn();
671            read_bytes_exact(&conn, zone_id, name, scan_to - fp_len, fp_len)?
672        };
673        let Some(fingerprint) = fingerprint else { return Ok(InitScan::Done(None)) };
674        Ok(InitScan::Scanned(ScanResult {
675            scan_to,
676            count,
677            open,
678            fingerprint,
679            incarnation: row.incarnation,
680            rev: row.rev,
681            base,
682            base_open,
683        }))
684    }
685
686    /// Phase 2 of [`Self::init_line_counter`]: validate the scan against the
687    /// row as it is now, count what was appended since, and start the epoch,
688    /// all in one transaction.
689    #[track_caller]
690    pub(super) fn init_finish(&self, zone_id: &str, name: &str, scan: ScanResult) -> Result<Option<LineState>, StoreError> {
691        let ScanResult { scan_to, count, open, fingerprint, incarnation, rev, base, base_open } = scan;
692        let fp_len = fingerprint.len() as i64;
693        self.write_txn(|tx| {
694            let Some(row) = read_row(tx, zone_id, name)? else { return Ok(None) };
695            if row.counter().is_some() {
696                return Ok(Some(row.state()));
697            }
698            // The scanned bytes must still be the file's bytes: the same row
699            // (its random incarnation — not deleted and re-created, even
700            // within one millisecond, Codex on #3631) and nothing rewritten since the
701            // scan began — `rev` is bumped by the database's triggers for any
702            // writer, older builds included (review of #3631). Appends don't
703            // bump it, and are counted below. The size and tail checks are
704            // redundant with `rev`, kept as a second line of defence.
705            if row.incarnation != incarnation
706                || row.rev != rev
707                || row.size < scan_to
708                || read_bytes_exact(tx, zone_id, name, scan_to - fp_len, fp_len)?.as_ref() != Some(&fingerprint)
709            {
710                return Ok(None);
711            }
712            // Catching up: the epoch scanned from must still be the row's —
713            // another instance may have dropped it, or started or caught up
714            // one of its own, meanwhile.
715            if let Some(b) = &base {
716                if row.behind().as_ref() != Some(b) {
717                    return Ok(None);
718                }
719            }
720            let Some(appended) = read_bytes_exact(tx, zone_id, name, scan_to, row.size - scan_to)? else {
721                return Ok(None);
722            };
723            let count = count_append(count, &open, scan_to as u64, &appended);
724            // A caught-up epoch keeps its generation when every counted line
725            // kept its content (`catch_up_keeps_gen`); otherwise, and for a
726            // count from byte 0, a new one.
727            let gen = match base {
728                Some(b) if catch_up_keeps_gen(&base_open, row.size > b.lines_size) => b.gen,
729                _ => new_gen(),
730            };
731            tx.execute(
732                "UPDATE db_wave_file SET gen = ?1, lines = ?2, lines_size = ?3, lines_tail = ?4, lines_modts = ?5,
733                     lines_rev = COALESCE(rev, 0)
734                 WHERE zoneid = ?6 AND name = ?7",
735                params![gen, count.lines as i64, row.size, count.tail_start as i64, row.modts, zone_id, name],
736            )?;
737            Ok(Some(LineState { size: row.size, counted: Some(Counted { gen, lines: count.lines }) }))
738        })
739    }
740}
741
742/// Where [`FileStore::init_scan_from`] may start counting.
743#[derive(Debug, Clone, Copy, PartialEq, Eq)]
744pub(super) enum ScanFrom {
745    /// From byte 0 if need be ([`FileStore::init_line_counter`]).
746    Anywhere,
747    /// Only by catching up an epoch that is behind
748    /// ([`FileStore::catch_up_line_counter`]): never a whole-file scan.
749    CatchUpOnly,
750}
751
752/// Outcome of [`FileStore::init_scan`].
753pub(super) enum InitScan {
754    /// Nothing to scan: the file is missing, or already counted.
755    Done(Option<LineState>),
756    Scanned(ScanResult),
757}
758
759/// A finished scan, and the row identity it was taken against.
760pub(super) struct ScanResult {
761    scan_to: i64,
762    count: LineCount,
763    open: Vec<u8>,
764    fingerprint: Vec<u8>,
765    incarnation: Option<Vec<u8>>,
766    rev: i64,
767    /// The epoch the scan caught up from, if it didn't start at byte 0.
768    base: Option<Behind>,
769    /// That epoch's open (unterminated) last line, as counted.
770    base_open: Vec<u8>,
771}