Skip to content

Commit b52a688

Browse files
committed
backup: #904 v16 - correct v14 Redis multi-DB fan-out (codex P2 v14)
v14 added an enumerate-and-fan-out path for redis/db_<N>/ to address codex P1 v13's silent-data-loss concern. Codex's v14 follow-up clarified that the fan-out is itself incorrect, and two P2 findings landed (L452 + L427). ## Codex P2 v14 L452: Reject Redis DB fan-out until DBs are encoded The Redis MVCC key prefixes (!redis|str|, !redis|hll|, !redis|ttl|, plus the collection helpers' analogues) carry NO database component. Feeding two distinct DBs into the same snapshotBuilder would: - collide on same-named keys across DBs (b.Add returns ErrDuplicate), or - merge keys under db_0 at decode time (DecodeOptions.RedisDBIndex defaults to 0, so a db_3-only self-test would decode under db_0 and the structural diff would fail). In either case the produced .fsm is mis-scoped: the v14 fan-out replaces silent-data-loss with silent-merge-or-collide. Fix: encodeAllRedisDBs now enumerates and decides: - 0 indices (no redis/, or empty redis/) → no-op. - [0] only → proceed with NewRedisEncoder(_, 0) (the legacy path). - anything else → fail closed with new sentinel ErrRedisEncodeMultiDBUnsupported. The sentinel is marked at the runAdapterEncoders boundary with ErrEncodeAdapterData so the CLI routes it to exit-2. Until Phase 1 makes native keys DB-aware, multi-DB inputs are quarantined as data-correctness failures rather than silently mis-scoped output. ## Codex P2 v14 L427: Fail closed on malformed Redis db_N entries The v14 parseRedisDBDir(ent) returned (_, false) whenever !ent.IsDir(), so a regular file or symlink at redis/db_0 would be silently skipped and the encoder would publish a header-only .fsm. Fix: split into parseRedisDBName (pure name parser) and shift the ent.IsDir() check into enumerateRedisDBs. When the name matches the canonical db_<N> pattern AND the entry is not a directory, enumerateRedisDBs returns ErrRedisEncodeNotDir (mirroring the per-DB encoder's existing Lstat guard). ## Pinned by - TestEncodeSnapshotRedisRejectsNonZeroDB: redis/db_3-only fixture through EncodeSnapshot → errors.Is ErrRedisEncodeMultiDBUnsupported AND errors.Is ErrEncodeAdapterData (both fire). - TestEncodeSnapshotRedisRejectsMultipleDBs: redis/db_0 + redis/db_3 → errors.Is ErrRedisEncodeMultiDBUnsupported. - TestEnumerateRedisDBsRejectsNonDirDBEntry: redis/db_2 as a regular file → errors.Is ErrRedisEncodeNotDir. - The replaced TestEncodeSnapshotRedisMultiDB (v14) was reformulated as TestEncodeSnapshotRedisRejectsNonZeroDB; the prior "more bytes than baseline" assertion no longer holds because the encoder rejects the fixture upfront. ## Caller audit per CLAUDE.md semantic-change rule - encodeAllRedisDBs: sole caller is adapterRunners.redis. Semantic change: was "fan out across all db_<N>"; now "encode db_0 only, fail closed on anything else." Error contract: - errors.Is(err, ErrEncodeAdapterData) — still true (mark applied at runAdapterEncoders, unchanged). - errors.Is(err, ErrRedisEncodeMultiDBUnsupported) — new path. - errors.Is(err, ErrRedisEncodeNotDir) — newly reachable via enumerateRedisDBs for malformed db_<N> entries. All in-tree NewRedisEncoder direct callers (encode_redis_test.go, encode_redis_coll_test.go, encode_redis_hardlink_unix_test.go) are unaffected — they bypass the adapter runner. - enumerateRedisDBs: was "silently skip non-dir db_<N>"; now "fail closed with ErrRedisEncodeNotDir." No production callers exist outside encodeAllRedisDBs; tests in encode_snapshot_test.go cover the new fail-closed path. - parseRedisDBDir → parseRedisDBName: signature change (DirEntry → string). Sole caller is enumerateRedisDBs (updated in lock-step). - v14's TestEncodeSnapshotRedisMultiDB is replaced — see above. TestEnumerateRedisDBsMixedEntries (v14) continues to pass: its positive entries (db_0, db_1, db_5) are all directories, so the IsDir check it never exercises remains exercised by the new TestEnumerateRedisDBsRejectsNonDirDBEntry. Tests + lint green.
1 parent af8c279 commit b52a688

2 files changed

Lines changed: 135 additions & 50 deletions

File tree

internal/backup/encode_snapshot.go

Lines changed: 66 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,22 @@ var ErrSelfTestLowerLastCommitTS = errors.New("backup: --last-commit-ts T < mani
4141
// until the encoder learns the JSONL layout (M7 / future milestone).
4242
var ErrEncodeUnsupportedDynamoDBLayout = errors.New("backup: DynamoDB JSONL layout not supported by encoder")
4343

44+
// ErrRedisEncodeMultiDBUnsupported is returned when the input tree
45+
// contains a Redis db_<N>/ for any N != 0, or contains multiple
46+
// db_<N> directories. The current Redis MVCC key prefixes
47+
// (!redis|str|, !redis|hll|, !redis|ttl|, …) carry NO database
48+
// component, so feeding two distinct DBs into the same snapshot
49+
// builder would either collide on same-named keys or silently merge
50+
// both DBs under db_0 on restore (DecodeOptions.RedisDBIndex
51+
// defaults to 0). Failing closed preserves correctness until Phase 1
52+
// makes the native keys DB-aware (codex P2 v14 #904).
53+
//
54+
// v14 originally fanned out across db_<N> to address codex P1 v13's
55+
// silent-data-loss concern; codex's v14 follow-up clarified that
56+
// fan-out under the current key format produces mis-scoped output.
57+
// The corrected fix replaces fan-out with fail-closed.
58+
var ErrRedisEncodeMultiDBUnsupported = errors.New("backup: redis encoder requires single db_0 (multi-DB or non-zero DB not yet supported)")
59+
4460
// ErrEncodeAdapterData marks every error returned by an adapter
4561
// encoder (Redis / DynamoDB / S3 / SQS) so callers can distinguish
4662
// "the input tree contained content the encoder cannot translate"
@@ -387,9 +403,20 @@ func enumerateRedisDBs(inRoot string) ([]int, error) {
387403
}
388404
var indices []int
389405
for _, ent := range entries {
390-
if idx, ok := parseRedisDBDir(ent); ok {
391-
indices = append(indices, idx)
406+
idx, ok := parseRedisDBName(ent.Name())
407+
if !ok {
408+
continue
392409
}
410+
// Canonical db_<N> name; entry MUST be a directory.
411+
// Silently skipping a regular file or symlink at
412+
// redis/db_<N> would let a malformed dump publish a
413+
// header-only/partial FSM (codex P2 v14 #904 L427).
414+
if !ent.IsDir() {
415+
return nil, errors.Wrapf(ErrRedisEncodeNotDir,
416+
"redis/%s exists but is not a directory (mode=%s)",
417+
ent.Name(), ent.Type())
418+
}
419+
indices = append(indices, idx)
393420
}
394421
sort.Ints(indices)
395422
return indices, nil
@@ -416,17 +443,19 @@ func checkRedisRoot(redisDir string) error {
416443
return nil
417444
}
418445

419-
// parseRedisDBDir returns (dbIndex, true) when ent names a canonical
420-
// db_<N> directory (N is a non-negative decimal with no leading zeros).
421-
// Non-matching entries return (0, false) so the caller can skip without
422-
// erroring — they cannot have been produced by the canonical decoder.
423-
// Reject non-canonical decimals so a hypothetical Phase 1 dumper cannot
424-
// double-emit the same db under two distinct directory names.
425-
func parseRedisDBDir(ent os.DirEntry) (int, bool) {
426-
if !ent.IsDir() {
427-
return 0, false
428-
}
429-
name := ent.Name()
446+
// parseRedisDBName returns (dbIndex, true) when name matches the
447+
// canonical db_<N> pattern (N is a non-negative decimal with no
448+
// leading zeros). Non-matching names return (0, false) so the caller
449+
// can skip them without erroring — they cannot have been produced by
450+
// the canonical decoder. Reject non-canonical decimals so a
451+
// hypothetical Phase 1 dumper cannot double-emit the same db under
452+
// two distinct directory names.
453+
//
454+
// This is a pure name parser; the caller is responsible for
455+
// validating the directory-entry shape (codex P2 v14 #904 L427
456+
// shifted the IsDir check to enumerateRedisDBs so a regular file
457+
// at redis/db_<N> fails closed instead of being silently skipped).
458+
func parseRedisDBName(name string) (int, bool) {
430459
if !strings.HasPrefix(name, redisDBDirPrefix) {
431460
return 0, false
432461
}
@@ -438,20 +467,35 @@ func parseRedisDBDir(ent os.DirEntry) (int, bool) {
438467
return idx, true
439468
}
440469

441-
// encodeAllRedisDBs invokes NewRedisEncoder per discovered db_<N>
442-
// directory in ascending index order. A missing redis/ directory is a
443-
// no-op. Codex P1 v13 #904: replaces the prior hardcoded db_0 fan-out
444-
// which would silently drop non-default DBs from any Phase 1 multi-DB
445-
// dump. Per-DB errors are wrapped with the db index for traceability.
470+
// encodeAllRedisDBs invokes NewRedisEncoder for redis/db_0/ when the
471+
// input tree has exactly that DB (or no Redis content at all). A
472+
// missing redis/ directory is a no-op. Any non-zero DB or the
473+
// presence of multiple db_<N> directories fails closed with
474+
// ErrRedisEncodeMultiDBUnsupported.
475+
//
476+
// Codex P1 v13 #904 originally asked for a per-DB fan-out to address
477+
// the prior hardcoded db_0 dispatch silently dropping non-default
478+
// DBs. Codex P2 v14 #904 (L452) clarified that fan-out under the
479+
// current MVCC key prefixes (!redis|str|, !redis|hll|, !redis|ttl|,
480+
// …, none of which carry a database component) would either collide
481+
// on same-named keys across DBs or merge everything under db_0 at
482+
// decode time. The corrected fix replaces the silent drop and the
483+
// incorrect fan-out with a fail-closed sentinel until Phase 1
484+
// makes the native keys DB-aware.
446485
func encodeAllRedisDBs(b *snapshotBuilder, inRoot string) error {
447486
indices, err := enumerateRedisDBs(inRoot)
448487
if err != nil {
449488
return errors.Wrap(err, "redis encoder enumerate")
450489
}
451-
for _, idx := range indices {
452-
if err := NewRedisEncoder(inRoot, idx).Encode(b); err != nil {
453-
return errors.Wrapf(err, "redis encoder db_%d", idx)
454-
}
490+
if len(indices) == 0 {
491+
return nil
492+
}
493+
if len(indices) > 1 || indices[0] != 0 {
494+
return errors.Wrapf(ErrRedisEncodeMultiDBUnsupported,
495+
"redis encoder enumerated db indices %v", indices)
496+
}
497+
if err := NewRedisEncoder(inRoot, 0).Encode(b); err != nil {
498+
return errors.Wrap(err, "redis encoder db_0")
455499
}
456500
return nil
457501
}

internal/backup/encode_snapshot_test.go

Lines changed: 69 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -459,30 +459,20 @@ func TestEnumerateRedisDBsRedisIsRegularFile(t *testing.T) {
459459
}
460460
}
461461

462-
// TestEncodeSnapshotRedisMultiDB pins codex P1 v13 #904: the Redis
463-
// fan-out in adapterRunners enumerates redis/db_<N>/ and invokes the
464-
// per-DB encoder for each. The fixture places a single string under
465-
// redis/db_3/ ONLY (no db_0). Pre-fix, the encoder hardcoded
466-
// NewRedisEncoder(root, 0) and produced a header-only .fsm for this
467-
// input — silent data loss. Post-fix, the db_3 string is included.
462+
// TestEncodeSnapshotRedisRejectsNonZeroDB pins codex P2 v14 #904
463+
// L452: the Redis MVCC key prefixes (!redis|str|, !redis|hll|,
464+
// !redis|ttl|, …) carry no database component, so feeding a
465+
// non-zero DB through the encoder would mis-scope the produced
466+
// .fsm — same-named keys collide and a db_3-only self-test would
467+
// decode under db_0. Until Phase 1 makes native keys DB-aware,
468+
// non-zero-DB inputs MUST fail closed.
468469
//
469-
// Assertion is content-free: compare encoded byte count against an
470-
// empty-redis baseline. With multi-DB fan-out, the db_3 fixture
471-
// produces MORE bytes than an empty tree. Without it, both encodes
472-
// would produce identical header-only output.
473-
func TestEncodeSnapshotRedisMultiDB(t *testing.T) {
470+
// The fixture places a single string under redis/db_3/ ONLY.
471+
// EncodeSnapshot must reject with ErrRedisEncodeMultiDBUnsupported
472+
// and write no bytes. (v14 originally attempted to fan out per DB;
473+
// codex's L452 follow-up established the correct fix is fail-closed.)
474+
func TestEncodeSnapshotRedisRejectsNonZeroDB(t *testing.T) {
474475
t.Parallel()
475-
emptyIn := t.TempDir()
476-
var emptyBuf bytes.Buffer
477-
emptyResult, err := EncodeSnapshot(EncodeOptions{
478-
InputRoot: emptyIn,
479-
Adapters: AdapterSet{Redis: true},
480-
LastCommitTS: 1,
481-
}, &emptyBuf)
482-
if err != nil {
483-
t.Fatalf("EncodeSnapshot empty: %v", err)
484-
}
485-
486476
in := t.TempDir()
487477
encKey := EncodeSegment([]byte("k3"))
488478
db3Strings := filepath.Join(in, "redis", "db_3", "strings")
@@ -493,17 +483,68 @@ func TestEncodeSnapshotRedisMultiDB(t *testing.T) {
493483
t.Fatalf("WriteFile db_3 string: %v", err)
494484
}
495485
var buf bytes.Buffer
496-
result, err := EncodeSnapshot(EncodeOptions{
486+
_, err := EncodeSnapshot(EncodeOptions{
497487
InputRoot: in,
498488
Adapters: AdapterSet{Redis: true},
499489
LastCommitTS: 1,
500490
}, &buf)
501-
if err != nil {
502-
t.Fatalf("EncodeSnapshot db_3-only: %v", err)
491+
if err == nil {
492+
t.Fatalf("EncodeSnapshot accepted db_3-only Redis input; want ErrRedisEncodeMultiDBUnsupported")
493+
}
494+
if !errors.Is(err, ErrRedisEncodeMultiDBUnsupported) {
495+
t.Errorf("err = %v, want errors.Is ErrRedisEncodeMultiDBUnsupported", err)
496+
}
497+
// Marked as adapter-data so the CLI routes it to exit-2.
498+
if !errors.Is(err, ErrEncodeAdapterData) {
499+
t.Errorf("err = %v, want errors.Is ErrEncodeAdapterData (mark from runAdapterEncoders)", err)
500+
}
501+
}
502+
503+
// TestEncodeSnapshotRedisRejectsMultipleDBs pins the multi-DB case:
504+
// redis/db_0 + redis/db_3 → ErrRedisEncodeMultiDBUnsupported (the
505+
// fan-out would collide on same-named keys or merge both DBs under
506+
// db_0 on restore; codex P2 v14 #904 L452).
507+
func TestEncodeSnapshotRedisRejectsMultipleDBs(t *testing.T) {
508+
t.Parallel()
509+
in := t.TempDir()
510+
for _, name := range []string{"db_0", "db_3"} {
511+
if err := os.MkdirAll(filepath.Join(in, "redis", name, "strings"), 0o755); err != nil {
512+
t.Fatalf("MkdirAll %s: %v", name, err)
513+
}
503514
}
504-
if result.BytesWritten <= emptyResult.BytesWritten {
505-
t.Errorf("BytesWritten with redis/db_3 fixture (%d) <= empty (%d); pre-fix, hardcoded db_0 fan-out dropped db_3 silently",
506-
result.BytesWritten, emptyResult.BytesWritten)
515+
var buf bytes.Buffer
516+
_, err := EncodeSnapshot(EncodeOptions{
517+
InputRoot: in,
518+
Adapters: AdapterSet{Redis: true},
519+
LastCommitTS: 1,
520+
}, &buf)
521+
if err == nil {
522+
t.Fatalf("EncodeSnapshot accepted db_0 + db_3; want ErrRedisEncodeMultiDBUnsupported")
523+
}
524+
if !errors.Is(err, ErrRedisEncodeMultiDBUnsupported) {
525+
t.Errorf("err = %v, want errors.Is ErrRedisEncodeMultiDBUnsupported", err)
526+
}
527+
}
528+
529+
// TestEnumerateRedisDBsRejectsNonDirDBEntry pins codex P2 v14 #904
530+
// L427: when a canonical db_<N> name resolves to a regular file
531+
// (or symlink) instead of a directory, enumerateRedisDBs must fail
532+
// closed with ErrRedisEncodeNotDir — silently skipping would let a
533+
// malformed dump publish a header-only/partial FSM.
534+
func TestEnumerateRedisDBsRejectsNonDirDBEntry(t *testing.T) {
535+
t.Parallel()
536+
in := t.TempDir()
537+
if err := os.MkdirAll(filepath.Join(in, "redis"), 0o755); err != nil {
538+
t.Fatalf("MkdirAll: %v", err)
539+
}
540+
// redis/db_2 is a regular file — name matches the canonical
541+
// pattern but the entry shape is wrong.
542+
if err := os.WriteFile(filepath.Join(in, "redis", "db_2"), []byte("not a dir"), 0o600); err != nil {
543+
t.Fatalf("WriteFile: %v", err)
544+
}
545+
_, err := enumerateRedisDBs(in)
546+
if !errors.Is(err, ErrRedisEncodeNotDir) {
547+
t.Errorf("err = %v, want errors.Is ErrRedisEncodeNotDir", err)
507548
}
508549
}
509550

0 commit comments

Comments
 (0)