Skip to content

feat: compress the searchable session archive - #148

Merged
Freudator86 merged 18 commits into
mainfrom
fm/fm-transcript-compress-archive
Aug 20, 2026
Merged

feat: compress the searchable session archive#148
Freudator86 merged 18 commits into
mainfrom
fm/fm-transcript-compress-archive

Conversation

@Freudator86

@Freudator86 Freudator86 commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

Intent

Compress the searchable session archive without giving up the property that makes it trustworthy: a full content scan is the index, and no inverted index may be added. This branch carries that work; this run validates it after a CI failure the user steered on directly.

WHAT THE BRANCH DOES. Each session is written as one zstd file at level 3, chosen on measurement over this seat's real store: a full rebuild with verification costs 89.8 s plain, 119.4 s at -3 (52.1 MB), 159.6 s at -9 (48.7 MB), so -9 spends 40 more seconds of every rebuild for 1.2 percent more saving. The live archive was migrated 271.2 MB to 52.1 MB with no session lost. Verification runs over the compressed output through the decompressor, and a store file it cannot read is reported as unread rather than counted as clean. A rebuild converges retained sessions the raw store no longer has. A missing compressor refuses the build before creating anything. The documentation trap that motivated the task is closed: the old promise that plain 'grep -r' works identically is withdrawn everywhere, because over a compressed store that command reports no matches on a full archive.

THE SEARCH USES ZSTD AND GREP, NOT RIPGREP, by the user's decision after two CI rounds failed on the runner's ripgrep while every build and verification test passed there. Required tools are zstd, grep and xargs. Do not reintroduce ripgrep, an override, or an index.

WHAT THIS RUN'S COMMIT FIXES, all measured rather than reasoned:

  1. A search matching EVERY session exited 2. The scan accepted only signal 141 from the decompressor when grep stopped reading early; the runner's zstd reports that closed pipe as an ordinary error instead. The rule is now that the reader's exit status means something only when the consumer read to the end: grep reporting a match means it deliberately stopped reading, so the reader's status is not consulted; grep reading the session through and finding nothing means the reader had its chance, so a non-zero status is a genuine read failure and stays an error. The status is deliberately NOT masked - masking it would leave a search that can never report a real failure - and a corrupt store file is proven to still exit non-zero on both paths. A test runs the real script against a reader that hides the signal like the runner's; it fails against the old rule and passes against the new one.

  2. Parallel workers wrote into one shared pipe, and buffered writes interleave at block boundaries rather than line boundaries. Three runs of one query returned 8121, 8105 and 8104 lines, with header lines cut mid-path. Each worker now writes its own file which the parent gathers, so a query returns the same answer every time.

  3. grep called five sessions binary and printed no lines for them, because a reduced session quotes material carrying the occasional control byte, so --files-only reported those sessions and the context path did not. The scan now reads the store as text.

MEASURED AGAINST GROUND TRUTH taken by decompressing every session separately, because a passing test is not the deliverable here - earlier rounds certified a search that found nothing: no-mistakes 2048 sessions and 88294 matching lines, forgejo 123 and 2306, Grossreinschiff 75 and 232, exit code 127 one session and 2 lines, all exact, and three runs of one query identical. Whole-store timings are 1.50 to 1.54 s for the file list and 2.31 to 2.64 s with context, and the documentation carries those figures rather than the earlier single one.

FM_ZSTD stays on the reducer by the user's explicit decision, because it is the only way to build a store on a machine without zstd on PATH; the search always uses the zstd on PATH. That split is recorded as a known condition where a reader of the variable meets it, which is the disposition the user chose over extending the withdrawal.

CONSTRAINTS. Shared tracked firstmate material, so firstmate-coding-guidelines applies and the colocated tests move with the change: 37 tests pass and bin/fm-lint.sh is clean. No credential value, pattern literal or transcript content in any commit, test or document. The repository forbids squash, so this lands as a true merge commit. Exit statuses are 0 matched, 1 genuinely no match, 2 usage error or missing archive or missing tool or scanner failure.

What Changed

  • Store each reduced session as an atomic level-3 zstd file, verify it through decompression, and converge retained plain sessions during rebuilds.
  • Scan compressed sessions with zstd, grep, and xargs while preserving deterministic parallel output, treating control bytes as text, and surfacing genuine reader failures.
  • Update archive documentation and regression coverage for compression, search dependencies, exit statuses, corruption handling, and measured rebuild/search performance.

Risk Assessment

