Skip to content

Parse the OpenCodex usage log incrementally instead of re-reading it every refresh - #3140

Merged
steipete merged 8 commits into
steipete:mainfrom
olddonkey:perf/opencodex-store-incremental
Aug 25, 2026
Merged

Parse the OpenCodex usage log incrementally instead of re-reading it every refresh#3140
steipete merged 8 commits into
steipete:mainfrom
olddonkey:perf/opencodex-store-incremental

Conversation

@olddonkey

@olddonkey olddonkey commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Builds on #3136 (same OpenCodex path). Until that merges, this PR's diff includes its commits; the new work is the last commit.

Summary

~/.opencodex/usage.jsonl is append-only, but OpenCodexUsageStore keyed its sqlite cache on path|size|mtime — so the identity changed on every append and the cache never hit in the steady state. Every refresh re-read the whole log (42 MB / ~35k entries on my machine), rebuilt it as one 42 MB String, ran a JSONSerialization per line, then did DELETE FROM entries plus ~35k inserts. Even a cache hit re-decoded a stored JSON payload for every row.

#3136 removed the per-entry pricing work on this path; this removes the per-refresh re-read.

Changes

  • Parse cursor. The store persists the log path, the file identity (st_dev/st_ino, not mtime), the byte offset just past the last consumed record, and a SHA256 of the first 64 KiB. On load it either serves the cached rows unchanged (size == parsedOffset), or reads only [parsedOffset, size) and merges those rows with INSERT OR REPLACE — which keeps the existing "last occurrence in file order wins" dedupe. A changed file identity, a shrunken file, or a prefix-digest mismatch falls back to the full re-parse, so rotation, truncation and in-place rewrites are still handled.
  • Trailing records. A complete record with no terminating newline is returned (a full parse of the same bytes returns it too) but is not committed and does not advance the cursor, so a later append that glues bytes onto it cannot desync the cache from a full parse.
  • Typed columns (schema v2). Token fields are nullable INTEGER columns instead of a JSON payload, so reading the cache no longer decodes JSON per row. A schema bump rebuilds cleanly; no code path ever reads a v1 table.
  • Byte-sliced parsing. The parser slices lines out of the file's bytes (mappedIfSafe + memchr) instead of materializing the log as a String. parseLines now uses the same splitter, so there is one line-splitting rule instead of two (\n only; a lone \r or a form feed is no longer a record separator, and a raw U+2028/U+0085 inside a JSON string no longer destroys the record).

Ordering is still produced by Swift comparisons, not SQLite collation, and the returned array is byte-identical to a full re-parse.

Measured

Release CodexBarCLI, isolated cache root, real 42 MB / 35k-entry log, cost --provider codex --format json --days 30. The OpenCodex path is isolated by pointing the run at an empty Codex home and giving both binaries an identical frozen copy of the log; CLI JSON output is identical apart from updatedAt.

Scenario parent (#3136) this PR
cold rebuild, no cache (runs once per upgrade) 3.71 s · 452 MB peak 2.68 s · 290 MB
warm cache hit 1.25 s · 120 MB 0.58 s · 82 MB
after the log grew (the app's per-refresh case) 4.05 s · 480 MB 1.95 s · 118 MB
cache database 18 MB 5.1 MB

The cold figure is higher than an earlier revision of this PR (187 MB) because the full parse no longer memory-maps the log — see the SIGBUS note under "Side-effect review" below. That path runs on a cold cache or a schema rebuild; the per-refresh path reads only the appended tail and is unaffected.

For scale: before #3136 the same cold case was 19–21 s / ~450 MB.

Tests

OpenCodexUsageStoreIncrementalTests (new): incremental result equals a full re-parse for append, partial trailing line, a newline-less record later glued to more bytes, duplicate request IDs, truncation, rotation with a matching 64 KiB prefix and in-place rewrite; tail-only reads verified with a byte/line recorder (zero bytes read when nothing changed); schema-v2 round trip for a missing usage, a single zero-valued field and mismatched totals; a v1 database rebuild; and a busy-writer case proving a failed BEGIN IMMEDIATE leaves both the rows and the cursor untouched. OpenCodexUsageParserTests gained cases for the separators whose handling changed.

Known limitation, deliberately accepted and commented in the code: the prefix digest covers only the first 64 KiB, so an in-place rewrite past 64 KiB that preserves both size and inode is not detected. The log is append-only; rotation changes the inode and truncation trips the size check.

Side-effect review

An independent review focused purely on runtime side effects (hangs, crashes, data loss) found three things worth fixing, all now addressed:

  • Two OpenCodex homes could be mixed in one cache. Every home shares one cache database while the log path is per-home. This PR had replaced the per-read identity check with a cursor check made from a separate connection, so a loader could read its own cursor, have another home's CLI replace the cache, and then use that home's rows as its baseline — the write-lock check only rejected same-file older offsets, so it kept those rows, reset the cursor and appended its own tail. The cursor and the rows are now read in one transaction, the write carries the exact base cursor the parse was derived from, and an incremental write is rejected whenever the durable cursor differs from that base in any field.
  • A crash class. The full parse mapped the whole log and then walked it; a truncation between mapping and touching those pages raises SIGBUS, which Swift cannot catch. The parser now opens one descriptor, snapshots its size with fstat and reads exactly the range it intends to parse, treating a short read as "changed under us". That also bounds a read that would otherwise chase a growing file, and gives the full-reload path the same post-read identity check the incremental path had. The cost is the cold-path memory noted above.
  • Transient I/O failures were published as "no spend". Any stat failure returned an empty array, which the dashboard turns into a confirmed-empty state that removes the OpenCodex source. Only ENOENT/ENOTDIR now mean "absent"; every other errno throws so the source is marked unavailable instead.

Schema v2 also moved to its own database filename, so downgrading to an older build leaves it a usable v1 cache rather than one it can read but never write.

The same review confirmed what it could not break: the retry state machine is bounded at two iterations, there is no cyclic SQLite lock acquisition, the cursor cannot point past EOF, and no file descriptor, statement or connection leaks on any error path.

Disclosed, not fixed here (pre-existing, and outside this PR's files): the forced-refresh reconciliation tail calls the synchronous OpenCodex merge from a @MainActor method (SpendDashboardController.merge(outcome:capture:)SpendDashboardSource+OpenCodex.swift), so that read happens on the main thread. It is reached only from SpendDashboardController.refresh(), i.e. the refresh button in the spend dashboard preferences pane — not the menu bar and not the periodic refresh. This PR makes that path roughly an order of magnitude cheaper (a cache hit or a tail read instead of a full 42 MB re-parse), but the synchronous call remains. Happy to move it off the main actor in a separate PR if you want it.

Process

Implemented by grok-4.6 (xhigh) via implementation-loop; reviewed hunk by hunk plus an independent deep review. That review found a merge blocker — writeEntries ignored the result of BEGIN IMMEDIATE, so under a concurrent writer (the CLI opens the same database as the app) the DELETE and the cursor write would land in autocommit and the rollback would be a no-op, leaving an empty cache with a valid cursor — plus seven smaller findings; all were fixed in one iterate round. Full make check and make test (77/77 sharded groups) pass locally on this head.

🤖 Generated with Claude Code

…ay reloads

The OpenCodex spend source (`~/.opencodex/usage.jsonl` → `OpenCodexUsageFanOut`
→ `OpenCodexUsageAggregator.snapshot`) re-resolved pricing context per entry:
`listPriceUSD` called `CostUsagePricing.codexCostUSD` without a pre-resolved
models.dev catalog, so every call went through `ModelsDevCache.load` →
`FileManager.attributesOfItem` (a stat plus an extended-attribute read), and
without a pre-resolved custom-pricing overlay, so every call also re-read the
overlay file location. Each windowed entry was priced three times (day, session
and hour accumulators), and day keys / hour buckets were recomputed through
Calendar per entry. On a 35k-entry log (all inside the 30-day window) that is
~100k stat+xattr syscalls and ~70k Calendar interval computations per refresh —
in the running app this was the 25–35 s CPU spike on every adaptive refresh
(sampled: `snapshotsBySubscription` → `attributesOfItem` → `getxattr`/`listxattr`).

Changes (snapshot output is byte-identical; verified against a reference
implementation in tests and by diffing CLI JSON on frozen inputs):
- Resolve the models.dev catalog and the custom-pricing overlay once per
  fan-out / snapshot and pass them down; price each windowed entry once and
  reuse the value for the day/session/hour/model merges. A missing catalog is
  substituted with an empty catalog so the degraded path never falls back to
  per-call loads.
- Memoize the local-day key and hour-bucket start per calendar interval using
  the calendar's own `[start, end)` intervals (DST-correct; no 86400/3600
  arithmetic).
- `ModelsDevCache.load` reads (mtime, size) via POSIX `stat` instead of
  `attributesOfItem` (which also reads xattrs); memo/invalidation semantics
  unchanged. This helps every caller repo-wide.

CodexParserHash is regenerated because ModelsDevPricing.swift is in the hashed
set; the previous hash (3c984b655688593f) is added to
compatiblePredecessorParserHashes since parsing and the persisted row shape are
unchanged, so existing cost-usage.sqlite stores are adopted on upgrade instead
of rebuilt.

Measured (release CodexBarCLI, isolated cache root, real 41.7 MB / ~35k-entry
usage.jsonl, same machine, `cost --provider codex --days 30`), OpenCodex path
isolated with identical frozen inputs:
- OpenCodex path alone (empty codex home, identical frozen inputs, CLI JSON
  output identical apart from `updatedAt`):
  cold  14.3 s real / 9.1 s user / 4.9 s sys / 193 G instructions
      →  2.6 s      / 2.4 s      / 0.1 s     /  40 G
  warm (store cache hit)  13.8 s / 8.3 s / 5.3 s / 166 G
      →  1.2 s / 1.1 s / 0.04 s / 13 G
- Full `cost --provider codex` CLI run on live data, steady state after the log
  grew (the app's per-refresh case): ~11 s → ~3.5 s real (7.3–9.2 s → 3.2 s user);
  cold 26 s → 14 s. Peak footprint unchanged (~430 MB cold/grown, ~120–140 MB
  warm).
Peak memory is unchanged — the remaining transient is the append-only log
re-parse (`OpenCodexUsageStore` identity = path|size|mtime), left for a
follow-up.

Tests: equivalence against an independent reference implementation (mixed
providers, estimated/reported/unreported/unsupported, custom overlay, duplicate
request IDs, DST transitions in America/Los_Angeles and America/Santiago),
metadata-read counting proving one catalog load per snapshot (zero with an
injected catalog), day/hour memo boundary cases, and ModelsDevCache memo
invalidation on size/mtime change after the stat switch.

Implemented by grok-4.6 (xhigh) via implementation-loop; reviewed hunk by hunk
plus an independent deep review; one iterate round.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@clawsweeper

clawsweeper Bot commented Aug 22, 2026

Copy link
Copy Markdown

🦞👀
ClawSweeper picked this up.

Pull request received. I will update this pull request when review starts.

olddonkey added a commit to olddonkey/CodexBar that referenced this pull request Aug 22, 2026
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8a5e56efdf

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +179 to +180
private func insertCachedEntries(_ entries: [OpenCodexUsageEntry], cursor: ParseCursor) {
self.writeEntries(entries, cursor: cursor, replaceAll: false)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject stale incremental cursor commits

When the app and CLI refresh this shared cache concurrently, a loader that parsed from an older cursor can acquire the write lock after another loader has already committed a later cursor, yet this insert-only write blindly moves the cursor backward. Usually the next refresh repairs that state, but if the log is then truncated back to the older offset, the cache-hit path sees size == parsedOffset and returns rows retained from beyond the truncation indefinitely. Compare the persisted cursor inside the transaction before inserting, or otherwise prevent an older incremental operation from overwriting a newer cursor.

Useful? React with 👍 / 👎.

@clawsweeper clawsweeper Bot added merge-risk: 🚨 other 🚨 Merging this PR has meaningful risk outside the owned taxonomy. P2 Normal priority bug or improvement with limited blast radius. proof: sufficient Contributor real behavior proof is sufficient. rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. status: ⏳ waiting on author ClawSweeper has contributor-facing work open and is waiting for author action. labels Aug 22, 2026
@clawsweeper

clawsweeper Bot commented Aug 22, 2026

Copy link
Copy Markdown

Codex review: needs changes before merge. Reviewed August 25, 2026, 3:46 AM ET / 07:46 UTC.

ClawSweeper review

What this changes

The PR replaces whole-file OpenCodex usage-log reparsing with cursor-based tail parsing, a versioned SQLite cache, and dedicated parser/cache regression tests.

Merge readiness

⚠️ Needs maintainer review before merge - 4 items remain

Needs changes before merge: full reloads can publish and cache records from a same-inode log that was truncated after parsing, and the branch still edits the release-owned changelog.

Priority: P2
Reviewed head: c94cace09d64343c8df29d32fbb90ce2a1e782c4

Review scores

Measure Result What it means
Overall readiness 🦐 gold shrimp (3/6) Strong real-run evidence supports the performance claim, but the full-reload race is a merge-blocking correctness gap.
Proof confidence 🦞 diamond lobster (5/6) Sufficient (live_output): The PR body provides an after-fix release-CLI comparison on a real frozen 42 MB usage log, with identical JSON output and measured cold, warm, and appended-tail results.
Patch quality 🦐 gold shrimp (3/6) 2 actionable review findings remain.

Verification

Check Result Evidence
Real behavior Verified Sufficient (live_output): The PR body provides an after-fix release-CLI comparison on a real frozen 42 MB usage log, with identical JSON output and measured cold, warm, and appended-tail results.
Evidence reviewed 6 items Current-main behavior: Current main keys the OpenCodex cache by path, size, and modification time, then reparses the full file and replaces cache rows when that identity changes; the requested incremental behavior is not already implemented.
Full-reload revalidation gap: The proposed full-reload path compares only inode/device identity after parsing. A truncation after the completed read preserves that identity while reducing the path size, so the method returns and persists entries that no longer exist in the file.
Snapshot size is already available: The parser records the descriptor snapshot size in its parse result, so the full-reload path can compare the post-read path size with the parsed snapshot before caching or returning rows.
Findings 2 actionable findings [P2] Revalidate full reloads after parsing
[P3] Remove the release-owned changelog entry
Security None None.

Live Verification

Command: swift run CodexBarCLI cost --help

Result: FAIL (failed) — execution before step 1 run: sh -lc pnpm install --ignore-scripts --frozen-lockfile failed: ! Corepack is about to download https://registry.npmjs.org/pnpm/-/pnpm-11.23.0.tgz

sh -lc pnpm install --ignore-scripts --frozen-lockfile failed: ! Corepack is about to download https://registry.npmjs.org/pnpm/-/pnpm-11.23.0.tgz

Assertions:

  • FAIL expect_output: cost

How this fits together

CodexBar parses the OpenCodex usage log into a local SQLite cache, which feeds spend aggregation for the dashboard and CLI. The changed subsystem decides when cached rows remain valid and when the log must be reread.

flowchart LR
A[OpenCodex usage log] --> B[Parser reads snapshot or tail]
C[File identity and cursor] --> D{Cache reuse decision}
B --> D
D --> E[SQLite usage cache]
E --> F[Spend aggregation]
F --> G[Dashboard and CLI]
Loading

Before merge

  • Revalidate full reloads after parsing (P2) - The post-read check only compares fileIdentity. If the log is truncated after parseLog has completed its bounded read, its inode stays the same but the returned rows and stored cursor still describe bytes that no longer exist. Compare the post-read size with parsed.size and retry or discard on mismatch; add the corresponding deterministic full-reload regression case.
  • Remove the release-owned changelog entry (P3) - CHANGELOG.md remains in the current diff. Repository policy reserves this file for release work, so keep the release-note wording in the PR context rather than landing it from this branch.
  • Resolve merge risk (P1) - A same-inode truncation after a full parse but before the post-read stat can show obsolete spend data for that refresh and persist a cursor beyond EOF until the next reload.
  • Resolve merge risk (P1) - The new cache filename and parser behavior are compatibility-sensitive, so the focused regression suite should cover the repaired full-reload path before merge.

Findings

  • [P2] Revalidate full reloads after parsing — Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageStore.swift:165
  • [P3] Remove the release-owned changelog entry — CHANGELOG.md:10-11
Agent review details

Security

None.

Review metrics

Metric Value Why it matters
Production and test delta production +784/-116, tests +868/-4, docs +3 Most added code is paired with dedicated regression coverage for a persistent cache and parser rewrite.

Merge-risk options

Maintainer options:

  1. Close the full-reload race before merge (recommended)
    Compare the post-read log size with the parser snapshot, retry or discard on mismatch, add regression coverage, and remove the release-owned changelog edit.
Copy recommended automerge instruction
@clawsweeper automerge

Special instructions:
Revalidate full reloads against the post-read snapshot size, add a deterministic truncation-after-read regression test, remove the CHANGELOG.md edit, and run focused cache/parser tests plus make check.

Technical review

Best possible solution:

Revalidate the full parse against the post-read log size before publishing or caching it, add a deterministic truncation-after-read regression test, and leave release-note edits to the release owner.

Do we have a high-confidence way to reproduce the issue?

Yes, from source: truncate the same file after full parsing completes but before its post-read stat; the current full-reload check accepts the unchanged inode without checking the captured size.

Is this the best way to solve the issue?

No: the incremental path revalidates after reading, but full reload must apply the same size-aware validation before its rows and cursor are persisted.

Full review comments:

  • [P2] Revalidate full reloads after parsing — Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageStore.swift:165
    The post-read check only compares fileIdentity. If the log is truncated after parseLog has completed its bounded read, its inode stays the same but the returned rows and stored cursor still describe bytes that no longer exist. Compare the post-read size with parsed.size and retry or discard on mismatch; add the corresponding deterministic full-reload regression case.
    Confidence: 0.96
  • [P3] Remove the release-owned changelog entry — CHANGELOG.md:10-11
    CHANGELOG.md remains in the current diff. Repository policy reserves this file for release work, so keep the release-note wording in the PR context rather than landing it from this branch.
    Confidence: 0.99

Overall correctness: patch is incorrect
Overall confidence: 0.96

AGENTS.md: found and applied where relevant.

Codex review notes: model internal, reasoning high; reviewed against 31cb6dfb9b93.

Labels

Label changes:

  • add rating: 🦐 gold shrimp: Overall readiness is 🦐 gold shrimp; proof is 🦞 diamond lobster and patch quality is 🦐 gold shrimp.
  • add status: ⏳ waiting on author: ClawSweeper has contributor-facing work open and is waiting for author action. Sufficient (live_output): The PR body provides an after-fix release-CLI comparison on a real frozen 42 MB usage log, with identical JSON output and measured cold, warm, and appended-tail results.
  • remove status: 👀 ready for maintainer look: Current PR status label is status: ⏳ waiting on author.
  • remove rating: 🐚 platinum hermit: Current PR rating is rating: 🦐 gold shrimp, so this older rating label is no longer current.

Label justifications:

  • P2: The proposed cache path can transiently publish stale OpenCodex spend data under a filesystem race.
  • merge-risk: 🚨 compatibility: The PR changes persisted cache format, invalidation rules, and parsing behavior for existing OpenCodex logs.
  • rating: 🦐 gold shrimp: Overall readiness is 🦐 gold shrimp; proof is 🦞 diamond lobster and patch quality is 🦐 gold shrimp.
  • status: ⏳ waiting on author: ClawSweeper has contributor-facing work open and is waiting for author action. Sufficient (live_output): The PR body provides an after-fix release-CLI comparison on a real frozen 42 MB usage log, with identical JSON output and measured cold, warm, and appended-tail results.
  • proof: sufficient: Contributor real behavior proof is sufficient. The PR body provides an after-fix release-CLI comparison on a real frozen 42 MB usage log, with identical JSON output and measured cold, warm, and appended-tail results.

Evidence

Acceptance criteria:

  • [P1] swift test --filter OpenCodexUsageStoreIncrementalTests.
  • [P1] swift test --filter OpenCodexUsageParserTests.
  • [P1] make check.
  • [P1] make test.

What I checked:

Likely related people:

  • steipete: The rebased head and blame for the full-reload path are attributed to Peter Steinberger, and the owner gave the current rebase/hash guidance. (role: recent integrator; confidence: high; commits: c94cace09d64; files: Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageStore.swift, CHANGELOG.md)
  • olddonkey: Authored this incremental-cache work and the merged predecessor that optimized the adjacent OpenCodex pricing pipeline. (role: OpenCodex performance contributor; confidence: high; commits: dfbed3020e2a, e331eab0d714; files: Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageParser.swift, Tests/CodexBarTests/OpenCodexUsageStoreIncrementalTests.swift)

Rank-up moves

Optional improvements that raise the rating; they are not merge blockers.

  • Add a deterministic full-reload truncation-after-read test and repair the post-read validation.
  • Remove the release-owned CHANGELOG.md entry.

Rating scale

Score Internal tier Crab rank Meaning
6/6 S 🦀 challenger crab Exceptional readiness
5/6 A 🦞 diamond lobster Very strong readiness
4/6 B 🐚 platinum hermit Good normal PR; ordinary maintainer review
3/6 C 🦐 gold shrimp Useful, but confidence is limited
2/6 D 🦪 silver shellfish Proof or implementation needs work
1/6 F 🧂 unranked krab Not merge-ready
N/A NA 🌊 off-meta tidepool Rating does not apply

Overall follows the weaker of proof and patch quality.
Shiny media proof means a screenshot, video, or linked artifact directly shows the changed behavior. Runtime, network, CSP, and security claims still need visible diagnostics.

Workflow

  • ClawSweeper keeps one durable marker-backed review comment per issue or PR.
  • Re-runs edit this comment so the latest verdict, findings, and automation markers stay together instead of adding duplicate bot comments.
  • A fresh review can be triggered by eligible @clawsweeper re-review comments, exact-item GitHub events, scheduled/background review runs, or manual workflow dispatch.
  • PR/issue authors and users with repository write access can comment @clawsweeper re-review or @clawsweeper re-run on an open PR or issue to request a fresh review only.
  • Maintainers can also comment @clawsweeper review to request a fresh review only.
  • Fresh-review commands do not start repair, autofix, rebase, CI repair, or automerge.
  • Maintainer-only repair and merge flows require explicit commands such as @clawsweeper autofix, @clawsweeper automerge, @clawsweeper fix ci, or @clawsweeper address review.
  • Maintainers can comment @clawsweeper explain to ask for more context, or @clawsweeper stop to stop active automation.

History

Review history (4 earlier review cycles)
  • reviewed 2026-08-22T09:54:37.033Z sha f456d6c :: needs changes before merge. :: [P2] Reject stale cursor commits after acquiring the write lock | [P3] Remove release-owned changelog edits
  • reviewed 2026-08-22T19:11:28.394Z sha 7b57ab1 :: needs changes before merge. :: [P2] Revalidate the file after tail parsing
  • reviewed 2026-08-22T21:39:34.078Z sha 79baa76 :: needs maintainer review before merge. :: none
  • reviewed 2026-08-22T23:30:21.103Z sha e331eab :: needs maintainer review before merge. :: none

olddonkey and others added 3 commits August 22, 2026 10:52
Explain why the models.dev catalog and the custom-pricing overlay are resolved
once per snapshot / fan-out, why a missing catalog is substituted with an empty
one (so the degraded path never falls back to per-call ModelsDevCache.load),
the two-level overlay precedence in listPriceUSD, why the day-key memo cannot
disagree with CostUsageLocalDay.key, and that the metadata-read recorder is
task-local test-only instrumentation. Comments only; CodexParserHash is
regenerated because ModelsDevPricing.swift is in the hashed set (no shipped
hash is affected; the predecessor list is unchanged).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…every refresh

`~/.opencodex/usage.jsonl` is append-only, but `OpenCodexUsageStore` keyed its
sqlite cache on `path|size|mtime`, so the identity changed on every append and
the cache never hit in the steady state. Each refresh therefore re-read the
whole log (42 MB / ~35k entries on the reference machine), rebuilt a 42 MB
`String`, ran one `JSONSerialization` per line, and then did
`DELETE FROM entries` plus 35k inserts. Even a cache hit re-decoded a stored
JSON payload for every row.

The store now keeps a parse cursor in its metadata — log path, file identity
(`st_dev`/`st_ino`, not mtime), the byte offset just past the last consumed
record, and a SHA256 of the first 64 KiB — and on load either serves the cached
rows unchanged, or reads only `[parsedOffset, size)` and merges those rows with
`INSERT OR REPLACE`. A changed file identity, a shrunken file, or a prefix-digest
mismatch falls back to the full re-parse, so rotation, truncation and in-place
rewrites are still handled. A trailing record with no newline is returned but
not committed and does not advance the cursor, so a later append that glues
bytes onto it cannot desync the cache from a full parse.

Two supporting changes: token fields become typed sqlite columns (schema v2), so
reading the cache no longer decodes JSON per row, and the parser slices lines out
of the file's bytes (`mappedIfSafe` plus `memchr`) instead of materializing the
whole log as a `String`. `parseLines` now uses the same splitter, so there is one
line-splitting rule instead of two.

Measured (release CodexBarCLI, isolated cache root, real 42 MB / 35k-entry log,
`cost --provider codex --format json --days 30`, OpenCodex path isolated with
identical frozen inputs; CLI JSON output identical apart from `updatedAt`):

- cold, no cache:   2.85 s / 422 MB peak -> 2.74 s / 187 MB
- warm cache hit:   1.06 s / 112 MB      -> 0.52 s /  75 MB
- after the log grew (the app's per-refresh case, full run on live data):
                    4.58 s / 422 MB      -> 2.14 s / 110 MB
- cache database:   18 MB -> 5.1 MB

Baselines are the parent commit (Codex 0.54.2 branch state); against upstream
main before the OpenCodex work the same cold case was 19-21 s / ~450 MB.

Tests: incremental result equals a full re-parse (append, partial trailing line,
newline-less record later glued to more bytes, duplicate request IDs, truncation,
rotation with a matching 64 KiB prefix, in-place rewrite), tail-only reads with a
byte/line recorder (zero bytes when nothing changed), schema-v2 round trip for a
missing `usage`, a single zero-valued field and mismatched totals, a v1 database
rebuild, and a busy-writer case proving a failed `BEGIN IMMEDIATE` leaves both the
rows and the cursor untouched.

Implemented by grok-4.6 (xhigh) via implementation-loop; reviewed hunk by hunk
plus an independent deep review whose blocker (an ignored `BEGIN IMMEDIATE`
result that could empty the cache under a concurrent writer) and seven further
findings were fixed in one iterate round.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The app and the CLI open the same OpenCodex usage cache, so two loaders can
race: A reads cursor C1 and parses from it, B reads C1, parses further, takes
the write lock and commits C2 > C1, and A then takes the lock and commits its
snapshot — moving the durable cursor backwards and re-inserting rows B already
stored. The same bytes are then parsed again on the next refresh, and after a
later truncation the `size == parsedOffset` cache-hit path can serve rows for
content no longer in the log.

`writeEntries` now re-reads the durable cursor after `BEGIN IMMEDIATE` succeeds
and before any mutation. For an incremental append (`replaceAll == false`), a
durable cursor with the same path and file identity whose `parsedOffset` is at
least the proposed one means this work is stale: the transaction rolls back
without touching the cursor or the rows. Full reloads re-derived the whole file
and still replace even a newer cursor, which is what truncation, rotation and a
schema rebuild need.

`loadEntries` re-runs its cursor path once against the freshly committed durable
state and falls back to a full reload if that write is stale too, so a caller
never sees a partial result and never loops.

Test: `stale incremental write does not regress a newer durable cursor` is
ordered rather than racy — load once to capture a cursor, append and load again
to commit a newer one, then drive the write path with the first snapshot through
a test-only seam and assert the newer cursor survives, no duplicate rows exist,
and a subsequent load still equals a full re-parse.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@olddonkey
olddonkey force-pushed the perf/opencodex-store-incremental branch from f456d6c to 7b57ab1 Compare August 22, 2026 19:06
@olddonkey

Copy link
Copy Markdown
Contributor Author

Both items are addressed on the pushed head (7b57ab10b).

P2 — stale cursor commits after acquiring the write lock

The race is real and I reproduced the mechanism as described: the write decided what to persist from a cursor snapshot taken before the lock, so a delayed loader could commit an older parsedOffset over a newer one, re-insert rows the other loader had already stored, and — after a later truncation — leave the size == parsedOffset cache-hit path serving rows for content no longer in the log.

writeEntries now re-reads the durable cursor after BEGIN IMMEDIATE succeeds and before any mutation, on the same write connection:

  • For an incremental append (replaceAll == false), a durable cursor with the same path and fileIdentity whose parsedOffset is >= the proposed one means this work is stale → ROLLBACK, no cursor write, no row inserts.
  • A full reload (replaceAll == true) still replaces even a newer cursor, because it re-derived the whole file — that is what truncation, rotation and a schema rebuild need. This is stated in a comment at the check.
  • A missing cursor, a different file identity, or an older durable offset writes exactly as before.

loadEntries re-runs its cursor path once against the freshly committed durable state and falls back to a full reload if that write is stale too, so a caller never returns a partial result and never loops.

Regression test stale incremental write does not regress a newer durable cursor is ordered rather than racy — no threads, no sleeps: load once to capture a cursor, append and load again to commit a newer one, then drive the write path with the first snapshot through a test-only seam, and assert the newer cursor survives, SELECT COUNT(*) matches the deduped parse, and a subsequent loadEntries still equals a full re-parse. The existing busy-writer test (failed begin immediate leaves rows and cursor untouched) is unchanged.

P3 — release-owned changelog

Removed. CHANGELOG.md on this branch is now byte-identical to main. For whoever writes the release notes:

  • OpenCodex: parse usage.jsonl incrementally. The cache keyed on path|size|mtime, so every refresh re-read, re-parsed and re-inserted the whole append-only log (~42 MB / 35k entries on a heavy machine); the store now keeps a parse cursor (file identity + byte offset + prefix digest) and reads only the appended tail, and token fields are typed columns instead of a JSON payload decoded per row — a refresh after the log grew drops from ~4.6 s / 422 MB to ~2.1 s / 110 MB with identical output, and the cache database shrinks from 18 MB to 5 MB (Parse the OpenCodex usage log incrementally instead of re-reading it every refresh #3140).

Note that the stacked predecessor #3136 still carries its own changelog commit; say the word and I will drop it there too rather than force-push a PR that is otherwise sitting quietly.

Checks

make check and the full make test (77/77 sharded groups, zero failures, zero timeouts) pass on 7b57ab10b. The CLI A/B in the description was re-run after the fix and is unchanged: OpenCodex path cold 2.74 s / 187 MB, warm 0.52 s / 75 MB, with byte-identical JSON output against both main and the parent branch.

@clawsweeper clawsweeper Bot added merge-risk: 🚨 compatibility 🚨 Merging this PR could break existing users, config, migrations, defaults, or upgrades. and removed merge-risk: 🚨 other 🚨 Merging this PR has meaningful risk outside the owned taxonomy. labels Aug 22, 2026
…al result

`loadEntries` validates the file identity and prefix digest before parsing, but
the tail read happens after that check. A rotation or replacement in that window
made the parse start at the old `parsedOffset` inside the new file, and those
bytes were then merged with cached rows belonging to the old file and persisted
under the old identity — a refresh could publish a mixed old/new snapshot and
store a cursor for a file that no longer exists at that path.

`incrementalReload` now re-stats the log after the parse and before anything is
inserted or returned, and requires the same path and `st_dev`/`st_ino` plus a
cursor that still validates against the post-read stat (size and prefix digest
included). On mismatch it discards the parsed tail, writes nothing, and runs a
single `fullReload` against the file as it now exists — the same bounded
fallback the stale-cursor path uses. A replacement that preserves path, device,
inode, size and the first 64 KiB is still undetectable, which is the digest
threat model already documented at `canReuseCursor`.

Tests drive the window deterministically through a task-local post-parse hook
(unset and free in production, with a case asserting it is unset by default):
the log is replaced with a different inode while the parse result is in hand,
once with a replacement longer than the old offset and once shorter. Both assert
the returned entries equal a full re-parse of the replacement, that no request ID
from the old file survives, that the persisted cursor describes the replacement,
that no duplicate rows exist, and that the next load is a cache hit reading zero
bytes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@olddonkey

Copy link
Copy Markdown
Contributor Author

Addressed on 79baa7694.

P2 — revalidate the file after tail parsing

Correct, and thank you for pushing on it: the identity check ran before the tail read, so a rotation in that window made the parse start at the old parsedOffset inside the replacement file, and those bytes were then merged with cached rows from the old file and persisted under the old identity.

incrementalReload now re-stats the log immediately after parseLog returns and before anything is inserted or returned. It requires the same path and st_dev/st_ino, and re-runs the cursor validation against the post-read stat, so the size check and the prefix digest are re-evaluated too. On mismatch it discards the parsed tail, writes neither rows nor cursor, and runs a single fullReload against the file as it now exists — the same bounded fallback the stale-cursor path already uses, no new loop. If the log disappeared entirely, the load returns empty, matching loadEntries's existing behaviour for a missing file.

Still undetectable, and commented at the check: a replacement that preserves path, device, inode, size and the first 64 KiB. That is the pre-existing digest threat model documented at canReuseCursor.

Deterministic coverage — no sleeps, no real threads. A task-local post-parse hook (unset and free in production; one test asserts it is unset by default) lets a test replace the log while the parse result is in hand:

  • replacement longer than the old parsedOffset, new inode
  • replacement shorter than the old parsedOffset, new inode

Both assert that the returned entries equal a full re-parse of the replacement, that no request ID from the old file survives, that the persisted cursor describes the replacement, that no duplicate rows exist, and that the next load is a cache hit reading zero bytes.

Merge order with #3136

Noted — this branch is stacked on #3136 deliberately (both touch the same OpenCodex path and #3140 builds on the price-once work). Whenever #3136 lands I will rebase this onto main; if you would rather review them as one change or in the other order, say so and I will restructure.

Checks

make check and the full make test (77/77 sharded groups, zero failures, zero timeouts) pass on 79baa7694. The CLI A/B in the description was re-measured after both concurrency fixes and is unchanged — OpenCodex path cold 2.74 s / 187 MB, warm 0.52 s / 75 MB, byte-identical JSON output against main and against the parent branch.

@clawsweeper clawsweeper Bot added rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR. and removed status: ⏳ waiting on author ClawSweeper has contributor-facing work open and is waiting for author action. rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. labels Aug 22, 2026
…nd I/O errors

An independent side-effect review found three ways this cache could misbehave at
runtime. None are theoretical; two are regressions this branch introduced.

Mixed ledgers in one cache. Every OpenCodex home shares one cache database while
the log path differs per home, and this branch had replaced the per-read identity
check with a cursor check made from a separate connection. A loader could read
its own cursor, have another home's CLI replace the cache, then read that home's
rows as its baseline — and the write-lock check only rejected same-file older
offsets, so it kept those rows, reset the cursor and appended its own tail. The
cursor and the cached rows are now read in one transaction, the write carries the
exact base cursor the parse was derived from, and an incremental write is rejected
whenever the durable cursor differs from that base in any field.

A crash class. The full parse mapped the whole log and then walked it; a
truncation between mapping and touching those pages raises SIGBUS, which Swift
cannot catch — the app dies. The parser now opens one descriptor, snapshots its
size with fstat, and reads exactly the range it intends to parse, treating a short
read as "changed under us". That also bounds a read that would otherwise chase a
growing file, and gives the full-reload path the same post-read identity check the
incremental path already had. The cost is that a full parse now holds the file's
bytes: on a 42 MB log the cold rebuild peaks at ~290 MB instead of ~187 MB, still
well under the ~485 MB of the current implementation, and it only runs on a cold
cache or a schema rebuild — the per-refresh path reads only the appended tail and
is unchanged at ~118 MB.

Transient failures shown as "no spend". Any stat failure returned an empty array,
and the dashboard turns that into a confirmed-empty state that removes the
OpenCodex source. Only ENOENT/ENOTDIR now mean "absent"; every other errno throws
so the source is marked unavailable instead.

Schema v2 also moves to its own database filename, so downgrading to an older
build leaves it a usable v1 cache rather than one it can read but never write.

Tests: an incremental load whose cache was replaced by another home keeps only its
own entries; a circular symlink and an unreadable directory throw instead of
returning empty; a missing log still returns empty without throwing; the legacy v1
database survives a v2 rebuild.

The cached-state accessor is `parseCursor`, not `cursor`: the provider-architecture
gate reads a bare `.cursor` as a reference to the Cursor provider, and this one is a
parse position, matching the persisted metadata key.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@olddonkey

Copy link
Copy Markdown
Contributor Author

Pushed e331eab0d. In addition to the review items above, I had this branch audited by a second reviewer looking only for runtime side effects — hangs, crashes, data loss — because a persisted cache shared by the app and the CLI is exactly the kind of thing that goes wrong at 2am rather than in a test. It found three real problems, two of them regressions this branch introduced. All are fixed.

1. Two OpenCodex homes could be mixed in one cache. OpenCodexUsageLog.cacheRoot() has no home component while the log path does, so every OPENCODEX_HOME shares one database. Before this branch that was harmless: readCachedEntries(identity:) required the stored identity to match, so another home's cache simply missed. This branch replaced that with a cursor check made from a separate connection — so a loader could read its own cursor, have the CLI replace the cache for another home in between, and then use that home's rows as its baseline. The write-lock check only rejected a same-file older offset, so the foreign cursor passed, the foreign rows were not deleted, the cursor was reset and this loader's tail was appended on top.

Now: the cursor and the cached rows are read in one transaction; the write carries the exact base cursor the parse was derived from; and an incremental write is rejected whenever the durable cursor differs from that base in any field, not just when it is a newer same-file offset. Regression test: seed the cache for log B, drive an incremental load for log A, assert both the returned entries and the sqlite rows contain only A's request IDs.

2. A crash class from .mappedIfSafe. The full parse mapped the whole log and then walked it with memchr. A truncation between the mapping and touching those pages raises SIGBUS, which Swift cannot catch — the app dies. The reviewer checked the installed OpenCodex writer (2.31.0) and confirmed it only appends, so exposure today is low, but manual cleanup or a future writer can truncate.

The parser now opens one FileHandle, snapshots its size with fstat, and reads exactly the range it intends to parse, treating a short read as "changed under us". Three things fall out of that: no mapping, so no SIGBUS; the read is bounded, so a continuously growing file cannot be chased indefinitely (readToEnd() had no bound); and fullReload gets the same post-read identity check the incremental path already had.

The cost is memory on the cold path: the full parse now holds the file's bytes, so a rebuild on a 42 MB log peaks at ~290 MB instead of ~187 MB. That is still well under the ~452 MB of the parent branch, and it runs only on a cold cache or a schema rebuild — the per-refresh path reads only the appended tail and is unchanged (~118 MB). I took the trade deliberately; say the word if you would rather have the mapping back with a documented caveat, or a chunked reader to claw the memory back.

3. Transient I/O failures were published as "no spend". Any stat failure returned an empty array, and SpendDashboardSource+OpenCodex.swift turns an empty array into .confirmedEmpty, which removes the OpenCodex source from the dashboard. Before this branch the metadata read was a throwing attributesOfItem, so a failure surfaced as unavailable. Only ENOENT/ENOTDIR now mean "absent"; every other errno throws. Tests cover a circular symlink and an unreadable directory (skipped when running as root), and a genuinely missing log still returns empty without throwing.

Also: schema v2 moved to opencodex-usage-v2.sqlite, so a downgrade to an older build leaves it a usable v1 cache instead of one it can read but never write.

What the review could not break, for the record: the retry state machine is bounded at two iterations; there is no cyclic SQLite lock acquisition between the app and the CLI; the cursor cannot point past EOF; and no descriptor, statement or connection leaks on any error path.

One thing I am disclosing rather than fixing here. The forced-refresh reconciliation tail calls the synchronous OpenCodex merge from a @MainActor method (SpendDashboardController.merge(outcome:capture:) at the .reconciling branch → SpendDashboardSource.mergingOpenCodexInputsWithObservationstore.loadEntries), so that read runs on the main thread. It predates this PR and is reached only from SpendDashboardController.refresh() — the refresh button in the spend dashboard preferences pane, not the menu bar and not the periodic refresh. This PR makes it roughly an order of magnitude cheaper (a cache hit or a tail read instead of a full 42 MB re-parse), but the synchronous call is still there. It is your controller and you refactored it recently in #3105, so I would rather not fold a concurrency change into this PR — happy to do it as a separate one if you want it.

make check and the full make test (77/77 sharded groups, zero failures) pass on e331eab0d, and the PR description's measurement table has been updated to the re-measured numbers.

@steipete

Copy link
Copy Markdown
Owner

Heads-up: #3136 just landed on main, touching the same aggregator/fan-out files plus a regenerated parser hash. This one needs a rebase onto latest main; after resolving, re-run ./Scripts/regenerate-codex-parser-hash.sh and update the compatiblePredecessorParserHashes exact-list assertion in CostUsageStoreTests if the hash changes. Ready to merge once green.

steipete and others added 2 commits August 25, 2026 00:38
Co-authored-by: olddonkey <olddonkeyblog@gmail.com>
Co-authored-by: olddonkey <olddonkeyblog@gmail.com>
@clawsweeper clawsweeper Bot added the rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. label Aug 25, 2026
@clawsweeper clawsweeper Bot added status: ⏳ waiting on author ClawSweeper has contributor-facing work open and is waiting for author action. and removed status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR. rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. labels Aug 25, 2026
@steipete
steipete merged commit 17ade2b into steipete:main Aug 25, 2026
9 checks passed
@steipete

Copy link
Copy Markdown
Owner

Maintainer verification: the combined focused OpenCodex parser, incremental store, fan-out, pricing, native cost-store, and provider-architecture suites passed 199 tests; the prior parallel SQLite fixture race reproduced before the database-scoped checkpoint fix and passed afterward; make check passed with zero formatting/lint violations; parser hash remained cfd84d13ad7d4cfa; exact-head GitHub CI including both macOS shards and Linux builds passed https://github.com/steipete/CodexBar/actions/runs/32822882427. The initial full local suite reached group 58/78 before the architecture scanner exceeded its 180-second timeout under host load above 90; exact-head CI completed the full platform coverage successfully. Landed as 17ade2b.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

merge-risk: 🚨 compatibility 🚨 Merging this PR could break existing users, config, migrations, defaults, or upgrades. P2 Normal priority bug or improvement with limited blast radius. proof: sufficient Contributor real behavior proof is sufficient. rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. status: ⏳ waiting on author ClawSweeper has contributor-facing work open and is waiting for author action.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants