feat: add --resume for incremental snapshot exports - #11
Open
gacevicljubisa wants to merge 33 commits into
Open
Conversation
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
TestReadCursor never exercised a valid log line split across the 64 KiB backward-read window boundary in lastCursorPlain; every prior case had the target line either wholly inside the last window or was garbage that fails to parse regardless of reconstruction order. Add a case that deterministically positions a real line across the boundary and asserts the straddle before running, so a broken carry concatenation order regresses this case specifically.
Adds AppendWriter and AppendLogsAsync alongside the existing SaveLogsAsync so a resumed export can append to an existing NDJSON file instead of overwriting it, dropping logs a skip function (e.g. resume.Cursor.Skip) reports as already written. filestore takes a bare func(types.Log) bool rather than importing pkg/resume, keeping the dependency one-directional. Test helper fix beyond the brief: feed() must give each log a non-nil Topics slice — go-ethereum's generated Log.UnmarshalJSON rejects a null "topics" field as missing, so a nil Topics (the brief's literal feed()) fails blocksIn's round trip regardless of the implementation under test.
…pstore
Closes a gap in the plan: no task specified the append-then-re-read
round trip promised for pkg/resume, even though it needs the
filestore.AppendLogsAsync/AppendWriter and gzipstore.AppendWriter this
task completes. Adds TestAppendResumeRoundTrip, table-driven over
plain NDJSON and gzip, that builds a starting export with a genuinely
partial final block, resumes it through resume.Read + the matching
append writer + AppendLogsAsync(skip: cursor.Skip), and asserts:
- the cursor lands on the last saved log
- a boundary-block log at a higher index than the cursor is written,
not skipped, since it was never saved
- the full sequence after append is exactly original + new logs,
with no duplicates and nothing dropped
- the original bytes remain an unchanged prefix of the appended
file (for gzip, proof a genuine second member was added rather
than the archive being decompressed and rewritten)
- resume.Read on the appended file advances to the newly written
tail, so a resumed export is itself resumable
An interrupted run can leave a tail that no reader can trust: half an NDJSON line, a line that never got its newline, or a gzip member that never got its CRC and length trailer. Read tolerated all three and told its caller nothing, so the write path appended straight onto them and silently destroyed logs. A valid but unterminated last line was the worst case: the cursor pointed at it, so a resumed query skipped it, while the append fused it with the next log into one unparseable line and the entry was gone for good. The cursor now names the last entry that is complete and properly terminated, and reports the offset at which the file's recoverable content ends (CleanSize) together with whether anything follows it (Truncated). Safety does not rest on recovery: where no boundary can be positively identified, Read refuses with ErrNoCleanBoundary instead of guessing one. For gzip, walk the file a member at a time with Multistream(false) plus Reset over an io.ByteReader, so the reader is never wrapped in a bufio of its own and the byte count stays in step with its real position. That gives exact member boundaries with no new dependency. A member that ends mid-line is not treated as a boundary at all, since appending after it would glue the next log onto an unterminated one. lastCursorGzip also stops discarding scanner errors. Returning the last cursor is still right, but a truncated member, a checksum mismatch or an over-long line now leave the walk at the last clean boundary rather than yielding a too-early cursor with a nil error. That was the single line that made the corruption silent and endlessly repeatable: every later resume appended more unreachable data while reporting success. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
AppendLogsAsync dropped the error from Close, though its doc comment promised a buffered destination is always flushed. On the gzip path Close writes the deflate terminator and the member footer, so a failure there -- a full disk is entirely plausible for a 90 MB export -- left a truncated member behind while the CLI printed "all logs have been saved". Join the close error into the result instead; errors.Join keeps errors.Is working, so a cancelled context still reads as context.Canceled. SaveLogsAsync now opens through CreateWriter and shares the same path, which fixes the same dropped Close for a fresh export and lets a caller open the destination before it starts producing logs. memberWriter.Close names the file it failed on, as CompressFile does; that error is about to become visible. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Discard whatever follows the resume file's clean boundary before the writer is opened, logging the offset and how many bytes went. Nothing recoverable is lost: everything discarded sits at or after the cursor, so the resumed query fetches it again. Only the offset the reader positively identified is ever truncated to, and never past it. This runs after the RPC connection is up, so an unreachable endpoint leaves the file untouched. Open the writer synchronously before GetLogs as well. Opening it inside the saving goroutine meant a failure there logged and returned while fetchLogs kept pushing onto the 100-slot channel; once full it blocked until cancellation, errorChan never closed, and RunE looped for ever printing "still retrieving logs..." while nothing was saved. It now returns the error immediately, and saveLogs is left as a dispatch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The claim that "entries already in the file are skipped, so resuming never duplicates or drops a log" held only for a file that ends cleanly. Say so, and document what happens when a run killed mid-write leaves a partial entry behind: the tool truncates to the last complete entry and re-fetches from there, or refuses outright when no such point can be identified. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Clarify that the resume boundary is the last newline-terminated line that parses as a log entry, not just any newline-terminated line. And scope the guarantee about re-fetching: for export content (partial writes), discarded data is re-fetched; for foreign data that was never a log entry, it is simply removed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The plan was scaffolding for building the feature, not reference material for using it. The design rationale it carried now lives in the README and in the PR description. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comments had grown into design essays restating what the code already says. Keep only the reasoning that stops the code being broken by a well-meaning simplification -- why countingReader must implement io.ByteReader, why Multistream is switched off per member, why a line without a newline means an interrupted write, why the close error is joined -- and drop the narration around it. Also removes the step-by-step narration in CompressFile and stale references to the review that produced the tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… and AppendLogsAsync Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016mxruRkPfuH8dd5nVxgB8S
PrepareOutput compared filepath.Clean(inputPath) == filepath.Clean(outputPath) to decide copy vs. in-place. A relative path against an absolute one, a symlink or hardlink, or a case-insensitive filesystem folding two names together all pass that check as "different", so os.Create(outputPath) truncated the input to zero bytes before io.CopyN ever got to read from it — destroying the file copy mode promises to leave untouched. Add an os.SameFile (device+inode) check as a second guard, falling back to prepareInPlace whenever the paths name the same file by any route. Add a table test covering an absolute/relative spelling pair and a symlink, over both a clean input and one with an interrupted final write, and amend the spec's §3 table row to describe the os.SameFile detection instead of leaving "distinct spellings" as the operator's problem. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016mxruRkPfuH8dd5nVxgB8S
…rors
Three related diagnostic fixes to gzip resume validation (§5):
- Thread a 1-based member index and per-member line number through
scanMember so a refusal on a 150k+-line archive names where the offending
line is ("member 4, line 812 is not a log entry") instead of nothing.
- Drop gzip.ErrChecksum from truncationShaped: an interrupted write cannot
produce a full-length member body plus a complete 8-byte trailer holding
the wrong CRC, so that shape is corruption or tampering, not truncation.
It now falls into the non-truncation-shaped branch, which is reclassified
from a bare wrapped error to ErrNotAnExport (documented tradeoff: a
genuine I/O error shaped like corruption is now also refused rather than
surfaced distinctly, since the two are indistinguishable at this layer and
§5 favors refusal).
- Classify gzip.NewReader's first-header failure the same way the plain
format's equivalent is handled: truncation-shaped (file cut inside the
10-byte header) is ErrNoLogs; anything else is ErrNotAnExport. Previously
it returned a bare wrapped error outside the two-sentinel taxonomy.
Also reword lastCursorPlain's "N bytes without a newline" refusal, which
named the whole file size even when the newline was simply outside the
examined window, to name the window and its starting offset instead.
New tests: a CRC-flip in a valid member's trailer, and a file cut to 5 bytes
inside the gzip header. Spec §5 corrected to say the plain read window is
2*maxLineBytes, not maxLineBytes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016mxruRkPfuH8dd5nVxgB8S
The saver goroutine treated any errors.Is(err, context.Canceled) as pure cancellation and returned without recording saveErr. But AppendLogsAsync can return errors.Join(context.Canceled, closeErr) — SIGINT racing a failing gzip-member flush on Close — and errors.Is only asks "does this contain context.Canceled anywhere," which such a joined error satisfies even though it also carries a real failure. saveErr stayed nil, so on a fresh --compress run compressFunc would run over a file whose final write had failed. Add solelyCanceled, which recurses through Join and single-wrap chains and only returns true when cancellation is the ENTIRE error, and use it in place of errors.Is for that check. The errorChan branch's own errors.Is(err, context.Canceled) is untouched — it classifies the fetcher's echo of the same cancellation, not a joined save error. No cmd unit harness exists for this path (per the design doc, §10); verified by build, go vet, and tracing both error shapes through solelyCanceled: a bare context.Canceled returns true, Join(context.Canceled, ENOSPC) returns false. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016mxruRkPfuH8dd5nVxgB8S
- README: Go 1.24 -> 1.25 (matches go.mod); the --end flag-table line's default text was a stale copy from --start's default and never matched the code (default 0 = latest block, cobra prints no "(default N)" suffix for it); add the copy-mode overwrite behavior, note --start is ignored on resume, and add the recovery one-liner for a failed copy-mode run. - pkg/resume/resume_test.go: reword the stale comment above manyLogs, which described the deleted multi-window backward-read walk; it now earns its keep via the gzip path's per-member buffered scan. Also assert PrepareOutput's discarded-count return exactly in TestResumeAfterInterruptedWrite instead of discarding it with _. - Spec: §8 "A fusnote" -> "A footnote"; §9's "pkg/gzipstore: unchanged" no longer describes this branch, which added AppendWriter to it earlier. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016mxruRkPfuH8dd5nVxgB8S
The spec documents the durable design; the plan was execution scaffolding. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…on non-goal Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The design rationale lives in the PR description and the README's behavior documentation. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds
export --resumefor incremental snapshots: continue a previous export from where it stopped instead of re-exporting every block from the contract's start.Design
--resume(input) composes with--output(destination). Copy mode raw-copies the input's clean content and appends newly fetched entries; the input is opened read-only. The same file under any spelling — absolute vs relative, case-insensitive filesystem, symlink, hardlink — is detected viaos.SameFileand treated as in-place, so copy mode can never truncate its own input.ErrNotAnExport, naming a byte offset for plain files or member+line for gzip) rather than repaired.--endworks as usual (pin it for a deterministic snapshot; default 0 = latest block).gzcat,gunzip, Go, Python all read it transparently). Measured on the real 90 MB export: a year of monthly continuations costs +0.021% in size.gzcat old.gzip | gzip > fresh.gzipconsolidates any time.--compressis always ignored on resume (a plain-twin recompression would overwrite an independently continued archive). Gzip stores no filename inside, so the README recommends the*.ndjson.gzipdouble extension — extraction then yields a.ndjsonfile.gzcatafterwards, ENOSPC on a tiny volume). An internal save failure is reported as itself, never dressed up as a user cancellation.Backward compatibility
Verified against the real pre-branch archives: the full 90 MB
export.ndjson/ 15 MBexport.ndjson.gzip(157,922 entries) pass strict validation with the correct cursor, resume cleanly in both modes, and round-trip byte-identically.Testing
First tests in the repo: strict-validation and refusal tables (13+ refusal shapes across both formats, incl. CRC corruption), copy/in-place round trips, interrupted-write recovery for the three crash shapes, the same-file-spelling guard (RED-verified against the unguarded code), gzip member-boundary exactness, and an end-to-end append-resume cycle.
go test -race,go vet,golangci-lintclean.cmd/export.gohas no unit harness (needs live RPC); its three exit-code guarantees were verified manually as above.Known limitations (documented)
--endpointis not detected (a header would change the formatbatch-archiveconsumes).PrepareOutput.🤖 Generated with Claude Code