✅ Low: The compression and parallel-search changes are well bounded, and the current code consistently fails closed across reader, worker, gathering, and output-pipeline failures without contradicting the stated intent.

Testing

The focused archive suite passed, and an end-to-end 97-session compressed-store scan exactly matched independent decompression ground truth, remained byte-identical across three parallel runs, preserved a control-byte match in context output, and rejected corrupt input with exit 2 on both query paths.

Evidence: End-to-end compressed archive search evidence

Ground truth: 97 files / 97 matching lines. Three parallel searches produced identical hashes. Context search returned all 97 sessions, including the control-byte session. Corrupt input exited 2 for matching and non-matching queries.

End-to-end compressed archive search evidence
Fixture: 97 independently zstd-compressed sessions; one matching line per session; one line includes a control byte.

Ground truth via per-session zstd + grep: files=97 matching_lines=97
files-only run 1: exit=0 files=97 sha256=b5ae421f1692d747ec2662a68b81e5759e602b8f1243a1a073eb2db7f2850e33
files-only run 2: exit=0 files=97 sha256=b5ae421f1692d747ec2662a68b81e5759e602b8f1243a1a073eb2db7f2850e33
files-only run 3: exit=0 files=97 sha256=b5ae421f1692d747ec2662a68b81e5759e602b8f1243a1a073eb2db7f2850e33
Repeated parallel file lists: byte-identical=yes
context search: exit=0 headers=97 matching_lines=97 control_session_present=1
Wrapper equals decompressed ground truth: files=yes lines=yes
Corrupt session scan exits: matching-query=2 no-match-query=2 (required scanner error=2)
Evidence: Rendered context-search output

=== claude-redacted/session-01.txt.zst
    cwd: /demo/project|span: 2026-08-19T00:00:00Z .. 2026-08-19T00:00:01Z|
  5:user: fleet needle session 01

=== claude-redacted/session-02.txt.zst
    cwd: /demo/project|span: 2026-08-19T00:00:00Z .. 2026-08-19T00:00:01Z|
  5:user: fleet needle session 02

=== claude-redacted/session-03.txt.zst
    cwd: /demo/project|span: 2026-08-19T00:00:00Z .. 2026-08-19T00:00:01Z|
  5:user: fleet needle session 03

=== claude-redacted/session-04.txt.zst
    cwd: /demo/project|span: 2026-08-19T00:00:00Z .. 2026-08-19T00:00:01Z|
  5:user: fleet needle session 04

=== claude-redacted/session-05.txt.zst
    cwd: /demo/project|span: 2026-08-19T00:00:00Z .. 2026-08-19T00:00:01Z|
  5:user: fleet needle session 05

=== claude-redacted/session-06.txt.zst
    cwd: /demo/project|span: 2026-08-19T00:00:00Z .. 2026-08-19T00:00:01Z|
  5:user: fleet needle session 06

=== claude-redacted/session-07.txt.zst
    cwd: /demo/project|span: 2026-08-19T00:00:00Z .. 2026-08-19T00:00:01Z|
  5:user: fleet needle session 07

=== claude-redacted/session-08.txt.zst
    cwd: /demo/project|span: 2026-08-19T00:00:00Z .. 2026-08-19T00:00:01Z|
  5:user: fleet needle session 08

=== claude-redacted/session-09.txt.zst
    cwd: /demo/project|span: 2026-08-19T00:00:00Z .. 2026-08-19T00:00:01Z|
  5:user: fleet needle session 09

=== claude-redacted/session-10.txt.zst
    cwd: /demo/project|span: 2026-08-19T00:00:00Z .. 2026-08-19T00:00:01Z|
  5:user: fleet needle session 10

=== claude-redacted/session-11.txt.zst
    cwd: /demo/project|span: 2026-08-19T00:00:00Z .. 2026-08-19T00:00:01Z|
  5:user: fleet needle session 11

=== claude-redacted/session-12.txt.zst
    cwd: /demo/project|span: 2026-08-19T00:00:00Z .. 2026-08-19T00:00:01Z|
  5:user: fleet needle session 12

=== claude-redacted/session-13.txt.zst
    cwd: /demo/project|span: 2026-08-19T00:00:00Z .. 2026-08-19T00:00:01Z|
  5:user: fleet needle session 13

=== claude-redacted/session-14.txt.zst
    cwd: /demo/project|span: 2026-08-19T00:00:00Z .. 2026-08-19T00:00:01Z|
  5:user: fleet needle session 14

=== claude-redacted/session-15.txt.zst
    cwd: /demo/project|span: 2026-08-19T00:00:00Z .. 2026-08-19T00:00:01Z|
  5:user: fleet needle session 15

=== claude-redacted/session-16.txt.zst
    cwd: /demo/project|span: 2026-08-19T00:00:00Z .. 2026-08-19T00:00:01Z|
  5:user: fleet needle session 16

=== claude-redacted/session-17.txt.zst
    cwd: /demo/project|span: 2026-08-19T00:00:00Z .. 2026-08-19T00:00:01Z|
  5:user: fleet needle session 17

=== claude-redacted/session-18.txt.zst
    cwd: /demo/project|span: 2026-08-19T00:00:00Z .. 2026-08-19T00:00:01Z|
  5:user: fleet needle session 18

=== claude-redacted/session-19.txt.zst
    cwd: /demo/project|span: 2026-08-19T00:00:00Z .. 2026-08-19T00:00:01Z|
  5:user: fleet needle session 19

=== claude-redacted/session-20.txt.zst
    cwd: /demo/project|span: 2026-08-19T00:00:00Z .. 2026-08-19T00:00:01Z|
  5:user: fleet needle session 20

=== claude-redacted/session-21.txt.zst
    cwd: /demo/project|span: 2026-08-19T00:00:00Z .. 2026-08-19T00:00:01Z|
  5:user: fleet needle session 21

=== claude-redacted/session-22.txt.zst
    cwd: /demo/project|span: 2026-08-19T00:00:00Z .. 2026-08-19T00:00:01Z|
  5:user: fleet needle session 22

=== claude-redacted/session-23.txt.zst
    cwd: /demo/project|span: 2026-08-19T00:00:00Z .. 2026-08-19T00:00:01Z|
  5:user: fleet needle session 23

=== claude-redacted/session-24.txt.zst
    cwd: /demo/project|span: 2026-08-19T00:00:00Z .. 2026-08-19T00:00:01Z|
  5:user: fleet needle session 24

=== claude-redacted/session-25.txt.zst
    cwd: /demo/project|span: 2026-08-19T00:00:00Z .. 2026-08-19T00:00:01Z|
  5:user: fleet needle session 25

=== claude-redacted/session-26.txt.zst
    cwd: /demo/project|span: 2026-08-19T00:00:00Z .. 2026-08-19T00:00:01Z|
  5:user: fleet needle session 26

=== claude-redacted/session-27.txt.zst
    cwd: /demo/project|span: 2026-08-19T00:00:00Z .. 2026-08-19T00:00:01Z|
  5:user: fleet needle session 27

=== claude-redacted/session-28.txt.zst
    cwd: /demo/project|span: 2026-08-19T00:00:00Z .. 2026-08-19T00:00:01Z|
  5:user: fleet needle session 28

=== claude-redacted/session-29.txt.zst
    cwd: /demo/project|span: 2026-08-19T00:00:00Z .. 2026-08-19T00:00:01Z|
  5:user: fleet needle session 29

=== claude-redacted/session-30.txt.zst
    cwd: /demo/project|span: 2026-08-19T00:00:00Z .. 2026-08-19T00:00:01Z|
  5:user: fleet needle session 30

=== claude-redacted/session-31.txt.zst
    cwd: /demo/project|span: 2026-08-19T00:00:00Z .. 2026-08-19T00:00:01Z|
  5:user: fleet needle session 31

=== claude-redacted/session-32.txt.zst
    cwd: /demo/project|span: 2026-08-19T00:00:00Z .. 2026-08-19T00:00:01Z|
  5:user: fleet needle session 32

=== claude-redacted/session-33.txt.zst
    cwd: /demo/project|span: 2026-08-19T00:00:00Z .. 2026-08-19T00:00:01Z|
  5:user: fleet needle session 33

=== claude-redacted/session-34.txt.zst
    cwd: /demo/project|span: 2026-08-19T00:00:00Z .. 2026-08-19T00:00:01Z|
  5:user: fleet needle session 34

=== claude-redacted/session-35.txt.zst
    cwd: /demo/project|span: 2026-08-19T00:00:00Z .. 2026-08-19T00:00:01Z|
  5:user: fleet needle session 35

=== claude-redacted/session-36.txt.zst
    cwd: /demo/project|span: 2026-08-19T00:00:00Z .. 2026-08-19T00:00:01Z|
  5:user: fleet needle session 36

=== claude-redacted/session-37.txt.zst
    cwd: /demo/project|span: 2026-08-19T00:00:00Z .. 2026-08-19T00:00:01Z|
  5:user: fleet needle session 37

=== claude-redacted/session-38.txt.zst
    cwd: /demo/project|span: 2026-08-19T00:00:00Z .. 2026-08-19T00:00:01Z|
  5:user: fleet needle session 38

=== claude-redacted/session-39.txt.zst
    cwd: /demo/project|span: 2026-08-19T00:00:00Z .. 2026-08-19T00:00:01Z|
  5:user: fleet needle session 39

=== claude-redacted/session-40.txt.zst
    cwd: /demo/project|span: 2026-08-19T00:00:00Z .. 2026-08-19T00:00:01Z|
  5:user: fleet needle session 40

=== claude-redacted/session-41.txt.zst
    cwd: /demo/project|span: 2026-08-19T00:00:00Z .. 2026-08-19T00:00:01Z|
  5:user: fleet needle session 41

=== claude-redacted/session-42.txt.zst
    cwd: /demo/project|span: 2026-08-19T00:00:00Z .. 2026-08-19T00:00:01Z|
  5:user: fleet needle session 42

=== claude-redacted/session-43.txt.zst
    cwd: /demo/project|span: 2026-08-19T00:00:00Z .. 2026-08-19T00:00:01Z|
  5:user: fleet needle session 43

=== claude-redacted/session-44.txt.zst
    cwd: /demo/project|span: 2026-08-19T00:00:00Z .. 2026-08-19T00:00:01Z|
  5:user: fleet needle session 44

=== claude-redacted/session-45.txt.zst
    cwd: /demo/project|span: 2026-08-19T00:00:00Z .. 2026-08-19T00:00:01Z|
  5:user: fleet needle session 45

=== claude-redacted/session-46.txt.zst
    cwd: /demo/project|span: 2026-08-19T00:00:00Z .. 2026-08-19T00:00:01Z|
  5:user: fleet needle session 46

=== claude-redacted/session-47.txt.zst
    cwd: /demo/project|span: 2026-08-19T00:00:00Z .. 2026-08-19T00:00:01Z|
  5:user: fleet needle session 47

=== claude-redacted/session-48.txt.zst
    cwd: /demo/project|span: 2026-08-19T00:00:00Z .. 2026-08-19T00:00:01Z|
  5:user: fleet needle session 48

=== claude-redacted/session-49.txt.zst
    cwd: /demo/project|span: 2026-08-19T00:00:00Z .. 2026-08-19T00:00:01Z|
  5:user: fleet needle session 49

=== claude-redacted/session-50.txt.zst
    cwd: /demo/project|span: 2026-08-19T00:00:00Z .. 2026-08-19T00:00:01Z|
  5:user: fleet needle session 50

=== claude-redacted/session-51.txt.zst
    cwd: /demo/project|span: 2026-08-19T00:00:00Z .. 2026-08-19T00:00:01Z|
  5:user: fleet needle session 51

=== claude-redacted/session-52.txt.zst
    cwd: /demo/project|span: 2026-08-19T00:00:00Z .. 2026-08-19T00:00:01Z|
  5:user: fleet needle session 52

=== claude-redacted/session-53.txt.zst
    cwd: /demo/project|span: 2026-08-19T00:00:00Z .. 2026-08-19T00:00:01Z|
  5:user: fleet needle session 53

=== claude-redacted/session-54.txt.zst
    cwd: /demo/project|span: 2026-08-19T00:00:00Z .. 2026-08-19T00:00:01Z|
  5:user: fleet needle session 54

=== claude-redacted/session-55.txt.zst
    cwd: /demo/project|span: 2026-08-19T00:00:00Z .. 2026-08-19T00:00:01Z|
  5:user: fleet needle session 55

=== claude-redacted/session-56.txt.zst
    cwd: /demo/project|span: 2026-08-19T00:00:00Z .. 2026-08-19T00:00:01Z|
  5:user: fleet needle session 56

=== claude-redacted/session-57.txt.zst
    cwd: /demo/project|span: 2026-08-19T00:00:00Z .. 2026-08-19T00:00:01Z|
  5:user: fleet needle session 57

=== claude-redacted/session-58.txt.zst
    cwd: /demo/project|span: 2026-08-19T00:00:00Z .. 2026-08-19T00:00:01Z|
  5:user: fleet needle session 58

=== claude-redacted/session-59.txt.zst
    cwd: /demo/project|span: 2026-08-19T00:00:00Z .. 2026-08-19T00:00:01Z|
  5:user: fleet needle session 59

=== claude-redacted/session-60.txt.zst
    cwd: /demo/project|span: 2026-08-19T00:00:00Z .. 2026-08-19T00:00:01Z|
  5:user: fleet needle session 60

=== claude-redacted/session-61.txt.zst
    cwd: /demo/project|span: 2026-08-19T00:00:00Z .. 2026-08-19T00:00:01Z|
  5:user: fleet needle session 61

=== claude-redacted/session-62.txt.zst
    cwd: /demo/project|span: 2026-08-19T00:00:00Z .. 2026-08-19T00:00:01Z|
  5:user: fleet needle session 62

=== claude-redacted/session-63.txt.zst
    cwd: /demo/project|span: 2026-08-19T00:00:00Z .. 2026-08-19T00:00:01Z|
  5:user: fleet needle session 63

=== claude-redacted/session-64.txt.zst
    cwd: /demo/project|span: 2026-08-19T00:00:00Z .. 2026-08-19T00:00:01Z|
  5:user: fleet needle session 64

=== claude-redacted/session-65.txt.zst
    cwd: /demo/project|span: 2026-08-19T00:00:00Z .. 2026-08-19T00:00:01Z|
  5:user: fleet needle session 65

=== claude-redacted/session-66.txt.zst
    cwd: /demo/project|span: 2026-08-19T00:00:00Z .. 2026-08-19T00:00:01Z|
  5:user: fleet needle session 66

=== claude-redacted/session-67.txt.zst
    cwd: /demo/project|span: 2026-08-19T00:00:00Z .. 2026-08-19T00:00:01Z|
  5:user: fleet needle session 67

=== claude-redacted/session-68.txt.zst
    cwd: /demo/project|span: 2026-08-19T00:00:00Z .. 2026-08-19T00:00:01Z|
  5:user: fleet needle session 68

=== claude-redacted/session-69.txt.zst
    cwd: /demo/project|span: 2026-08-19T00:00:00Z .. 2026-08-19T00:00:01Z|
  5:user: fleet needle session 69

=== claude-redacted/session-70.txt.zst
    cwd: /demo/project|span: 2026-08-19T00:00:00Z .. 2026-08-19T00:00:01Z|
  5:user: fleet needle session 70

=== claude-redacted/session-71.txt.zst
    cwd: /demo/project|span: 2026-08-19T00:00:00Z .. 2026-08-19T00:00:01Z|
  5:user: fleet needle session 71

=== claude-redacted/session-72.txt.zst
    cwd: /demo/project|span: 2026-08-19T00:00:00Z .. 2026-08-19T00:00:01Z|
  5:user: fleet needle session 72

=== claude-redacted/session-73.txt.zst
    cwd: /demo/project|span: 2026-08-19T00:00:00Z .. 2026-08-19T00:00:01Z|
  5:user: fleet needle session 73

=== claude-redacted/session-74.txt.zst
    cwd: /demo/project|span: 2026-08-19T00:00:00Z .. 2026-08-19T00:00:01Z|
  5:user: fleet needle session 74

=== claude-redacted/session-75.txt.zst
    cwd: /demo/project|span: 2026-08-19T00:00:00Z .. 2026-08-19T00:00:01Z|
  5:user: fleet needle session 75

=== claude-redacted/session-76.txt.zst
    cwd: /demo/project|span: 2026-08-19T00:00:00Z .. 2026-08-19T00:00:01Z|
  5:user: fleet needle session 76

=== claude-redacted/session-77.txt.zst
    cwd: /demo/project|span: 2026-08-19T00:00:00Z .. 2026-08-19T00:00:01Z|
  5:user: fleet needle session 77

=== claude-redacted/session-78.txt.zst
    cwd: /demo/project|span: 2026-08-19T00:00:00Z .. 2026-08-19T00:00:01Z|
  5:user: fleet needle session 78

=== claude-redacted/session-79.txt.zst
    cwd: /demo/project|span: 2026-08-19T00:00:00Z .. 2026-08-19T00:00:01Z|
  5:user: fleet needle session 79

=== claude-redacted/session-80.txt.zst
    cwd: /demo/project|span: 2026-08-19T00:00:00Z .. 2026-08-19T00:00:01Z|
  5:user: fleet needle session 80

=== claude-redacted/session-81.txt.zst
    cwd: /demo/project|span: 2026-08-19T00:00:00Z .. 2026-08-19T00:00:01Z|
  5:user: fleet needle session 81

=== claude-redacted/session-82.txt.zst
    cwd: /demo/project|span: 2026-08-19T00:00:00Z .. 2026-08-19T00:00:01Z|
  5:user: fleet needle session 82

=== claude-redacted/session-83.txt.zst
    cwd: /demo/project|span: 2026-08-19T00:00:00Z .. 2026-08-19T00:00:01Z|
  5:user: fleet needle session 83

=== claude-redacted/session-84.txt.zst
    cwd: /demo/project|span: 2026-08-19T00:00:00Z .. 2026-08-19T00:00:01Z|
  5:user: fleet needle session 84

=== claude-redacted/session-85.txt.zst
    cwd: /demo/project|span: 2026-08-19T00:00:00Z .. 2026-08-19T00:00:01Z|
  5:user: fleet needle session 85

=== claude-redacted/session-86.txt.zst
    cwd: /demo/project|span: 2026-08-19T00:00:00Z .. 2026-08-19T00:00:01Z|
  5:user: fleet needle session 86

=== claude-redacted/session-87.txt.zst
    cwd: /demo/project|span: 2026-08-19T00:00:00Z .. 2026-08-19T00:00:01Z|
  5:user: fleet needle session 87

=== claude-redacted/session-88.txt.zst
    cwd: /demo/project|span: 2026-08-19T00:00:00Z .. 2026-08-19T00:00:01Z|
  5:user: fleet needle session 88

=== claude-redacted/session-89.txt.zst
    cwd: /demo/project|span: 2026-08-19T00:00:00Z .. 2026-08-19T00:00:01Z|
  5:user: fleet needle session 89

=== claude-redacted/session-90.txt.zst
    cwd: /demo/project|span: 2026-08-19T00:00:00Z .. 2026-08-19T00:00:01Z|
  5:user: fleet needle session 90

=== claude-redacted/session-91.txt.zst
    cwd: /demo/project|span: 2026-08-19T00:00:00Z .. 2026-08-19T00:00:01Z|
  5:user: fleet needle session 91

=== claude-redacted/session-92.txt.zst
    cwd: /demo/project|span: 2026-08-19T00:00:00Z .. 2026-08-19T00:00:01Z|
  5:user: fleet needle session 92

=== claude-redacted/session-93.txt.zst
    cwd: /demo/project|span: 2026-08-19T00:00:00Z .. 2026-08-19T00:00:01Z|
  5:user: fleet needle session 93

=== claude-redacted/session-94.txt.zst
    cwd: /demo/project|span: 2026-08-19T00:00:00Z .. 2026-08-19T00:00:01Z|
  5:user: fleet needle session 94

=== claude-redacted/session-95.txt.zst
    cwd: /demo/project|span: 2026-08-19T00:00:00Z .. 2026-08-19T00:00:01Z|
  5:user: fleet needle session 95

=== claude-redacted/session-96.txt.zst
    cwd: /demo/project|span: 2026-08-19T00:00:00Z .. 2026-08-19T00:00:01Z|
  5:user: fleet needle session 96

=== claude-redacted/session-control.txt.zst
    cwd: /demo/control|span: 2026-08-19T00:00:00Z .. 2026-08-19T00:00:01Z|
  5:user: byte � fleet needle retained

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

🔧 **Review** - 1 issue found → auto-fixed (4) ✅
  • 🚨 bin/fm-transcript-search.sh:195 - --files-only ignores every reader failure once grep has seen a match. A truncated .zst that decompresses a matching prefix and then fails therefore prints the path and exits 0, contradicting the required guarantee that a corrupt store file exits non-zero on both search paths. Make this path consume the full stream (for example, avoid grep -q and discard grep's output) so the reader status is meaningful, then reject any non-zero reader status.

🔧 Fix: Fail closed on corrupt matching sessions
1 warning still open:

  • ⚠️ docs/session-archive.md:67 - The documentation still says the scan stops after its first match and that the regression fixture exercises this behavior, but both search paths now intentionally consume every session completely. Update this historical explanation to describe the former failure and the new full-read invariant; otherwise it directly contradicts the implementation and its header documentation.

🔧 Fix: Document full-read corruption detection invariant
1 error still open:

  • 🚨 bin/fm-transcript-search.sh:215 - The final output pipelines return only awk's status (PIPESTATUS[2]), masking failures from find or sort. If either producer fails after emitting partial data, awk can still see a match and return 0, so the search reports a successful but incomplete answer, violating the exit-status contract that scanner failures return 2. Capture all pipeline statuses and return 2 when find or sort is non-zero, while preserving awk's 0/1 match result.

🔧 Fix: Fail closed on output pipeline errors
1 error still open:

  • 🚨 bin/fm-transcript-search.sh:150 - File-set gathering still masks producer failures. find errors are redirected and ignored, while the indexed path likewise continues after a failed awk/reader pipeline; either can leave a partial file list that is then reported as a successful search or genuine no-match. Capture each gathering pipeline’s status and exit 2 before scanning when it is non-zero, preserving the required scanner-failure contract.

🔧 Fix: Fail closed on file gathering errors
✅ Re-checked - no issues remain.

✅ **Test** - passed

✅ No issues found.

  • bash tests/fm-transcript-archive.test.sh
  • Created 97 independently zstd-compressed synthetic sessions, including one control-byte line, then established ground truth using per-session zstd -dcq | grep -aE.
  • Ran FM_TRANSCRIPT_ARCHIVE="$DEMO" ./bin/fm-transcript-search.sh 'fleet needle' --files-only three times and compared outputs with cmp and sha256sum.
  • Ran FM_TRANSCRIPT_ARCHIVE="$DEMO" ./bin/fm-transcript-search.sh 'fleet needle' -C 0 and compared session/header and matching-line counts with decompressed ground truth.
  • Inserted an invalid .txt.zst session and exercised both matching and no-match --files-only searches, confirming scanner-error exit status 2.
⚠️ **Document** - 1 error
  • 🚨 docs/session-archive.md:68 - The documentation says both search paths always consume each session fully, contradicting the authoritative contract that a matching files-only grep may stop early and its reader status must then be ignored. The implementation also unconditionally checks that status, so this cannot be resolved with documentation-only edits.
✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

@Freudator86
Freudator86 force-pushed the fm/fm-transcript-compress-archive branch from 41b5149 to d70fe3f Compare August 19, 2026 22:08
coditan added 18 commits August 19, 2026 23:10
The store was 265 MB of plain text and growing daily. Each session is now
written as one zstd file at level 3, which took this seat's archive from
271.2 MB to 52.1 MB, and the migration lost nothing: of the 2236 sessions
present beforehand, 2144 came back byte-identical and 92 came back longer
because the session had continued since the previous build.

Level 3 rather than a higher one, measured on this seat's real store rather
than on a sample: a full rebuild with verification costs 119.4 s at -3 and
159.6 s at -9, against 89.8 s plain, and -9 spends those 40 extra seconds of
every rebuild to save 3.4 MB more - 1.2 percent of what -3 already saved.

No index was added and none was needed. The scan still reads every byte of
every session, now through ripgrep's -z, so nothing exists that could
silently disagree with the content; a decompression that goes wrong is an
error rather than a wrong answer. Searches over the whole compressed store
measured 0.63 to 0.92 s on real queries, and --since and --cwd narrow the
file set exactly as before.

What compression would have broken, had the documentation been left alone,
is the sentence promising that plain `grep -r` works identically. It does
not: it matches nothing in a compressed session and exits reporting no
matches over a full archive, which is the one answer this archive must never
give - and it half answers, because _index.tsv is the one plain file left, so
a phrase in its first-user-message column still matches. That promise is
withdrawn everywhere it stood, and the raw-tool alternative that does work,
`rg -z`, is named in its place. A rebuild that finds an older archive README
still carrying the promise says so rather than overwriting what someone wrote
there.

The rest is refusal, in the shape this tool already used:

- a missing compressor refuses the build instead of writing a store that is
  half compressed and half plain
- a rebuild compresses the sessions the raw store no longer has, where they
  lie, so nothing is stranded plain
- verification reads the store through the decompressor, and a file it cannot
  read is reported as unread rather than counted as clean
- a missing search tool is reported as a missing tool, never as a search that
  found nothing
- the exit status states whether the search matched, not how one batch of it
  ended
The compressed store was searched through ripgrep, and ripgrep turned out to
be the one part of this tool that answered differently depending on the
machine. Two CI rounds failed on the runner's build - which flags it accepted,
whether it could decompress at all - while every build and verification test
passed there. A search tool whose answer depends on the machine it runs on is
the same silent disagreement this archive exists to refuse, so the dependency
is removed rather than pinned to a version.

The scan now decompresses each session into grep, one file at a time, spread
across the machine's cores. Required tools are zstd, grep and xargs, and
nothing else; zstd is already needed to build the store, and the other two are
on any machine that can run this repository's tests. No override names any of
them, after a partial override twice passed its own prerequisite check before
the scan read nothing.

Measured on this seat's live 2273-session archive rather than on a fixture:

    Grossreinschiff   75 of 75 matching sessions, exit 0
    no-mistakes     2048 matching sessions, exit 0
    whole-store scan            1.44 - 1.48 s

That is slower than the 0.63 - 0.92 s recorded for the ripgrep scan, and
docs/session-archive.md now carries the new number rather than the old one.

The defect that made this necessary is worth stating, because it survived
three fix rounds and every existing test: grep stops reading as soon as it can
answer, the decompressor feeding it then dies of a broken pipe, and treating
that ordinary event as a scanner error returned 0 matching sessions where 75
were expected - over a full archive, with the suite green. A one-session
fixture with one match is too small to make anything stop early, which is why
nothing caught it. The suite now plants that case: a dozen sessions with the
match at the top and a long tail behind it, requiring all twelve back.

Two further tests replace assertions that could not have caught this. The
proof that the search does not need ripgrep now runs it on a PATH where
ripgrep cannot be resolved at all, rather than stubbing a flag on a wrapper;
and the missing-tool refusal removes the decompressor from PATH rather than
pointing an override at a nonexistent path.

Everything else holds: 0 when the search matched, 1 when it genuinely matched
nothing, 2 for a usage error, a missing archive, a missing tool or a scanner
failure; --files-only and the context path agree; the session header still
prints with each hit; a plain .txt session left from an older build is still
searched; --since and --cwd still narrow the file set first; and no inverted
index exists anywhere.
CI caught one real assertion: a search matching every session exited 2. It is
the mirror of the defect this branch already fixed once - the old search
reported success while finding nothing, this one found everything and reported
failure - and both make the exit status lie to every caller that trusts it.

The cause was accepting only signal 141 from the decompressor when its
consumer stopped reading early. This machine's zstd dies on the signal; the CI
runner's reports the closed pipe as an ordinary error, so a full match became a
scanner failure there while passing here.

The rule is now stated rather than pattern-matched: the reader's exit status
means something only when the consumer read to the end. When grep reports a
match it deliberately stopped reading, so its reader's status says nothing
about the file and is not consulted. When grep read the session through and
found nothing, the reader had every chance to finish, so a non-zero status is a
genuine failure to read the store and stays an error. The status is not
masked - masking it would leave a search that can no longer report a real
failure at all - it is read only where it carries information.
tests/fm-transcript-archive.test.sh runs the real script against a reader that
hides the signal the way the runner's does, and that test fails against the
previous rule and passes against this one. A deliberately corrupt store file is
proven to still exit non-zero, on both search paths.

Verifying that fix on the live archive surfaced two further defects in the
parallel scan, both of which made the search disagree with itself rather than
merely with its exit status:

Parallel workers wrote into one shared pipe, and buffered writes interleave at
block boundaries rather than line boundaries. Three runs of one query returned
8121, 8105 and 8104 lines with header lines cut mid-path. Each worker now
writes its own file, which the parent gathers afterwards, so the same query
returns the same answer every time.

grep called five sessions binary and printed no lines for them, because a
reduced session quotes material that carries the occasional control byte.
--files-only reported those sessions and the context path did not. The scan
now reads the store as text.

Measured on the live 2273-session archive against ground truth taken by
decompressing every session separately:

    no-mistakes       2048 sessions, 88294 matching lines   exact
    forgejo            123 sessions,  2306 matching lines   exact
    Grossreinschiff     75 sessions,   232 matching lines   exact
    exit code 127        1 session,       2 matching lines  exact
    three runs of one query                                 identical

Whole-store timings move with the extra correctness: 1.50 - 1.54 s for the file
list and 2.31 - 2.64 s with context, and docs/session-archive.md carries those
rather than the earlier single figure.

The compressor-override mismatch is now recorded as a known condition where a
reader of FM_ZSTD meets it: it governs building and verifying, the search uses
the zstd on PATH, and the two have to be able to read each other.
@Freudator86
Freudator86 force-pushed the fm/fm-transcript-compress-archive branch from d70fe3f to e1c828f Compare August 19, 2026 23:41
@Freudator86 Freudator86 changed the title feat: compress searchable session archive feat: compress the searchable session archive Aug 19, 2026
@Freudator86
Freudator86 merged commit 6737943 into main Aug 20, 2026
11 checks passed
@Freudator86
Freudator86 deleted the fm/fm-transcript-compress-archive branch August 20, 2026 00:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant