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}