Skip to content

Commit aa472b8

Browse files
committed
fix(evidence): skip oversized JSONL lines instead of aborting the import
A codex rollout with a single line past bufio.Scanner's 10MB limit killed the whole import-codex step with 'token too long' (miseledger-refresh has failed on it daily since 2026-07-14). Replace the scanners in the shared JSONL walker and the adapter importer with a bounded line reader that drains oversized lines, records a per-line warning, and keeps importing the rest of the file. The two interrupted-import tests used an 11MB line as their interruption mechanism, which relied on the old aborting behavior; they now interrupt with a mid-stream read error and keep the same assertions.
1 parent 9a6532c commit aa472b8

5 files changed

Lines changed: 266 additions & 36 deletions

File tree

engines/evidence-ledger/internal/ingest/importer.go

Lines changed: 17 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
package ingest
22

33
import (
4-
"bufio"
54
"compress/gzip"
65
"crypto/sha256"
76
"database/sql"
@@ -113,8 +112,6 @@ func importAdapterReaderProgress(db *sql.DB, r io.Reader, sourcePath, sourceOver
113112

114113
result := AdapterResult{SourceKind: sourceKind, SourcePath: sourcePath}
115114
h := sha256.New()
116-
scanner := bufio.NewScanner(r)
117-
scanner.Buffer(make([]byte, 0, 64*1024), 10*1024*1024)
118115
ordinal := int64(0)
119116
warnedOverrideKinds := map[string]bool{}
120117

@@ -145,38 +142,39 @@ func importAdapterReaderProgress(db *sql.DB, r io.Reader, sourcePath, sourceOver
145142
return nil
146143
}
147144

148-
for scanner.Scan() {
149-
line := append([]byte(nil), scanner.Bytes()...)
145+
scanErr := sources.EachLine(r, sources.MaxLineBytes, func(raw []byte, tooLong bool, size int64) error {
150146
ordinal++
147+
if tooLong {
148+
result.Warnings = append(result.Warnings, fmt.Sprintf("line %d: line too long (%d bytes > %d limit), skipped", ordinal, size, sources.MaxLineBytes))
149+
return nil
150+
}
151+
line := append([]byte(nil), raw...)
151152
if len(strings.TrimSpace(string(line))) == 0 {
152-
continue
153+
return nil
153154
}
154155
if recordScan != nil {
155156
file, ok, err := parseSourceScanSentinel(line)
156157
if err != nil {
157-
return AdapterResult{}, err
158+
return err
158159
}
159160
if ok {
160161
if sourceKind == "" {
161162
sourceKind = "adapter"
162163
result.SourceKind = sourceKind
163164
}
164165
if err := flush(batchCount > 0); err != nil {
165-
return AdapterResult{}, err
166+
return err
166167
}
167168
generatedHash := "sha256:" + hex.EncodeToString(h.Sum(nil))
168-
if err := recordScan(sourceKind, generatedHash, file); err != nil {
169-
return AdapterResult{}, err
170-
}
171-
continue
169+
return recordScan(sourceKind, generatedHash, file)
172170
}
173171
}
174172
_, _ = h.Write(line)
175173
_, _ = h.Write([]byte("\n"))
176174
rec, err := adapter.Parse(line)
177175
if err != nil {
178176
result.Warnings = append(result.Warnings, fmt.Sprintf("line %d: %s", ordinal, err))
179-
continue
177+
return nil
180178
}
181179
embeddedKind := rec.Source.Kind
182180
if sourceOverride != "" && embeddedKind != "" && embeddedKind != sourceOverride && !warnedOverrideKinds[embeddedKind] {
@@ -193,20 +191,21 @@ func importAdapterReaderProgress(db *sql.DB, r io.Reader, sourcePath, sourceOver
193191
inserted, err := upsertRecord(tx, rec, sourcePath, ordinal, line)
194192
if err != nil {
195193
result.Warnings = append(result.Warnings, fmt.Sprintf("line %d: %s", ordinal, err))
196-
continue
194+
return nil
197195
}
198196
if inserted {
199197
result.Inserted++
200198
}
201199
batchCount++
202200
if batchCount >= importBatchSize {
203201
if err := flush(true); err != nil {
204-
return AdapterResult{}, err
202+
return err
205203
}
206204
}
207-
}
208-
if err := scanner.Err(); err != nil {
209-
return AdapterResult{}, err
205+
return nil
206+
})
207+
if scanErr != nil {
208+
return AdapterResult{}, scanErr
210209
}
211210
sourceHash := "sha256:" + hex.EncodeToString(h.Sum(nil))
212211
if sourceKind == "" {

engines/evidence-ledger/internal/ingest/importer_test.go

Lines changed: 51 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,13 @@ package ingest
33
import (
44
"bytes"
55
"encoding/json"
6+
"errors"
67
"fmt"
8+
"io"
79
"strconv"
810
"strings"
911
"testing"
12+
"testing/iotest"
1013

1114
"github.com/escoffier-labs/miseledger/internal/archive"
1215
"github.com/escoffier-labs/miseledger/internal/sources"
@@ -46,6 +49,42 @@ func TestImportAdapterReaderIdempotent(t *testing.T) {
4649
}
4750
}
4851

52+
// Regression: an adapter line beyond the 10MB scanner limit used to abort the
53+
// whole import with bufio.Scanner: token too long. It must be skipped with a
54+
// warning while surrounding records still import.
55+
func TestImportAdapterReaderSkipsOversizedLine(t *testing.T) {
56+
db, err := archive.Open(t.TempDir() + "/miseledger.db")
57+
if err != nil {
58+
t.Fatal(err)
59+
}
60+
defer db.Close()
61+
if err := archive.Migrate(db); err != nil {
62+
t.Fatal(err)
63+
}
64+
record := func(id, text string) string {
65+
return `{"schema":"miseledger.adapter.v1","source":{"kind":"oversize-test","name":"Oversize Test"},"collection":{"external_id":"oversize:collection","kind":"agent_session","name":"oversize"},"item":{"external_id":"oversize:item:` + id + `","kind":"message","created_at":"2026-07-14T00:00:00Z","text":"` + text + `","tags":["oversize"]},"actor":{"external_id":"oversize:actor","type":"human","name":"reader"},"artifacts":[],"links":[],"relations":[],"raw":{"format":"json","path":"oversize.jsonl","ordinal":1}}`
66+
}
67+
jsonl := record("1", "before") + "\n" +
68+
record("huge", strings.Repeat("a", sources.MaxLineBytes+1024)) + "\n" +
69+
record("2", "after") + "\n"
70+
res, err := ImportAdapterReader(db, strings.NewReader(jsonl), "oversize://fixture", "oversize-test")
71+
if err != nil {
72+
t.Fatalf("import must not abort on an oversized line: %v", err)
73+
}
74+
if res.Inserted != 2 {
75+
t.Fatalf("inserted = %d, want 2", res.Inserted)
76+
}
77+
var warned bool
78+
for _, w := range res.Warnings {
79+
if strings.Contains(w, "line too long") {
80+
warned = true
81+
}
82+
}
83+
if !warned {
84+
t.Fatalf("expected a line-too-long warning, got %d warnings", len(res.Warnings))
85+
}
86+
}
87+
4988
func TestImportAdapterReaderPreservesSchemaConformantCodeReferencesInItemMetadata(t *testing.T) {
5089
db, err := archive.Open(t.TempDir() + "/miseledger.db")
5190
if err != nil {
@@ -282,9 +321,12 @@ func TestNativeReaderRecordsCommittedFileScanBeforeInterruptedImport(t *testing.
282321
}); err != nil {
283322
t.Fatal(err)
284323
}
285-
b.WriteString(strings.Repeat("x", 11*1024*1024))
324+
// Interrupt the stream with a read error after the sentinel. (This used to
325+
// be an 11MB line tripping bufio.Scanner's limit; oversized lines are now
326+
// skipped with a warning instead of aborting, so they no longer interrupt.)
327+
interrupted := io.MultiReader(strings.NewReader(b.String()), iotest.ErrReader(errors.New("simulated interruption")))
286328

287-
_, err = ImportNativeReaderProgress(db, strings.NewReader(b.String()), "native://fixture", "native-scan", nil, func(sourceKind, generatedHash string, file sources.FileScan) error {
329+
_, err = ImportNativeReaderProgress(db, interrupted, "native://fixture", "native-scan", nil, func(sourceKind, generatedHash string, file sources.FileScan) error {
288330
return RecordSourceScans(db, sourceKind, generatedHash, []sources.FileScan{file}, true)
289331
})
290332
if err == nil {
@@ -315,8 +357,13 @@ func TestNativeReaderDoesNotRecordScanForUncommittedFile(t *testing.T) {
315357
if err := archive.Migrate(db); err != nil {
316358
t.Fatal(err)
317359
}
318-
stream := adapterRecord("native-uncommitted", "file1:item:1", "uncommitted file record", "file1.jsonl", 1) + strings.Repeat("x", 11*1024*1024)
319-
_, err = ImportNativeReaderProgress(db, strings.NewReader(stream), "native://fixture", "native-uncommitted", nil, func(sourceKind, generatedHash string, file sources.FileScan) error {
360+
// Interrupt with a read error before any file-complete sentinel arrives
361+
// (formerly an 11MB line tripping bufio.Scanner's limit).
362+
stream := io.MultiReader(
363+
strings.NewReader(adapterRecord("native-uncommitted", "file1:item:1", "uncommitted file record", "file1.jsonl", 1)),
364+
iotest.ErrReader(errors.New("simulated interruption")),
365+
)
366+
_, err = ImportNativeReaderProgress(db, stream, "native://fixture", "native-uncommitted", nil, func(sourceKind, generatedHash string, file sources.FileScan) error {
320367
return RecordSourceScans(db, sourceKind, generatedHash, []sources.FileScan{file}, true)
321368
})
322369
if err == nil {

engines/evidence-ledger/internal/sources/codex/codex_test.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,38 @@ func TestGenerateMalformedAndUnknownInput(t *testing.T) {
107107
}
108108
}
109109

110+
// Regression: a rollout file with a single line beyond the 10MB scanner limit
111+
// used to abort the whole codex import with bufio.Scanner: token too long.
112+
// The oversized line must be skipped with a warning while every other line in
113+
// the same file (and the rest of the tree) still imports.
114+
func TestGenerateSkipsOversizedLine(t *testing.T) {
115+
dir := t.TempDir()
116+
path := filepath.Join(dir, "rollout-oversized.jsonl")
117+
huge := `{"type":"event_msg","timestamp":"2026-07-14T19:29:49Z","payload":{"session_id":"s","role":"assistant","message":"` +
118+
strings.Repeat("a", sources.MaxLineBytes+1024) + `"}}`
119+
content := strings.Join([]string{
120+
`{"type":"event_msg","timestamp":"2026-07-14T19:29:48Z","payload":{"session_id":"s","role":"user","message":"before the oversized line"}}`,
121+
huge,
122+
`{"type":"event_msg","timestamp":"2026-07-14T19:29:50Z","payload":{"session_id":"s","role":"assistant","message":"after the oversized line"}}`,
123+
}, "\n") + "\n"
124+
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
125+
t.Fatal(err)
126+
}
127+
recs, res := parseRecords(t, path, sources.Options{})
128+
if len(recs) != 2 {
129+
t.Fatalf("records = %d, want the two normal lines to import", len(recs))
130+
}
131+
var warned bool
132+
for _, w := range res.Warnings {
133+
if strings.Contains(w, "line too long") {
134+
warned = true
135+
}
136+
}
137+
if !warned {
138+
t.Fatalf("expected a line-too-long warning, got %v", res.Warnings)
139+
}
140+
}
141+
110142
func TestGenerateMissingPathErrors(t *testing.T) {
111143
var buf bytes.Buffer
112144
if _, err := Generate(filepath.Join(t.TempDir(), "nope.jsonl"), sources.Options{}, &buf); err == nil {
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
package sources
2+
3+
import (
4+
"strings"
5+
"testing"
6+
)
7+
8+
type collectedLine struct {
9+
line string
10+
tooLong bool
11+
size int64
12+
}
13+
14+
func collectLines(t *testing.T, input string, max int) []collectedLine {
15+
t.Helper()
16+
var out []collectedLine
17+
err := EachLine(strings.NewReader(input), max, func(line []byte, tooLong bool, size int64) error {
18+
out = append(out, collectedLine{line: string(line), tooLong: tooLong, size: size})
19+
return nil
20+
})
21+
if err != nil {
22+
t.Fatalf("EachLine: %v", err)
23+
}
24+
return out
25+
}
26+
27+
func TestEachLineSkipsOversizedLineAndContinues(t *testing.T) {
28+
long := strings.Repeat("x", 100)
29+
got := collectLines(t, "before\n"+long+"\nafter\n", 10)
30+
if len(got) != 3 {
31+
t.Fatalf("lines = %d, want 3: %+v", len(got), got)
32+
}
33+
if got[0].line != "before" || got[0].tooLong {
34+
t.Fatalf("line 1 = %+v", got[0])
35+
}
36+
if !got[1].tooLong || got[1].line != "" || got[1].size != 100 {
37+
t.Fatalf("oversized line = %+v, want tooLong with size 100", got[1])
38+
}
39+
if got[2].line != "after" || got[2].tooLong {
40+
t.Fatalf("line 3 = %+v, oversized line must not poison later lines", got[2])
41+
}
42+
}
43+
44+
// A line far larger than the read buffer must be drained chunk by chunk
45+
// without buffering it whole.
46+
func TestEachLineOversizedSpansManyReadChunks(t *testing.T) {
47+
long := strings.Repeat("y", 300*1024)
48+
got := collectLines(t, long+"\nok\n", 1024)
49+
if len(got) != 2 {
50+
t.Fatalf("lines = %d, want 2", len(got))
51+
}
52+
if !got[0].tooLong || got[0].size != int64(len(long)) {
53+
t.Fatalf("oversized line = %+v", got[0])
54+
}
55+
if got[1].line != "ok" {
56+
t.Fatalf("line after oversized = %+v", got[1])
57+
}
58+
}
59+
60+
func TestEachLineFinalLineWithoutNewline(t *testing.T) {
61+
got := collectLines(t, "a\nb", 10)
62+
if len(got) != 2 || got[1].line != "b" || got[1].tooLong {
63+
t.Fatalf("lines = %+v", got)
64+
}
65+
// Oversized final line without a terminator is still reported.
66+
got = collectLines(t, "a\n"+strings.Repeat("z", 20), 10)
67+
if len(got) != 2 || !got[1].tooLong || got[1].size != 20 {
68+
t.Fatalf("lines = %+v", got)
69+
}
70+
}
71+
72+
func TestEachLineStripsCarriageReturns(t *testing.T) {
73+
got := collectLines(t, "a\r\nb\r", 10)
74+
if len(got) != 2 || got[0].line != "a" || got[1].line != "b" {
75+
t.Fatalf("lines = %+v", got)
76+
}
77+
}
78+
79+
func TestEachLineBlankAndEmptyInput(t *testing.T) {
80+
if got := collectLines(t, "", 10); len(got) != 0 {
81+
t.Fatalf("empty input produced %+v", got)
82+
}
83+
got := collectLines(t, "\n\n", 10)
84+
if len(got) != 2 || got[0].line != "" || got[1].line != "" {
85+
t.Fatalf("blank lines = %+v", got)
86+
}
87+
}

0 commit comments

Comments
 (0)