Skip to content

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

Open
olddonkey wants to merge 6 commits into
steipete:mainfrom
olddonkey:perf/opencodex-store-incremental
Open

Parse the OpenCodex usage log incrementally instead of re-reading it every refresh#3140
olddonkey wants to merge 6 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 maintainer review before merge. Reviewed August 22, 2026, 7:30 PM ET / 23:30 UTC.

ClawSweeper review

What this changes

The branch reduces OpenCodex refresh work by caching a file cursor and parsing only appended JSONL records, while preserving safe fallback behavior for replacement, truncation, and cache-schema changes.

Merge readiness

⚠️ Ready for maintainer review - 4 items remain

Keep open: the current main branch still fully reparses the OpenCodex log, while this branch adds the missing incremental path. No new blocking correctness or security finding was identified; maintainer merge-order approval is needed because the branch is intentionally stacked on an open predecessor.

Priority: P2
Reviewed head: e331eab0d714910ff16b10e64f588a7797dac394
Owner decision: Required. See Decision needed.

Review scores

Measure Result What it means
Overall readiness 🐚 platinum hermit (4/6) The branch has strong targeted coverage and credible real CLI evidence, with merge-order rather than patch correctness remaining to resolve.
Proof confidence 🐚 platinum hermit (4/6) Sufficient (terminal): The PR reports an after-fix release-CLI comparison using a real 42 MB OpenCodex log, with JSON output matching aside from the update timestamp.
Patch quality 🐚 platinum hermit (4/6) No actionable review findings were identified.

Verification

Check Result Evidence
Real behavior Verified Sufficient (terminal): The PR reports an after-fix release-CLI comparison using a real 42 MB OpenCodex log, with JSON output matching aside from the update timestamp.
Evidence reviewed 6 items Current main still reparses the complete log: Current main keys the cache by path, size, and modification time, then reparses and replaces all cached rows whenever that identity changes; an append therefore cannot be a steady-state cache hit.
Incremental cache implementation: The PR validates a persisted cursor, returns cached rows when unchanged, and otherwise parses from the prior byte offset with bounded stale-write retry behavior.
Rotation and short-read safeguards: The parser snapshots a descriptor size, reads exactly that byte range, and reports a changed-under-read error on a short read; the store validates the post-read file state before caching a tail.
Findings None None.
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.22.0.tgz

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

Assertions:

  • FAIL expect_output: Print local cost usage as text or JSON

How this fits together

CodexBar reads OpenCodex usage JSONL into a local SQLite cache, then aggregates those entries for the spend dashboard and CLI. This change sits between the log reader and the cached usage rows that downstream cost views consume.

flowchart LR
  A[OpenCodex usage log] --> B[Byte-based JSONL parser]
  B --> C[Cursor and file validation]
  C --> D[SQLite usage cache]
  D --> E[Spend aggregation]
  E --> F[Dashboard and CLI output]
Loading

Decision needed

Question Recommendation
Should this stacked branch be rebased after #3136 lands, or intentionally be reviewed and merged as the combined pricing-and-cache change? Rebase as the successor: Land the pricing predecessor, rebase this PR onto current main, and review the remaining incremental-cache delta.

Why: Both choices are technically viable, but only maintainers can choose the preferred review and release unit for the two dependent changes.

Before merge

  • Resolve merge risk (P1) - The branch is stacked on open predecessor Price OpenCodex usage once per entry and stop per-entry catalog/overlay reloads #3136; landing order determines whether this is reviewed as a combined change or rebased as a focused successor.
  • Resolve merge risk (P1) - The versioned SQLite filename deliberately rebuilds the OpenCodex cache on upgrade; this preserves downgrade compatibility but should remain an explicit upgrade choice.
  • Complete next step (P2) - A maintainer must select the stacked-PR landing order; no narrow automated repair is indicated.
Agent review details

Security

None.

Review metrics

Metric Value Why it matters
Patch size 13 files, +2,559 / -141 lines The submitted diff includes both this incremental-cache work and its stacked pricing predecessor.
Production versus tests production +960 / -137; tests +1,599 / -5 The larger production rewrite is accompanied by substantial targeted regression coverage.

Merge-risk options

Maintainer options:

  1. Rebase after the predecessor (recommended)
    After Price OpenCodex usage once per entry and stop per-entry catalog/overlay reloads #3136 lands, rebase this branch and confirm the versioned-cache upgrade still rebuilds only derived data.
  2. Accept the combined release unit
    Merge the stacked branch only if maintainers intentionally want the pricing and cache changes released together.

Technical review

Best possible solution:

Land the pricing predecessor first, then rebase this branch onto current main and retain the versioned-cache migration with its focused incremental-cache regression coverage.

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

Yes: the supplied release-CLI scenario with a frozen OpenCodex log, plus focused append/rotation/concurrency tests, provides a high-confidence path to observe the changed cache behavior.

Is this the best way to solve the issue?

Yes, conditional on resolving merge order: a cursor plus byte-offset tail parsing addresses the current full-reparse behavior while preserving deliberate rebuild fallbacks.

AGENTS.md: found and applied where relevant.

Codex review notes: model internal, reasoning high; reviewed against 27c7f334e3c4.

Labels

Label justifications:

  • P2: This is a bounded usage-cache performance and correctness improvement with limited user-facing blast radius.
  • merge-risk: 🚨 compatibility: The PR changes the persisted cache schema and filename, so upgrade and downgrade cache behavior must remain intentional.
  • rating: 🐚 platinum hermit: Overall readiness is 🐚 platinum hermit; proof is 🐚 platinum hermit and patch quality is 🐚 platinum hermit.
  • status: 👀 ready for maintainer look: ClawSweeper has no concrete contributor-facing blocker left for this PR. Sufficient (terminal): The PR reports an after-fix release-CLI comparison using a real 42 MB OpenCodex log, with JSON output matching aside from the update timestamp.
  • proof: sufficient: Contributor real behavior proof is sufficient. The PR reports an after-fix release-CLI comparison using a real 42 MB OpenCodex log, with JSON output matching aside from the update timestamp.

Evidence

What I checked:

Likely related people:

  • Yuxin-Qiao: Introduced the read-only OpenCodex usage parser and independent cache that this PR refactors. (role: original feature author; confidence: high; commits: 197bd61ddb40; files: Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageStore.swift, Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageParser.swift)
  • steipete: Merged the spend-reporting series that owns the dashboard path consuming OpenCodex cached entries. (role: recent area integrator; confidence: high; commits: bbb5cd73af04; files: Sources/CodexBar/SpendDashboardSource+OpenCodex.swift, Sources/CodexBarCore/Vendored/OpenCodexUsage/OpenCodexUsageStore.swift)

Rank-up moves

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

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 (3 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

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.

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: 🐚 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.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant