-
Notifications
You must be signed in to change notification settings - Fork 32
perf(history): limit parsing to recent entries #247
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
6e855b9
6c4e6a4
4d4f04f
2e3378c
0a971b6
4b1d68e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,7 +5,7 @@ import { join } from 'node:path'; | |
| import { tmpdir } from 'node:os'; | ||
|
|
||
| import { getConfigDir } from '../dist/config/store.js'; | ||
| import { countEntries, loadEntries } from '../dist/history/store.js'; | ||
| import { countEntries, loadEntries, readHistoryChunk } from '../dist/history/store.js'; | ||
|
|
||
| function writeHistory(lines) { | ||
| const configDir = getConfigDir(); | ||
|
|
@@ -31,6 +31,35 @@ function validEntry(message, timestamp) { | |
| }); | ||
| } | ||
|
|
||
| test('readHistoryChunk retries short reads from the unread offset', async () => { | ||
| const source = Buffer.from('history entry with a short read'); | ||
| const destination = Buffer.alloc(source.length); | ||
| const reads = []; | ||
| const history = { | ||
| async read(buffer, offset, length, position) { | ||
| reads.push({ offset, length, position }); | ||
| const bytesToCopy = Math.min(5, length); | ||
| source.copy(buffer, offset, position, position + bytesToCopy); | ||
| return { bytesRead: bytesToCopy, buffer }; | ||
| }, | ||
| }; | ||
|
|
||
| assert.equal(await readHistoryChunk(history, destination, 0, source.length), source.length); | ||
| assert.deepEqual(destination, source); | ||
| assert.deepEqual( | ||
| reads.map(({ offset, position }) => ({ offset, position })), | ||
| [ | ||
| { offset: 0, position: 0 }, | ||
| { offset: 5, position: 5 }, | ||
| { offset: 10, position: 10 }, | ||
| { offset: 15, position: 15 }, | ||
| { offset: 20, position: 20 }, | ||
| { offset: 25, position: 25 }, | ||
| { offset: 30, position: 30 }, | ||
| ], | ||
| ); | ||
| }); | ||
|
|
||
| async function withIsolatedHistory(lines, assertion) { | ||
| const originalHome = process.env.HOME; | ||
| const originalAppData = process.env.APPDATA; | ||
|
|
@@ -137,6 +166,40 @@ test('loadEntries warns and returns empty entries when all lines are corrupted', | |
| }); | ||
| }); | ||
|
|
||
| test('loadEntries stops parsing once it has enough recent valid entries', async () => { | ||
| await withIsolatedHistory( | ||
| [ | ||
| '{invalid old line', | ||
| validEntry('fix: keep older valid entry', '2026-06-01T00:00:00Z'), | ||
| validEntry('feat: keep newest valid entry', '2026-06-01T00:00:01Z'), | ||
| ], | ||
| async ({ warnings }) => { | ||
| const entries = await loadEntries(1); | ||
|
|
||
| assert.deepEqual(entries.map((entry) => entry.message), ['feat: keep newest valid entry']); | ||
| assert.deepEqual(warnings, []); | ||
| }, | ||
| ); | ||
| }); | ||
|
Comment on lines
+169
to
+183
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win Consider covering the "recently scanned rows" fallback path. This test only exercises the case where a corrupted row is skipped entirely because it's never scanned (stop happens before reaching it). It doesn't cover the case where a corrupted row is scanned but the read stops before reaching the physical start of the file (a real multi-chunk history), which is when 🤖 Prompt for AI Agents |
||
|
|
||
| test('loadEntries uses a generic location for corrupted rows in a partial scan', async () => { | ||
| const olderEntries = Array.from({ length: 700 }, (_, index) => | ||
| validEntry(`chore: keep older entry ${index}`, `2026-06-01T00:00:${String(index).padStart(2, '0')}Z`), | ||
| ); | ||
|
|
||
| await withIsolatedHistory( | ||
| [...olderEntries, validEntry('fix: keep newest valid entry', '2026-06-01T00:01:00Z'), '{invalid newest line'], | ||
| async ({ warnings }) => { | ||
| const entries = await loadEntries(1); | ||
|
|
||
| assert.deepEqual(entries.map((entry) => entry.message), ['fix: keep newest valid entry']); | ||
| assert.equal(warnings.length, 1); | ||
| assert.match(warnings[0], /ignored 1 corrupted commit history entry/); | ||
| assert.match(warnings[0], /recently scanned rows/); | ||
| }, | ||
| ); | ||
| }); | ||
|
|
||
| test('countEntries counts raw non-empty history rows, including malformed JSON lines', async () => { | ||
| await withIsolatedHistory( | ||
| [ | ||
|
|
@@ -151,3 +214,31 @@ test('countEntries counts raw non-empty history rows, including malformed JSON l | |
| }, | ||
| ); | ||
| }); | ||
|
|
||
| test('loadEntries preserves UTF-8 characters split across backward-read chunks', async () => { | ||
| const prefix = '{"timestamp":"2026-06-01T00:00:00Z","message":"a'; | ||
| const suffix = '","diff":"","model":"test-model","provider":"local"}'; | ||
| const messageTailLength = 2 * 1024 * 1024 - Buffer.byteLength(suffix) - 1; | ||
| const row = `${prefix}\u00e9x\u00e9${'b'.repeat(messageTailLength)}${suffix}`; | ||
|
|
||
| const originalConcat = Buffer.concat; | ||
| let concatCalls = 0; | ||
| Buffer.concat = (...args) => { | ||
| concatCalls += 1; | ||
| return originalConcat(...args); | ||
| }; | ||
|
|
||
| await withIsolatedHistory([row], async () => { | ||
| try { | ||
| const entries = await loadEntries(1); | ||
|
|
||
| assert.equal(entries.length, 1); | ||
| assert.equal(entries[0].message, `a\u00e9x\u00e9${'b'.repeat(messageTailLength)}`); | ||
| assert.doesNotMatch(entries[0].message, /\uFFFD/); | ||
| } finally { | ||
| Buffer.concat = originalConcat; | ||
| } | ||
| }); | ||
|
|
||
| assert.equal(concatCalls, 1); | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When
FileHandle.read()returns fewer bytes than requested, as permitted on some network or FUSE filesystems,positionhas already moved backward by the fullbytesToRead, while onlybytesReadbytes are retained. The unread suffix is then permanently skipped, which can merge JSONL fragments, discard otherwise valid recent entries, or report them as corrupted. Continue reading until the requested interval is filled, or update the cursor based on the bytes actually read.Useful? React with 👍 / 👎.