Skip to content

Commit 72fb54d

Browse files
committed
backup: #904 v8 - fail-closed on DynamoDB JSONL + fix stale godoc
Two findings on v7. ## Codex P2 v7: silent data loss on DynamoDB JSONL inputs The decoder writes items/data-*.jsonl when --dynamodb-bundle-mode jsonl is set, and the manifest records this as `dynamodb_layout: "jsonl"`. The reverse encoder, however, only walks per-item files (items/*.json, items/*/*.json) and silently skips every JSONL file. Result: a JSONL dump round-trips through the encoder as an .fsm with ONLY table metadata and ZERO items — a "successful" empty restore artifact. Fix: - New ErrEncodeUnsupportedDynamoDBLayout sentinel. - EncodeOptions gains DynamoDBBundleJSONL bool. validateEncodeOptions rejects when DynamoDBBundleJSONL && Adapters.DynamoDB. - CLI's buildEncodeOptions sets the field from manifest.DynamoDBLayout == DynamoDBLayoutJSONL. - CLI's run() maps the error to exit-2 (data-correctness, like the HLC ceiling regression). Guard fires only when DDB is in scope so a caller encoding ONLY Redis/SQS/S3 with the flag accidentally set is unaffected; pinned by TestEncodeSnapshotJSONLOnlyRejectedWhenDDBEnabled. Rejection path pinned by TestEncodeSnapshotRejectsDynamoDBJSONLLayout. When the encoder learns the JSONL layout (future milestone), this field switches from a fail-closed guard to a layout selector. ## Claude v7 doc bug: ErrSelfTestLowerLastCommitTS comment is stale The var comment at lines 17-22 was accurate after v4's doc fix ("EncodeSnapshot itself does NOT enforce this floor") but v7 added validateEncodeOptions which DOES enforce it when ManifestLastCommitTS > 0. Comment now describes both layers (CLI + library) and points at the codex P2 v2 plus claude v3 history. ## Caller audit per CLAUDE.md semantic-change rule - EncodeOptions gained DynamoDBBundleJSONL bool. CLI sets it from manifest; existing same-package tests pass EncodeOptions{} so the zero default (false) preserves their behavior. No legitimate caller impacted. - validateEncodeOptions split into validateEncodeOptions + validateEncodeOptionsData to keep both under the cyclop threshold. Pure refactor: identical control flow, same errors returned. - run() gained a third errors.Is branch for the new sentinel; same exit-2 mapping pattern as the prior two. Tests + lint green.
1 parent 9f69295 commit 72fb54d

3 files changed

Lines changed: 110 additions & 22 deletions

File tree

cmd/elastickv-snapshot-encode/main.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,9 @@ func run(argv []string, logger *slog.Logger) (int, error) {
8787
if errors.Is(err, backup.ErrSelfTestLowerLastCommitTS) {
8888
return exitDataErr, err
8989
}
90+
if errors.Is(err, backup.ErrEncodeUnsupportedDynamoDBLayout) {
91+
return exitDataErr, err
92+
}
9093
if errors.Is(err, errSelfTestMismatch) {
9194
return exitDataErr, err
9295
}
@@ -294,6 +297,7 @@ func buildEncodeOptions(cfg *config, effectiveTS uint64, manifest backup.Manifes
294297
Adapters: cfg.adapters,
295298
LastCommitTS: effectiveTS,
296299
ManifestLastCommitTS: manifest.LastCommitTS,
300+
DynamoDBBundleJSONL: manifest.DynamoDBLayout == backup.DynamoDBLayoutJSONL,
297301
SelfTest: cfg.selfTest,
298302
}
299303
if cfg.selfTest {

internal/backup/encode_snapshot.go

Lines changed: 51 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -14,20 +14,33 @@ import (
1414
"github.com/cockroachdb/errors"
1515
)
1616

17-
// ErrSelfTestLowerLastCommitTS is the sentinel callers should return
18-
// when the operator-supplied T is below the manifest's last_commit_ts.
19-
// The HLC ceiling invariant (CLAUDE.md "Timestamp Oracle") forbids
20-
// lowering the ceiling on restore: a lower T would let a post-restart
21-
// leader issue a read ts ≤ a restored row's commit ts.
17+
// ErrSelfTestLowerLastCommitTS is returned when the operator-supplied
18+
// T is below the manifest's last_commit_ts. The HLC ceiling invariant
19+
// (CLAUDE.md "Timestamp Oracle") forbids lowering the ceiling on
20+
// restore: a lower T would let a post-restart leader issue a read
21+
// ts ≤ a restored row's commit ts.
2222
//
23-
// EncodeSnapshot itself does NOT read MANIFEST.json or enforce this
24-
// floor — the comparison happens in the CLI's resolveLastCommitTS
25-
// BEFORE EncodeSnapshot is called, and a future library caller (Phase 1
26-
// live extractor, integration tests) must perform its own comparison
27-
// and return this sentinel on regression so callers can errors.Is on
28-
// it to map to the right exit code (claude v3 doc bug #904).
23+
// Enforced at two layers:
24+
// - CLI (`resolveLastCommitTS`) rejects --last-commit-ts T < manifest
25+
// before EncodeSnapshot is called (exit code 2).
26+
// - Library (`validateEncodeOptions`) rejects when the caller threads
27+
// `opts.ManifestLastCommitTS > 0` and `opts.LastCommitTS` is below
28+
// it — defense-in-depth for in-process callers (Phase 1 live
29+
// extractor, integration tests) that bypass the CLI.
30+
//
31+
// Callers can errors.Is on this sentinel to map to the right exit code
32+
// (claude v3 doc bug #904 + claude v7 doc bug #904 + codex P2 v2 #904).
2933
var ErrSelfTestLowerLastCommitTS = errors.New("backup: --last-commit-ts T < manifest.last_commit_ts (HLC ceiling regression)")
3034

35+
// ErrEncodeUnsupportedDynamoDBLayout is returned when an input dump
36+
// declares `dynamodb_layout: "jsonl"` in MANIFEST.json. The DynamoDB
37+
// reverse encoder only walks per-item files (items/*.json,
38+
// items/*/*.json) and would silently skip every items/data-*.jsonl
39+
// file, producing an .fsm with only table metadata and no items —
40+
// a silent-data-loss restore artifact (codex P2 v7 #904). Fail closed
41+
// until the encoder learns the JSONL layout (M7 / future milestone).
42+
var ErrEncodeUnsupportedDynamoDBLayout = errors.New("backup: DynamoDB JSONL layout not supported by encoder")
43+
3144
// The encoder dispatch order (redis → dynamodb → s3 → sqs) is encoded
3245
// inside adapterRunners() and is intentionally distinct from decode.go's
3346
// finalize order (dynamodb → s3 → redis → sqs). The final .fsm byte
@@ -53,6 +66,15 @@ type EncodeOptions struct {
5366
// header and every key's invTS = ^T. Callers pass manifest.last_commit_ts
5467
// by default and the --last-commit-ts override otherwise.
5568
LastCommitTS uint64
69+
// DynamoDBBundleJSONL is true when the input dump's MANIFEST.json
70+
// has `dynamodb_layout: "jsonl"`. The reverse encoder does not
71+
// support that layout — it would silently skip every
72+
// items/data-*.jsonl file and publish an .fsm with only table
73+
// metadata. Fail-closed via ErrEncodeUnsupportedDynamoDBLayout
74+
// when true (codex P2 v7 #904). When the encoder gains JSONL
75+
// support, this field will switch from a guard to a control.
76+
DynamoDBBundleJSONL bool
77+
5678
// ManifestLastCommitTS is the floor LastCommitTS must not fall
5779
// below. When > 0, EncodeSnapshot fails-closed with
5880
// ErrSelfTestLowerLastCommitTS if LastCommitTS < ManifestLastCommitTS.
@@ -155,9 +177,10 @@ type EncodeResult struct {
155177
// test fixtures without a manifest reference can still call this
156178
// directly (codex P2 v2 #904).
157179
// validateEncodeOptions enforces the four pre-encode invariants:
158-
// InputRoot/out non-nil, non-empty adapter selection, and the optional
159-
// manifest-TS floor. Split out so EncodeSnapshot stays under the cyclop
160-
// threshold.
180+
// InputRoot/out non-nil, non-empty adapter selection, optional
181+
// manifest-TS floor, and DDB JSONL guard. Split out so EncodeSnapshot
182+
// stays under the cyclop threshold; per-check helpers below keep each
183+
// branch's intent narrow.
161184
func validateEncodeOptions(opts EncodeOptions, out io.Writer) error {
162185
if opts.InputRoot == "" {
163186
return errors.New("backup: EncodeOptions.InputRoot is required")
@@ -167,21 +190,27 @@ func validateEncodeOptions(opts EncodeOptions, out io.Writer) error {
167190
}
168191
if !opts.Adapters.DynamoDB && !opts.Adapters.S3 && !opts.Adapters.Redis && !opts.Adapters.SQS {
169192
// Zero AdapterSet would silently produce a header-only .fsm —
170-
// a "successful" empty restore artifact. CLI's parseAdapterSet
171-
// rejects this for flag-driven entry; library callers (Phase 1
172-
// live extractor, integration tests) get the same guard here
173-
// (codex v5 + claude v5 #904).
193+
// a "successful" empty restore artifact (codex v5 + claude v5 #904).
174194
return errors.New("backup: EncodeOptions.Adapters has no enabled adapter")
175195
}
196+
return validateEncodeOptionsData(opts)
197+
}
198+
199+
// validateEncodeOptionsData covers the data-correctness pre-conditions:
200+
// HLC ceiling floor and DynamoDB JSONL guard. Kept separate from the
201+
// nil/empty-args checks so each function stays cyclop-clean.
202+
func validateEncodeOptionsData(opts EncodeOptions) error {
176203
if opts.ManifestLastCommitTS > 0 && opts.LastCommitTS < opts.ManifestLastCommitTS {
177-
// Defense-in-depth HLC ceiling floor (codex P2 v2 #904). The
178-
// CLI's resolveLastCommitTS already enforces this for flag-
179-
// driven entry; library callers that thread the manifest value
180-
// via ManifestLastCommitTS get the same fail-closed guard here.
204+
// Defense-in-depth HLC ceiling floor (codex P2 v2 #904).
181205
return errors.Wrapf(ErrSelfTestLowerLastCommitTS,
182206
"EncodeSnapshot opts.LastCommitTS %d < opts.ManifestLastCommitTS %d",
183207
opts.LastCommitTS, opts.ManifestLastCommitTS)
184208
}
209+
if opts.DynamoDBBundleJSONL && opts.Adapters.DynamoDB {
210+
// The DynamoDB reverse encoder only walks per-item files;
211+
// JSONL items would be silently skipped (codex P2 v7 #904).
212+
return errors.WithStack(ErrEncodeUnsupportedDynamoDBLayout)
213+
}
185214
return nil
186215
}
187216

internal/backup/encode_snapshot_test.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,61 @@ func TestEncodeSnapshotManifestFloorOptOut(t *testing.T) {
263263
}
264264
}
265265

266+
// TestEncodeSnapshotRejectsDynamoDBJSONLLayout pins codex P2 v7 #904:
267+
// the DynamoDB reverse encoder does not support the JSONL bundle
268+
// layout, so a caller that threads DynamoDBBundleJSONL=true must be
269+
// rejected with ErrEncodeUnsupportedDynamoDBLayout before any bytes
270+
// are written. The CLI hits this path automatically when MANIFEST.json
271+
// has `dynamodb_layout: "jsonl"`; library callers that mirror that
272+
// thread the field themselves.
273+
func TestEncodeSnapshotRejectsDynamoDBJSONLLayout(t *testing.T) {
274+
t.Parallel()
275+
in := t.TempDir()
276+
var buf bytes.Buffer
277+
_, err := EncodeSnapshot(EncodeOptions{
278+
InputRoot: in,
279+
Adapters: AdapterSet{DynamoDB: true},
280+
LastCommitTS: 1,
281+
DynamoDBBundleJSONL: true,
282+
}, &buf)
283+
if err == nil {
284+
t.Fatalf("EncodeSnapshot with DynamoDBBundleJSONL accepted; want error")
285+
}
286+
if !errors.Is(err, ErrEncodeUnsupportedDynamoDBLayout) {
287+
t.Errorf("err = %v, want errors.Is ErrEncodeUnsupportedDynamoDBLayout", err)
288+
}
289+
if buf.Len() != 0 {
290+
t.Errorf("buf.Len = %d, want 0 (no bytes should be written when JSONL is rejected)", buf.Len())
291+
}
292+
}
293+
294+
// TestEncodeSnapshotJSONLOnlyRejectedWhenDDBEnabled pins that the JSONL
295+
// guard fires only when DynamoDB is in the adapter set — a caller that
296+
// happens to set DynamoDBBundleJSONL=true while encoding ONLY Redis (or
297+
// any other adapter) is unaffected. Prevents the guard from becoming
298+
// over-zealous for callers who simply mirror the manifest field.
299+
func TestEncodeSnapshotJSONLOnlyRejectedWhenDDBEnabled(t *testing.T) {
300+
t.Parallel()
301+
in := t.TempDir()
302+
const queue = "no-ddb"
303+
writeSQSQueue(t, in, queue,
304+
[]byte(`{"format_version":1,"name":"no-ddb","fifo":false,"partition_count":1,"generation":1}`),
305+
[][]byte{
306+
[]byte(`{"format_version":1,"message_id":"m1","body":"a","send_timestamp_millis":1700000000000,"available_at_millis":1700000000000,"sequence_number":0}`),
307+
},
308+
)
309+
var buf bytes.Buffer
310+
_, err := EncodeSnapshot(EncodeOptions{
311+
InputRoot: in,
312+
Adapters: AdapterSet{SQS: true}, // DDB NOT in scope
313+
LastCommitTS: 1,
314+
DynamoDBBundleJSONL: true, // would be rejected if DDB were enabled
315+
}, &buf)
316+
if err != nil {
317+
t.Fatalf("EncodeSnapshot rejected JSONL flag when DDB not in scope: %v", err)
318+
}
319+
}
320+
266321
// TestEncodeSnapshotRejectsZeroAdapterSet pins claude v5 + codex v5
267322
// carry-over: a library caller that forgets to thread Adapters into
268323
// EncodeOptions gets a fail-closed error rather than a silently empty

0 commit comments

Comments
 (0)