Skip to content

backup: snapshot dispatcher (Phase 0a routing layer)#806

Merged
bootjp merged 5 commits into
mainfrom
backup/snapshot-dispatcher
May 23, 2026
Merged

backup: snapshot dispatcher (Phase 0a routing layer)#806
bootjp merged 5 commits into
mainfrom
backup/snapshot-dispatcher

Conversation

@bootjp
Copy link
Copy Markdown
Owner

@bootjp bootjp commented May 22, 2026

Summary

  • Adds DecodeSnapshot(io.Reader, DecodeOptions) (DecodeResult, error) in internal/backup/decode.go that wires the four adapter encoders (DynamoDB, S3, Redis, SQS) onto a single prefix-routed dispatcher.
  • Adds ReadSnapshotWithHeader so the header is surfaced even for snapshots with zero entries — MANIFEST.last_commit_ts needs to populate even on an empty dump.
  • Phase 0a routing layer only. The cmd/elastickv-snapshot-decode binary, MANIFEST.json emission, and CHECKSUMS writer live in a separate follow-up PR on top of this.

Design reference

docs/design/2026_04_29_proposed_snapshot_logical_decoder.md §"Pipeline" — the routing table here implements the prefix list spelled out there. Longer prefixes win (!hs|meta|d| beats !hs|meta|); !txn| / !s3route| / !s3|gc|upload| are dropped as internal-only.

Behavior

  • Tombstones are filtered before adapter dispatch (Phase 0a dumps the current user-visible state, not MVCC history).
  • An entry whose prefix matches an adapter that is disabled in DecodeOptions.Adapters is counted as Internal (intentional drop), not Unknown — so --adapter <subset> keeps per-class counts in the right bucket.
  • An entry whose prefix matches nothing increments Unknown — the format-skew / corruption signal a Phase 0a operator wants to surface, not swallow.

Test plan

  • go test -race ./internal/backup/ (passes — 1.4s including the new tests)
  • golangci-lint run ./internal/backup/... (clean — 0 issues)
  • Eight new tests cover the routing surface: empty OutRoot rejection, header-only finalize, Redis-string end-to-end, tombstone skip, disabled-adapter Internal counter, unknown-prefix Unknown counter, !txn| Internal counter, prefix-length descending ordering invariant, and the !hs|meta|d| vs !hs|meta| ambiguity canary.

Self-review (CLAUDE.md §"Self-review of code changes")

  1. Data loss — tombstones dropped on purpose; disabled-adapter entries counted but not written; routing covers every prefix the live adapters emit.
  2. Concurrency — dispatcher is single-threaded over the ReadSnapshot callback; no shared state across goroutines.
  3. Performance — prefix table walks ~30 entries per record; throughput in offline decode is dominated by per-adapter file I/O, not the lookup.
  4. Data consistencylast_commit_ts carried through ReadSnapshotWithHeader; encrypted entries propagate ErrSnapshotEncryptedEntry from ReadSnapshot (the underlying reader fails closed on encState=0b01).
  5. Test coverage — eight tests pin the routing surface; per-adapter encoders are already tested in their own files (redis_*_test.go, dynamodb_test.go, s3_test.go, sqs_test.go).

Wires the four adapter encoders (DynamoDB, S3, Redis, SQS) under
a single DecodeSnapshot entrypoint that consumes a .fsm-format
stream and routes each entry by leading prefix into the encoder
that owns it.

- internal/backup/decode.go
  - DecodeOptions + DecodeResult + DecodeCounters
  - dispatcher with prefix-length-sorted routing table (longer
    prefixes win so !hs|meta|d| beats !hs|meta|)
  - tombstones dropped before adapter dispatch; disabled adapters
    surface as Internal (not Unknown) so --adapter <subset> keeps
    counts in the right bucket
  - !txn|, !s3route|, !s3|gc|upload| routed to internal-drop
- internal/backup/snapshot_reader.go
  - new ReadSnapshotWithHeader so header-only snapshots still
    surface LastCommitTS to the dispatcher (the existing
    callback-only ReadSnapshot never fires fn on an empty body,
    leaving MANIFEST.last_commit_ts unpopulated)
- internal/backup/decode_test.go pins:
  empty OutRoot rejected, header-only snapshot finalizes cleanly,
  Redis string round-trip end-to-end (one entry in, file out),
  tombstone skip, disabled-adapter -> Internal counter,
  unknown prefix -> Unknown counter, !txn| -> Internal counter,
  prefix routing table sorted by descending length, and
  !hs|meta|d| matches before !hs|meta| (routing ambiguity canary)

cmd/elastickv-snapshot-decode binary + MANIFEST/CHECKSUMS
emission follow in a separate PR.

Self-review:
1. Data loss: tombstones dropped on purpose (Phase 0a reflects
   current state, not MVCC history); disabled-adapter entries
   counted but not written (operator opted out); routing covers
   every prefix the live adapters emit.
2. Concurrency: dispatcher is single-threaded over ReadSnapshot
   callback; no shared state.
3. Performance: prefix table walks about 30 entries per record;
   acceptable for offline decode (throughput dominated by
   per-adapter file I/O, not the dispatch lookup).
4. Data consistency: header carried through; encrypted entries
   propagate ErrSnapshotEncryptedEntry from ReadSnapshot.
5. Test coverage: eight tests cover the routing surface; per-
   adapter encoders are already tested in their own files.
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 22, 2026

Warning

Rate limit exceeded

@bootjp has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 13 minutes and 20 seconds before requesting another review.

You’ve run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 9d75c0de-c113-473b-ad84-e0a025c3976a

📥 Commits

Reviewing files that changed from the base of the PR and between 81937f6 and b9fa4a7.

📒 Files selected for processing (3)
  • internal/backup/decode.go
  • internal/backup/decode_test.go
  • internal/backup/snapshot_reader.go
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch backup/snapshot-dispatcher

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request introduces a dispatcher to orchestrate the routing of snapshot entries to specific adapter encoders (DynamoDB, S3, Redis, and SQS) based on key prefixes. It also refactors the snapshot reader to ensure headers are accessible for empty snapshots and adds unit tests for routing logic. Feedback focuses on preventing data loss by ensuring the S3 scratch directory does not overlap with the output root and improving the robustness of the finalization process to ensure all adapters are cleaned up even when errors occur.

Comment thread internal/backup/decode.go Outdated
Comment on lines +185 to +187
scratch = opts.OutRoot
}
d.s3 = NewS3Encoder(opts.OutRoot, scratch).
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

Defaulting ScratchRoot to opts.OutRoot causes the S3Encoder to use the same base directory (s3/) for both temporary scratch chunks and final assembled objects. Since S3Encoder.Finalize performs an os.RemoveAll on its scratchRoot at the end of its execution, this default behavior will result in the entire S3 dump being deleted immediately after it is written. Using a dedicated .scratch subdirectory prevents this collision.

if scratch == "" {
			scratch = filepath.Join(opts.OutRoot, ".scratch")
		}

Comment thread internal/backup/decode.go
Comment on lines +3 to +9
import (
"bytes"
"io"
"sort"

"github.com/cockroachdb/errors"
)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

The path/filepath package should be imported to support safe path manipulation when setting up the default scratch directory for the S3 encoder.

Suggested change
import (
"bytes"
"io"
"sort"
"github.com/cockroachdb/errors"
)
import (
"bytes"
"io"
"path/filepath"
"sort"
"github.com/cockroachdb/errors"
)

Comment thread internal/backup/decode.go
Comment on lines +146 to +154
hdr, err := ReadSnapshotWithHeader(r, func(_ SnapshotHeader, e SnapshotEntry) error {
return d.handleEntry(e)
})
if err != nil {
return DecodeResult{}, err
}
if err := d.finalize(); err != nil {
return DecodeResult{}, err
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

If ReadSnapshotWithHeader fails (e.g., due to a truncated stream), d.finalize() is currently skipped. This prevents encoders from cleaning up temporary resources, such as the S3 scratch directory. The dispatcher should ensure that finalize is called even on read errors to perform necessary cleanup, while still prioritizing and returning the original read error.

Suggested change
hdr, err := ReadSnapshotWithHeader(r, func(_ SnapshotHeader, e SnapshotEntry) error {
return d.handleEntry(e)
})
if err != nil {
return DecodeResult{}, err
}
if err := d.finalize(); err != nil {
return DecodeResult{}, err
}
hdr, err := ReadSnapshotWithHeader(r, func(_ SnapshotHeader, e SnapshotEntry) error {
return d.handleEntry(e)
})
fErr := d.finalize()
if err == nil {
err = fErr
}
if err != nil {
return DecodeResult{}, err
}
References
  1. Do not silently ignore errors from cleanup methods on resources. Ensure errors are visible to prevent resource leaks or hidden failures.

Comment thread internal/backup/decode.go
Comment on lines +238 to +260
func (d *dispatcher) finalize() error {
if d.ddb != nil {
if err := d.ddb.Finalize(); err != nil {
return err
}
}
if d.s3 != nil {
if err := d.s3.Finalize(); err != nil {
return err
}
}
if d.redis != nil {
if err := d.redis.Finalize(); err != nil {
return err
}
}
if d.sqs != nil {
if err := d.sqs.Finalize(); err != nil {
return err
}
}
return nil
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

The current finalize implementation returns immediately on the first error, which can leave subsequent encoders unfinalized and their resources (like scratch directories) leaked. The dispatcher should attempt to finalize all active encoders and return the first error encountered to ensure visibility into cleanup failures.

func (d *dispatcher) finalize() error {
	var firstErr error
	if d.ddb != nil {
		if err := d.ddb.Finalize(); err != nil && firstErr == nil {
			firstErr = err
		}
	}
	if d.s3 != nil {
		if err := d.s3.Finalize(); err != nil && firstErr == nil {
			firstErr = err
		}
	}
	if d.redis != nil {
		if err := d.redis.Finalize(); err != nil && firstErr == nil {
			firstErr = err
		}
	}
	if d.sqs != nil {
		if err := d.sqs.Finalize(); err != nil && firstErr == nil {
			firstErr = err
		}
	}
	return firstErr
}
References
  1. Do not silently ignore errors from cleanup methods on resources. Ensure errors are visible to prevent resource leaks or hidden failures.

Copy link
Copy Markdown

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

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: 3e0fade764

ℹ️ 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 thread internal/backup/decode.go
Comment on lines +329 to +332
// Internal-only: transactional intents / locks / resolver
// records. Phase 0a drops these; they belong to the running
// cluster, not the data.
{[]byte("!txn|"), routeInternalDrop},
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 Route known control-plane prefixes to Internal bucket

The dispatcher treats any non-matched key as Unknown, but the route table only hard-drops !txn| (plus S3 internals) and omits other non-user namespaces that are persisted in snapshots, such as distribution catalog keys (!dist|meta|, !dist|route| in distribution/catalog.go). Because snapshot writing iterates all Pebble keys, these valid control-plane records will be counted as Unknown instead of Internal, creating false corruption/format-skew signals and skewing counters on real clustered snapshots.

Useful? React with 👍 / 👎.

Comment thread internal/backup/decode.go Outdated
Comment on lines +149 to +151
if err != nil {
return DecodeResult{}, err
}
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 Run adapter cleanup when decode aborts mid-stream

If ReadSnapshotWithHeader returns an error after some entries were already processed, DecodeSnapshot returns immediately and skips d.finalize(). This bypasses adapter cleanup paths (notably S3 Finalize, which closes buffered JSONL writers and removes scratch files), so a malformed/truncated tail can leak temporary files/handles and leave partial on-disk state in long-running processes that call the decoder repeatedly.

Useful? React with 👍 / 👎.

…ys runs

Addresses gemini r1 review on PR #806 (1 high + 3 mediums, all
on internal/backup/decode.go).

## security-high: default ScratchRoot wipes final S3 output

Pre-fix, `newDispatcher` defaulted `scratch := opts.OutRoot`
when `opts.ScratchRoot` was empty. `NewS3Encoder(outRoot, scratch)`
then computed `scratchRoot = filepath.Join(scratch, "s3")` = the
SAME directory the encoder writes final assembled bodies to.
`S3Encoder.Finalize` runs `defer os.RemoveAll(s.scratchRoot)`
unconditionally, so the entire S3 portion of the dump was wiped
immediately after it landed.

Fix: default scratch to `filepath.Join(opts.OutRoot, ".scratch")`
so the scratch tree is a disjoint sibling of the final `s3/`
output tree. Operators who still want scratch outside OutRoot
keep their existing override via `opts.ScratchRoot`.

Regression test:
`TestDecodeSnapshot_DefaultScratchDoesNotWipeFinalS3Output`
pre-creates a marker file at `<OutRoot>/s3/preexisting.txt`,
runs a header-only DecodeSnapshot with S3 enabled and empty
ScratchRoot, and asserts the marker survives. Verified the test
fails against the pre-fix code (`scratch = opts.OutRoot`) — it
reports "marker file was wiped by Finalize".

## medium: finalize() skipped on ReadSnapshotWithHeader error

Pre-fix, a truncated stream returned `err` from
`ReadSnapshotWithHeader` and skipped `d.finalize()` — leaving
scratch trees on disk after a failed decode.

Fix: call `d.finalize()` unconditionally. The original read
error wins over a finalize error when both occur (the read
failure is the root cause; finalize errors are usually
downstream symptoms).

Regression test:
`TestDecodeSnapshot_FinalizeRunsOnTruncatedStream` feeds a
half-header, asserts DecodeSnapshot errors, AND asserts the
`.scratch` tree is absent.

## medium: finalize() short-circuits on first encoder error

Pre-fix, `dispatcher.finalize()` returned on the first encoder
error — later encoders never got to clean up.

Fix: best-effort iteration, first error wins. Each encoder's
Finalize still runs even if an earlier one errored, so all
scratch trees get reclaimed.

## medium: add `path/filepath` import

Required by the high-priority fix above.

## Caller audit (semantic-change rule)

`DecodeSnapshot` has no non-test callers yet (Phase 0a — the
routing layer ships separately from any CLI wrapper). The only
direct caller of `dispatcher.finalize()` is `DecodeSnapshot`
itself, which is updated in lock-step with the best-effort
change.

The fail-closed semantics are preserved: a Finalize error still
surfaces as a non-nil return; the change only affects WHICH
error is returned when multiple encoders fail.

## Verification

- `go test -race -count=1 ./internal/backup/...` passes (existing
  tests + 2 new regression tests).
- `golangci-lint` clean.
- Regression test verified to catch the pre-fix bug.
@bootjp
Copy link
Copy Markdown
Owner Author

bootjp commented May 22, 2026

@claude review

Round 1 (commit c9f23c75) addresses all four gemini r1 findings on internal/backup/decode.go:

security-high (decode.go:187): Default ScratchRoot no longer collides with the final S3 output tree. The scratch path is now filepath.Join(opts.OutRoot, ".scratch"), so S3Encoder.Finalize's deferred os.RemoveAll(scratchRoot/s3/) reclaims <OutRoot>/.scratch/s3/ rather than wiping <OutRoot>/s3/ itself. Operators who already override opts.ScratchRoot keep their existing tmpfs / out-of-band scratch dir.

medium (decode.go:154): d.finalize() now runs even when ReadSnapshotWithHeader returns an error. The read error wins over a finalize error when both occur — the read failure is the root cause; finalize errors are downstream symptoms.

medium (decode.go:260): dispatcher.finalize() is now best-effort per encoder. The first error wins, but subsequent encoders still get to run their Finalize (and reclaim their scratch trees) instead of being silently skipped.

medium (decode.go:9): path/filepath import added.

Regression tests:

  • TestDecodeSnapshot_DefaultScratchDoesNotWipeFinalS3Output — pre-creates a marker at <OutRoot>/s3/preexisting.txt, runs DecodeSnapshot with S3 enabled and empty ScratchRoot, and asserts the marker survives. Verified to fail against the pre-fix code (scratch = opts.OutRoot): "marker file was wiped by Finalize".
  • TestDecodeSnapshot_FinalizeRunsOnTruncatedStream — feeds a half-header, asserts DecodeSnapshot errors, AND asserts the .scratch tree is absent (proves Finalize ran).

Caller audit: DecodeSnapshot has no non-test callers in Phase 0a (no CLI wrapper yet); the only direct caller of dispatcher.finalize() is DecodeSnapshot itself. The fail-closed semantics on Finalize errors are preserved — only the choice of which error to return changes when multiple encoders fail.

go test -race ./internal/backup/... passes; golangci-lint clean.

@claude
Copy link
Copy Markdown

claude Bot commented May 22, 2026

Claude finished @bootjp's task in 2m 53s —— View job


Review of Round 1 fixes (commit c9f23c75)

  • Load tools
  • Read internal/backup/decode.go
  • Read internal/backup/decode_test.go
  • Read internal/backup/snapshot_reader.go
  • Analyze fixes and remaining issues
  • Post review

Gemini r1 findings — all four verified correct ✓

ScratchRoot path collision fix (decode.go:192–200) — The default is now filepath.Join(opts.OutRoot, ".scratch"), keeping the scratch tree disjoint from the final output at <OutRoot>/s3/. The record()-based best-effort finalize at decode.go:260–278 is also correctly implemented: each encoder runs regardless of prior failures, and the first error wins.

Finalize-on-error (decode.go:156–159)d.finalize() is now unconditional, with the read error taking precedence over a finalize error. Correct.

path/filepath import — present at line 6. ✓


Remaining issues

1. Stale DecodeOptions.ScratchRoot comment (minor, must fix before merge)

decode.go:50–51:

// ScratchRoot is where the S3 encoder buffers blob chunks during
// assembly. Defaults to OutRoot when empty; callers backing the

The comment says "Defaults to OutRoot when empty" but the code now defaults to filepath.Join(opts.OutRoot, ".scratch"). This directly contradicts the security-high fix and will mislead operators setting up a production deployment. The actual default behavior should be documented here.

Fix this →

2. !dist| prefix missing from route table — Codex P2 is valid (should fix before merge)

distribution/catalog.go:15–16 defines:

catalogMetaPrefix  = "!dist|meta|"
catalogRoutePrefix = "!dist|route|"

These keys are persisted to the default Raft group's Pebble (confirmed: catalog.go takes a store.Store, which is the same Pebble instance the FSM snapshots). They will appear in a .fsm dump, and the current prefixRoutes table has no entry for !dist|. Result: every route-catalog entry from a real clustered snapshot lands in Counters.Unknown instead of Counters.Internal, producing a false corruption signal.

The Redis adapter already knows to skip these (adapter/redis_compat_types.go:225: []byte("!dist|")), confirming they're a known internal namespace.

The one-line fix is to add to buildPrefixRoutes():

{[]byte("!dist|"), routeInternalDrop},

Fix this →

3. TestDecodeSnapshot_FinalizeRunsOnTruncatedStream — assertion is vacuously true (note)

The test feeds a half-header stream. ReadSnapshotWithHeader fails in readSnapshotHeader before the callback fires — the S3 encoder never writes to .scratch. The final assertion:

if _, err := os.Stat(filepath.Join(root, ".scratch")); err == nil {
    t.Fatalf(...)
}

…passes whether or not finalize() was called, because .scratch was never created. The test proves DecodeSnapshot returns an error on truncation (useful), but the comment's claim that "Finalize ran (deferred RemoveAll)" is not demonstrated. The author acknowledged this in the inline comment, but a future reader may lean on this test as behavioral evidence that Finalize runs — it isn't.

A stronger version would: pre-create <root>/.scratch/s3/ manually, run the truncated decode, and assert the dir is absent. Not blocking, but worth a follow-up.


Summary

The four Gemini r1 fixes are mechanically correct. Two items warrant attention before merging: the stale ScratchRoot comment (directly contradicts the security fix it was part of) and the missing !dist| route (real false-positive Unknown signal on every clustered snapshot). The test weakness is noted but not blocking.

Copy link
Copy Markdown

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

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: c9f23c75b3

ℹ️ 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 thread internal/backup/decode.go
// Internal-only: transactional intents / locks / resolver
// records. Phase 0a drops these; they belong to the running
// cluster, not the data.
{[]byte("!txn|"), routeInternalDrop},
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 Route reserved !dist| catalog keys to Internal bucket

The dispatch table only hard-drops !txn| (plus S3 internals), so persisted control-plane keys like !dist|meta|... / !dist|route|... fall through to Unknown even though they are expected in healthy snapshots. This produces false format-skew/corruption signals and noisy counters for real cluster backups. Fresh evidence: distribution/catalog.go defines and persists these prefixes, and store/snapshot_pebble.go snapshots all Pebble keys, so they will reach this router.

Useful? React with 👍 / 👎.

…then test

Addresses round-2 review on PR #806:

  - Codex r1 P2  (!dist| missing from route table)
  - claude-bot r1 (stale ScratchRoot doc + vacuous truncated-stream test)

## P2: !dist| dispatched as Internal, not Unknown

The distribution catalog persists `!dist|meta|*` and `!dist|route|*`
keys through the default Raft group's Pebble (distribution/catalog.go).
Snapshot writes iterate every Pebble key, so these records appear
in every real clustered snapshot. The prefix router only knows
`!txn|` as an internal-drop today, so distribution keys land in
Counters.Unknown — a false corruption signal on production dumps.

The Redis adapter already treats this as a known internal namespace
(adapter/redis_compat_types.go's []byte("!dist|") skip), confirming
the prefix is well-defined cluster-wide.

Add `{[]byte("!dist|"), routeInternalDrop}` to buildPrefixRoutes()
alongside the existing !txn| entry. The descending-length sort
preserves longest-match semantics so no existing route is shadowed.

Caller audit: routeInternalDrop is a no-op that increments
Counters.Internal. Moving distribution-catalog entries from
Unknown → Internal preserves the operator-facing semantics:
  - Counters.Unknown remains the "corruption / version-skew" alarm
  - Counters.Internal remains the "intentional drop" bucket
Existing Counters.Unknown / Counters.Internal consumers (none in
production code yet — Phase 0a is the routing layer) interpret
both buckets correctly under the new routing.

Regression test: TestDecodeSnapshot_DistPrefixDroppedAsInternal
feeds one `!dist|meta|version` + one `!dist|route|abc` and asserts
Counters.Internal == 2 / Counters.Unknown == 0.

## Stale ScratchRoot doc (claude-bot r1 must-fix)

DecodeOptions.ScratchRoot comment said "Defaults to OutRoot when
empty" — directly contradicted the security-high fix in r1
(scratch now defaults to `<OutRoot>/.scratch`). Updated to match
the actual behavior, with an explicit NEVER-default-to-OutRoot
note explaining why.

## Strengthened truncated-stream test (claude-bot r1 follow-up)

TestDecodeSnapshot_FinalizeRunsOnTruncatedStream was vacuously
true: the truncated stream errored before any S3 record was
processed, so `.scratch` was never created and the absent-check
passed regardless of whether finalize() ran.

Strengthen: pre-create `<root>/.scratch/s3/` manually so it
exists going in. Now finalize-skip leaves the dir intact; the
fix's unconditional finalize() reclaims it via the deferred
os.RemoveAll. Verified the strengthened test FAILS against a
hand-rolled regression of the pre-fix code (finalize skipped on
read error): "`.scratch/s3/ survived truncated-stream decode —
finalize did not run`".

## Verification

  - go test -race ./internal/backup/... passes (existing tests +
    1 new + 1 strengthened).
  - golangci-lint clean.
@bootjp
Copy link
Copy Markdown
Owner Author

bootjp commented May 22, 2026

@claude review

Round 2 (commit 67cce29e) — addresses claude-bot r1 follow-up + Codex r1 P2 that I missed in r1:

Codex r1 P2 (!dist| missing from route table): Added {[]byte("!dist|"), routeInternalDrop} alongside the existing !txn| entry. distribution/catalog.go's !dist|meta|* / !dist|route|* keys ride the default Raft group's Pebble and so appear in every clustered snapshot — without the route they'd land in Counters.Unknown, a false corruption signal on real production dumps. The Redis adapter already skips !dist| (adapter/redis_compat_types.go:225), so the prefix is well-defined cluster-wide.

claude-bot r1 must-fix (stale ScratchRoot doc): DecodeOptions.ScratchRoot comment said "Defaults to OutRoot when empty" — directly contradicted the r1 security-high fix. Now says "Defaults to <OutRoot>/.scratch when empty (NEVER to OutRoot itself — sharing would let S3Encoder.Finalize's os.RemoveAll wipe the final dump)."

claude-bot r1 follow-up (vacuous truncated-stream test): TestDecodeSnapshot_FinalizeRunsOnTruncatedStream now pre-creates <root>/.scratch/s3/ manually, so finalize-skip leaves the dir intact and finalize-runs reclaims it. Verified the strengthened test FAILS against a hand-rolled regression of the pre-fix code (skip finalize on read error): .scratch/s3/ survived truncated-stream decode — finalize did not run.

New regression test: TestDecodeSnapshot_DistPrefixDroppedAsInternal feeds one !dist|meta|version + one !dist|route|abc and asserts Counters.Internal == 2 / Counters.Unknown == 0.

Caller audit (semantic-change rule): routeInternalDrop increments Counters.Internal. Moving distribution-catalog entries from Unknown → Internal preserves operator-facing semantics: Counters.Unknown remains the corruption / version-skew alarm, Counters.Internal remains the intentional-drop bucket. No production consumers of either counter exist yet (Phase 0a is the routing layer).

go test -race ./internal/backup/... passes; golangci-lint clean.

@claude
Copy link
Copy Markdown

claude Bot commented May 22, 2026

Claude finished @bootjp's task in 3m 33s —— View job


Review of Round 2 fixes (commit 67cce29e)

  • Load tools
  • Read internal/backup/decode.go
  • Read internal/backup/decode_test.go
  • Read internal/backup/snapshot_reader.go
  • Verify !dist| coverage and check for other undeclared internal namespaces
  • Analyze Round 2 fixes
  • Post review

Round 2 — all three r1 items correctly addressed ✓

1. !dist| route (decode.go:354–361)

Added {[]byte("!dist|"), routeInternalDrop} with a clear rationale comment. Verified against distribution/catalog.go:15–16: both !dist|meta| and !dist|route| are covered by the single !dist| prefix entry. The accompanying TestDecodeSnapshot_DistPrefixDroppedAsInternal feeds both sub-keys and pins Internal==2 / Unknown==0. Correct.

I also checked whether the shard-layer route-lookup keys (!ddb|route|table|* from kv/shard_key.go:15, !sqs|route|* from kv/shard_key.go:33) would appear in snapshots. They don't — dynamoRouteTableKey and sqsRouteKey build keys only for in-memory RouteEngine lookups; nothing writes them to Pebble. Similarly, HLC ceiling updates (kv/fsm.go:186) call hlc.SetPhysicalCeiling directly on the in-memory struct. So the route table has no additional gaps.

2. ScratchRoot doc comment (decode.go:49–53)

// ScratchRoot is where the S3 encoder buffers blob chunks during
// assembly. Defaults to <OutRoot>/.scratch when empty (NEVER to
// OutRoot itself — sharing would let S3Encoder.Finalize's
// os.RemoveAll wipe the final dump). Callers backing the CLI's
// --scratch-root flag override it (e.g. to a tmpfs).

Matches the implementation at line 202 exactly. The safety explanation is appropriately loud. ✓

3. Strengthened truncated-stream test (decode_test.go:253–278)

Pre-creates <root>/.scratch/s3/ before the decode attempt. After DecodeSnapshot errors on the half-header (8 of 16 bytes), d.finalize() calls S3Encoder.Finalize() which runs os.RemoveAll(<root>/.scratch/s3/). The final os.Stat(preScratch) asserts the dir is absent — if finalize were skipped, the pre-created dir would survive and the test would fail. The author confirmed the test fails against the regressed code. Genuinely non-vacuous. ✓


No remaining blocking issues

The three r1 findings that were flagged as must-fix are all correctly resolved. No new issues introduced by the round 2 commits.

Minor (note only, not blocking):

The DecodeCounters doc comment (line 106) lists !txn|, !s3route| as Internal examples but doesn't mention !dist|. The list is intentionally illustrative rather than exhaustive, so this isn't wrong — but a one-line addendum (e.g. !txn|, !s3route|, !dist|) would keep the comment current with the table. Low friction; leave for the author to decide.


Summary

Round 2 is clean. All r1 must-fix items addressed correctly, the route table now covers every known persistent internal namespace, and the tests have real behavioral signal. Ready to merge.

Copy link
Copy Markdown

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

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: 67cce29e59

ℹ️ 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 thread internal/backup/decode.go
// Internal-only: transactional intents / locks / resolver
// records. Phase 0a drops these; they belong to the running
// cluster, not the data.
{[]byte("!txn|"), routeInternalDrop},
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 Classify writer-registry keys as internal

The dispatch table drops !txn|/!dist| as internal but leaves out the encryption writer-registry namespace, so !encryption|writers|... records fall through to Unknown. Those keys are legitimately persisted in Pebble by the encryption applier/store glue and are included in snapshots because writePebbleSnapshotEntries iterates all DB keys, so snapshots from clusters that have encryption registry rows will report false format-skew/corruption signals. Add an internal-drop route for the writer-registry prefix in this section.

Useful? React with 👍 / 👎.

PR #806 round 3 — addresses Codex r3 P2 on the !encryption|
writer-registry namespace.

`!encryption|writers|<be4 dek_id>|<be2 uint16 node_id>` rows
(internal/encryption/registry.go's WriterRegistryPrefix) are
persisted through the default Raft group's Pebble by the §4.1
encryption applier on every Bootstrap / Rotation / Registration
apply. They ride snapshots like any other 0x03 entry — so every
clustered snapshot from an encrypted-storage cluster carries
them.

Pre-fix, these rows hit no prefix route and landed in
Counters.Unknown — a false corruption signal on production
dumps the moment storage envelope encryption rolls out.

Fix: add `{[]byte("!encryption|"), routeInternalDrop}` to
buildPrefixRoutes(), mirroring the !txn| and !dist| entries.
The broader `!encryption|` prefix (not just `!encryption|writers|`)
covers any future encryption metadata under the same namespace
without needing a second route addition.

The descending-length sort preserves longest-match semantics so
no existing route is shadowed.

## Caller audit (semantic-change rule)

routeInternalDrop is a no-op that increments Counters.Internal.
The behavior change moves writer-registry entries from
Counters.Unknown → Counters.Internal. As with the !dist|
addition in r2, this preserves operator-facing semantics:
Counters.Unknown remains the corruption / version-skew alarm
bucket; Counters.Internal remains the intentional-drop bucket.
No production consumers of either counter exist yet (Phase 0a).

## Regression test

TestDecodeSnapshot_EncryptionPrefixDroppedAsInternal feeds one
`!encryption|writers|<be4 1>|<be2 0x42>` row and asserts
Counters.Internal == 1 / Counters.Unknown == 0.

## Verification

- go test -race ./internal/backup/... passes.
- golangci-lint clean.
@bootjp
Copy link
Copy Markdown
Owner Author

bootjp commented May 22, 2026

@claude review

Round 3 (commit 53469c33) — Codex r3 P2:

Route !encryption| writer-registry as Internal: internal/encryption/registry.go's WriterRegistryPrefix = []byte("!encryption|writers|") rows are persisted through the default Raft group's Pebble by every Bootstrap / Rotation / Registration apply. They ride snapshots like any 0x03 entry, so every clustered snapshot from an encrypted-storage cluster carries them. Pre-fix they hit no prefix route and landed in Counters.Unknown — false corruption signal the moment storage envelope encryption rolls out.

Used the broader !encryption| prefix (not just !encryption|writers|) so future encryption metadata under the same namespace is covered without another route addition.

Caller audit (semantic-change rule): routeInternalDrop is a no-op that increments Counters.Internal. As with the !dist| addition in r2, this preserves operator-facing semantics: Counters.Unknown remains the corruption / version-skew alarm; Counters.Internal remains the intentional-drop bucket. No production consumers of either counter exist yet (Phase 0a).

Regression test: TestDecodeSnapshot_EncryptionPrefixDroppedAsInternal feeds a real !encryption|writers|<be4 1>|<be2 0x42> row and asserts Counters.Internal == 1 / Counters.Unknown == 0.

go test -race ./internal/backup/... passes; golangci-lint clean.

@claude
Copy link
Copy Markdown

claude Bot commented May 22, 2026

Claude finished @bootjp's task in 2m 23s —— View job


Review of Round 3 fixes (commit 53469c33)

  • Read internal/backup/decode.go — verify !encryption| route addition
  • Read internal/backup/decode_test.go — verify regression test
  • Check internal/encryption/registry.go + applier.go — confirm prefix and persistence path
  • Scan for remaining undeclared internal namespaces
  • Post review findings

!encryption| route — verified correct ✓

Prefix and persistence path (decode.go:363–371)

internal/encryption/registry.go:21 defines WriterRegistryPrefix = []byte("!encryption|writers|") — the only !encryption|* prefix in the package. Persistence is confirmed: internal/encryption/applier.go:230–284 (ApplyRegistration) calls a.registry.SetRegistryRow(RegistryKey(p.DEKID, uint16(...)), val), writing durably to Pebble on every Bootstrap, Rotation, and Registration apply. These rows ride snapshots like any other Pebble key.

The broader !encryption| (13 bytes) covers !encryption|writers| (20 bytes) and any future sibling families under that namespace. Since the prefix table is sorted descending by length, a future longer entry (e.g. !encryption|audit|) will correctly shadow the broader match — TestPrefixRoutes_LongestMatchesFirst is already the enforcement mechanism. ✓

Regression test (decode_test.go:307–333)

TestDecodeSnapshot_EncryptionPrefixDroppedAsInternal constructs !encryption|writers|\x00\x00\x00\x01|\x00\x42 — a well-formed registry key for dek_id=1, node_id=0x42, matching the RegistryKey() byte layout at registry.go:60–65. It asserts Counters.Internal == 1 / Counters.Unknown == 0. The test is non-vacuous (an !alien|* key in the same slot would land in Unknown). ✓


Remaining namespace audit — no new gaps

After three rounds the coverage is:

Prefix Source Covered since
`!txn ` kv/txn_keys.go:13
`!s3route ` S3 internal
`!s3 gc upload
`!dist ` distribution/catalog.go:15–16
`!encryption ` internal/encryption/registry.go:21
`!ddb route table
`!sqs route `

HLC ceiling updates call hlc.SetPhysicalCeiling on the in-memory *HLC struct (kv/fsm.go:342); no Pebble key is written. No other persistent internal namespaces found.


Minor (carry-over from r2, still non-blocking)

decode.go:104–106 lists !txn|, !s3route| as illustrative Internal examples; the list now lags by two entries (!dist|, !encryption|). The comment is labeled e.g. and is intentionally non-exhaustive, so this doesn't affect correctness or operator behavior. Worth a one-liner update before Phase 1 if the example list grows further.


Summary

Round 3 is clean. All three rounds of findings are correctly resolved, the namespace coverage is complete against all known Pebble-persisted internal keys, and the regression test is genuine. Ready to merge.

Copy link
Copy Markdown

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

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: 53469c33a8

ℹ️ 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 thread internal/backup/decode.go
Comment on lines +193 to +194
scratch := opts.ScratchRoot
if scratch == "" {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Reject scratch root that aliases output root

When opts.ScratchRoot is provided, the dispatcher uses it verbatim for S3 without checking whether it aliases OutRoot; if a caller sets ScratchRoot == OutRoot, NewS3Encoder turns that into <out>/s3 and S3Encoder.Finalize later runs os.RemoveAll on that path, deleting the decoded S3 output tree after a successful run. This is a silent data-loss path triggered by a plausible CLI misconfiguration (--scratch-root equal to output), so DecodeSnapshot should fail fast on overlapping roots.

Useful? React with 👍 / 👎.

PR #806 round 4 — addresses Codex r3 P1.

The r1 fix only protected the *default* path (empty ScratchRoot
→ `<OutRoot>/.scratch`). An explicit `--scratch-root=$OUT_ROOT`
CLI invocation would still set scratchRoot to outRoot verbatim;
NewS3Encoder then turns that into `<outRoot>/s3` and
S3Encoder.Finalize's deferred os.RemoveAll wipes the final
output tree after a successful decode — a plausible
misconfiguration with no surfaceable failure mode.

Fix: in newDispatcher, after resolving the scratch path, fail
fast with ErrDecodeOptionsInvalid if
`filepath.Clean(scratch) == filepath.Clean(opts.OutRoot)`. Clean
normalises trailing slashes / `./` noise, so the two paths must
be cleaned-equal to trip the check (a sibling scratch dir like
`<OutRoot>/scratch` is fine; only true equality is rejected).

This is a fail-closed validation change at the DecodeSnapshot
boundary. Caller audit: DecodeSnapshot has no non-test callers
yet (Phase 0a — no CLI wrapper); future callers receive
ErrDecodeOptionsInvalid the same way they would for missing
OutRoot, so the contract is uniform.

The ScratchRoot field doc-comment now mentions the failure mode
so operators reading the API can spot the constraint without
running into the error at decode time.

## Regression test

TestDecodeSnapshot_RejectsScratchEqualsOutRoot covers two cases:
  - Exact equality (ScratchRoot == OutRoot)
  - Noisy equality (ScratchRoot == OutRoot + "/./") — confirms
    filepath.Clean normalises both before comparison

Both assert errors.Is(err, ErrDecodeOptionsInvalid).

## Verification

- go test -race ./internal/backup/... passes.
- golangci-lint clean.
@bootjp
Copy link
Copy Markdown
Owner Author

bootjp commented May 22, 2026

@claude review

Round 4 (commit b9fa4a7a) — Codex r3 P1:

Reject ScratchRoot == OutRoot: The r1 fix only protected the default path (empty ScratchRoot<OutRoot>/.scratch). An explicit --scratch-root=$OUT_ROOT CLI misconfig would still wipe the final output. Fail fast in newDispatcher with ErrDecodeOptionsInvalid when filepath.Clean(scratch) == filepath.Clean(opts.OutRoot).

filepath.Clean normalises trailing slashes / ./ noise so both --scratch-root=$OUT and --scratch-root=$OUT/./ are caught. Only true equality is rejected — sibling scratch dirs like <OutRoot>/scratch are fine.

Caller audit: This is a fail-closed validation change at the DecodeSnapshot boundary. DecodeSnapshot has no non-test callers yet (Phase 0a, no CLI wrapper); future callers receive ErrDecodeOptionsInvalid the same way they would for missing OutRoot, so the contract is uniform.

Doc: DecodeOptions.ScratchRoot field comment now mentions the rejection so operators reading the API can spot the constraint without hitting it at decode time.

Regression test: TestDecodeSnapshot_RejectsScratchEqualsOutRoot covers (a) exact equality and (b) noisy equality (OutRoot + "/./") — both assert errors.Is(err, ErrDecodeOptionsInvalid).

go test -race ./internal/backup/... passes; golangci-lint clean.

@claude
Copy link
Copy Markdown

claude Bot commented May 22, 2026

Claude finished @bootjp's task in 1m 38s —— View job


Review of Round 4 (commit b9fa4a7a)

  • Read internal/backup/decode.go — verify ScratchRoot == OutRoot rejection
  • Read internal/backup/decode_test.go — verify regression tests
  • Check for edge cases and remaining issues
  • Post review findings

Codex r3 P1 — ScratchRoot == OutRoot rejection correctly implemented ✓

Implementation (decode.go:209–220)

if filepath.Clean(scratch) == filepath.Clean(opts.OutRoot) {
    return nil, errors.Wrap(ErrDecodeOptionsInvalid,
        "ScratchRoot must not equal OutRoot: S3Encoder.Finalize "+
            "would os.RemoveAll the final <OutRoot>/s3/ tree")
}

The check fires after the default-scratch logic (scratch = filepath.Join(opts.OutRoot, ".scratch") at line 207) and before NewS3Encoder is called. Both orderings are correct:

  • The default path can never equal OutRoot (it always appends /.scratch), so the check has no false positives on the default branch.
  • Explicit --scratch-root=$OUT_ROOT is caught before any encoder is constructed, so no partial state is created before the error is returned. ✓

Scope is correctly limited to if opts.Adapters.S3 {

The check only runs when the S3 adapter is enabled. If ScratchRoot == OutRoot but S3 is disabled, no S3Encoder is constructed, Finalize is never called, and the path collision is harmless. Skipping the guard for non-S3 configurations is intentional and correct. ✓

filepath.Clean coverage

  • root vs root → equal, rejected ✓
  • root vs root + "/./" → both clean to root, rejected ✓
  • root vs root + "/scratch" → unequal, passes through ✓
  • root vs root + "/subdir/.." → both clean to root, rejected ✓ (path-traversal misconfig caught for free)

No false positives for sibling scratch dirs (<OutRoot>/scratch, <OutRoot>/../scratch, etc.).

errors.Is chaining

ErrDecodeOptionsInvalid is wrapped via github.com/cockroachdb/errors.Wrap in the implementation. The test uses stdlib errors.Is (import at decode_test.go:5). The cockroachdb errors package implements Unwrap() on its wrapped errors, so stdlib errors.Is traverses the chain correctly. Both test assertions at lines 42 and 52 are valid. ✓

Regression tests (decode_test.go:33–55)

TestDecodeSnapshot_RejectsScratchEqualsOutRoot:

  1. Exact equality (ScratchRoot: root) → ErrDecodeOptionsInvalid
  2. Noisy equality (ScratchRoot: root + "/./") → ErrDecodeOptionsInvalid

AdapterSet{S3: true} is the narrowest configuration that exercises the guard, confirming the check is not accidentally gated on other adapter flags.

DecodeOptions.ScratchRoot doc comment (decode.go:49–58)

Now accurately documents both the default behavior and the rejection contract:

DecodeSnapshot returns ErrDecodeOptionsInvalid if ScratchRoot cleaned-equals OutRoot

Matches the implementation exactly. The security warning in the comment is appropriately prominent. ✓


No remaining blocking issues

All five rounds of findings are correctly resolved. The validation contract is now uniform (ErrDecodeOptionsInvalid for missing OutRoot, for ScratchRoot == OutRoot, via errors.Wrap in both cases), the check is scoped to the only dangerous configuration (S3 enabled), and the tests are non-vacuous.

Carry-over note (non-blocking, cosmetic): decode.go:110 still lists only !txn|, !s3route| as illustrative Internal examples. With !dist| (r2) and !encryption| (r3) now in the table, the e.g. list is two entries stale. The comment is labeled e.g. and the accounting invariant on line 117 (Total = Tombstone + … + Internal + Unknown) remains correct — this is cosmetic only.

Ready to merge.

@bootjp bootjp merged commit 349b421 into main May 23, 2026
9 checks passed
@bootjp bootjp deleted the backup/snapshot-dispatcher branch May 23, 2026 03:11
bootjp added a commit that referenced this pull request May 23, 2026
Adds the Phase 0a operator-facing binary on top of the dispatcher
landed in PR #806. The CLI reads a .fsm snapshot, dispatches it
through DecodeSnapshot, and emits MANIFEST.json + CHECKSUMS into
the chosen output root.

- cmd/elastickv-snapshot-decode/main.go
  - flag surface: --input, --output, --adapter, --cluster-id,
    --include-incomplete-uploads, --include-orphans,
    --preserve-sqs-visibility, --include-sqs-side-records,
    --rename-collisions, --dynamodb-bundle-mode
  - testable run() entrypoint (argv slice in, error out;
    no os.Exit) so main_test.go can drive the binary directly
  - structured slog warning sink wired into the encoders
  - MANIFEST.json stamped with snapshot_index parsed from filename,
    last_commit_ts from the dispatcher header, exclusion flags
    from CLI, and a non-nil pointer to Adapter{} for every enabled
    adapter so the on-disk shape is already correct when per-scope
    enumeration lands
- cmd/elastickv-snapshot-decode/main_test.go
  - end-to-end synthetic Redis-string snapshot round-trips through
    run(), produces the expected blob file, manifest, and
    CHECKSUMS, and passes VerifyChecksums
  - flag-validation paths: missing --input, unknown adapter, zero
    adapter list, unparseable .fsm filename
  - parseAdapterSet table test
- internal/backup/checksums.go
  - WriteChecksums(root): walks root, computes sha256, writes a
    sha256sum(1)-compatible CHECKSUMS file (two-space separator,
    forward-slash paths, lowercase hex, lexicographic order,
    excludes CHECKSUMS itself)
  - VerifyChecksums(root): re-reads the file and surfaces the
    first mismatch as ErrChecksumMismatch (typed sentinel for the
    Phase 0b encoder self-test path)
- internal/backup/source.go
  - SnapshotIndexFromPath: parses the <uint64>.fsm filename
    convention the live writer emits and the CLI stamps into
    MANIFEST.snapshot_index; soft-fails for off-convention names
    via ErrSnapshotIndexUnparseable
- internal/backup/{checksums,source}_test.go
  - basic listing, self-exclusion, determinism across runs,
    happy-path verify, tamper detection
  - SnapshotIndexFromPath table test covering live-writer layout,
    hand-rolled names, max-uint64, missing suffix, empty stem,
    non-numeric, negative, hex stems

Self-review:
1. Data loss: no — CHECKSUMS is written last so a crash mid-dump
   leaves an unchecked but otherwise intact tree the operator
   can re-checksum; the MkdirAll on outputRoot is idempotent so
   an operator-supplied pre-existing directory is preserved.
2. Concurrency: single-threaded; main is sequential.
3. Performance: sha256 over each output file streams via io.Copy
   so multi-GB dump artifacts pass without buffering. CHECKSUMS
   walk is O(files).
4. Data consistency: MANIFEST.last_commit_ts comes from the
   dispatcher header (now surfaced via ReadSnapshotWithHeader so
   empty-snapshot dumps still populate it); snapshot_index
   parsed from the filename per the documented live-writer
   convention; encryption-state failures propagate from
   DecodeSnapshot as ErrSnapshotEncryptedEntry.
5. Test coverage: five CLI tests (golden path + four flag-
   validation surfaces); seven backup-package tests covering
   the new helpers; existing backup-package tests still pass.
bootjp added a commit that referenced this pull request May 23, 2026
…ning)

Two reviewer-flagged issues on the CHECKSUMS path. The Codex P1
("default ScratchRoot aliased OutRoot") was already fixed on main
by PR #806 follow-ups (c9f23c7 + b9fa4a7 — default to
<OutRoot>/.scratch + reject ScratchRoot==OutRoot), so this commit
ships only the remaining two findings.

1. (coderabbit critical, path traversal) VerifyChecksums joined
   CHECKSUMS relative paths to root without validating them, so a
   malicious CHECKSUMS line like `<hex>  ../../etc/shadow` would
   direct the verifier to fingerprint files outside the dump root.

   Fix: validateChecksumRelPath rejects absolute paths and any
   path whose filepath.Clean'd form starts with `..` or equals
   `..`/`.`/empty. Returns ErrChecksumsPathTraversal so callers
   can branch on errors.Is.

   Test: TestVerifyChecksums_RejectsTraversalPath — table-driven
   over five attack shapes (parent-traversal, deep-traversal,
   absolute, current-dir, empty); each is rejected before any
   sha256 is computed.

2. (gemini security-high + high, OOM vector) VerifyChecksums used
   os.ReadFile + strings.Split, materialising the whole CHECKSUMS
   in memory. A multi-million-file dump or an adversarial
   newline-free CHECKSUMS could OOM the verifier.

   Fix: switched to bufio.Scanner with a fixed 8 KiB per-line
   budget. Streaming throughout — even with a TB of dump
   artefacts the verifier holds at most one line plus the
   in-flight sha256 hash state.

   Test: TestVerifyChecksums_StreamsLargeFile exercises the
   scanner path with 64 files; CI cost of a literal-large
   fixture is not justified vs. the assertion that the streaming
   API works at all.

Caller audit (CLAUDE.md "semantic-change → grep all callers"):

- VerifyChecksums: zero production callers. Test-only consumers.
  Path-validation is rejection-side tightening; honest CHECKSUMS
  unaffected. Streaming semantics unobservable to callers.
- WriteChecksums (unchanged in this commit) has one caller
  (cmd/elastickv-snapshot-decode/main.go:252) — still passes
  through.

Self-review:
1. Data loss — no regression (S3 scratch fix already on main).
2. Concurrency — none.
3. Performance — streaming verify is strictly better.
4. Data consistency — path traversal closed.
5. Test coverage — two regression tests, one per finding.
bootjp added a commit that referenced this pull request May 24, 2026
## Summary

Adds the Phase 0a operator-facing binary on top of the dispatcher in
#806. Plus the CHECKSUMS writer / verifier and the `<uint64>.fsm`
filename → snapshot-index parser the CLI needs to stamp MANIFEST.json.

Stacked on `backup/snapshot-dispatcher` — once #806 merges, this PR's
diff collapses to just the CLI + helpers and I'll retarget to `main`.

## What lands

| File | Purpose |
|---|---|
| `cmd/elastickv-snapshot-decode/main.go` | Flag parsing,
`DecodeSnapshot` invocation, `MANIFEST.json` + `CHECKSUMS` emission |
| `cmd/elastickv-snapshot-decode/main_test.go` | End-to-end
synthetic-snapshot golden path + four flag-validation paths +
`parseAdapterSet` table test |
| `internal/backup/checksums.go` | `WriteChecksums(root)` /
`VerifyChecksums(root)` — sha256sum(1)-compatible |
| `internal/backup/source.go` | `SnapshotIndexFromPath` — `<uint64>.fsm`
filename parser used to stamp `MANIFEST.snapshot_index` |
| `internal/backup/checksums_test.go`, `source_test.go` | Pin file
shape, deterministic ordering, tamper detection, parse table |

## CLI surface

```
elastickv-snapshot-decode \
  --input  <fsm-file>          (required)
  --output <directory-root>    (required)
  [--adapter dynamodb,s3,redis,sqs|all]
  [--cluster-id <id>]
  [--include-incomplete-uploads]
  [--include-orphans]
  [--preserve-sqs-visibility]
  [--include-sqs-side-records]
  [--rename-collisions]
  [--dynamodb-bundle-mode per-item|jsonl]
```

Matches the design's §"Tooling" decoder section, minus the deferred
`--input-snap-dir`, `--scope`, and `--dynamodb-bundle-size` flags
(called out in the design as later additions).

## Output shape

`MANIFEST.json` carries `phase = "phase0-snapshot-decode"`, the
filename-derived `snapshot_index`, the dispatcher-surfaced
`last_commit_ts`, exclusion flags from the CLI, and a non-nil
`Adapter{}` pointer for every enabled adapter (per-scope enumeration is
a follow-up).

`CHECKSUMS` is the standard `sha256sum --binary` shape — verifiable with
`sha256sum -c CHECKSUMS` from the dump root, no elastickv binary
involved.

## Test plan

- [x] `go test -race ./internal/backup/
./cmd/elastickv-snapshot-decode/` (passes)
- [x] `golangci-lint run ./...` over the touched packages (clean — 0
issues)
- [x] `go build ./...` (clean)
- [x] Golden-path test: synthetic `!redis|str|greeting` →
`redis/db_0/strings/greeting.bin` + `MANIFEST.json` + `CHECKSUMS` +
`VerifyChecksums` round-trip

## Self-review (CLAUDE.md §"Self-review of code changes")

1. **Data loss** — CHECKSUMS is written last so a crash mid-dump leaves
an intact-but-unchecked tree; `MkdirAll(outputRoot)` is idempotent.
2. **Concurrency** — single-threaded; no shared state across goroutines.
3. **Performance** — sha256 streams via `io.Copy`; multi-GB files pass
without buffering. CHECKSUMS walk is O(files).
4. **Data consistency** — `MANIFEST.last_commit_ts` comes from the
dispatcher header (now surfaced via `ReadSnapshotWithHeader` so
empty-snapshot dumps still populate it); `snapshot_index` parsed from
the filename per the documented live-writer convention; encryption-state
failures propagate from `DecodeSnapshot` as `ErrSnapshotEncryptedEntry`.
5. **Test coverage** — five CLI tests (golden path + four
flag-validation paths); seven backup-package tests for the new helpers;
existing backup-package tests still pass.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* New CLI to decode local snapshots into vendor-independent directory
trees with adapter selection, output options, and manifest/CHECKSUMS
generation
* CHECKSUMS written in sha256sum-compatible format for integrity
verification; manifest includes version, cluster metadata and parsed
snapshot index when available

* **Bug Fixes / Security**
* CHECKSUMS verification rejects path-traversal and symlink escape
attempts

* **Tests**
* Comprehensive tests covering decoding, manifesting, checksums,
parsing, and security regressions

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/bootjp/elastickv/pull/810?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack)

<!-- review_stack_entry_end -->
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant