Repository navigation
Fix lost Redis appends during Raft leader handoff #1286
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
fd25bda
2f5e5a8
afbb73d
01e8424
aa51b35
6d874a3
78cab1b
d23ab8a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1514,7 +1514,7 @@ func (f *kvFSM) handleOnePhaseTxnRequest(ctx context.Context, r *pb.Request, com | |
| // applying this log entry. The retention-window > max-retry-latency | ||
| // invariant prevents the rare case where a real never-landed retry | ||
| // arrives with PrevCommitTS below pebble's compacted floor. | ||
| dedup, err := f.dedupProbeOnePhase(ctx, meta, r.ReadKeys) | ||
| dedup, err := f.dedupProbeOnePhase(ctx, meta, r.ReadKeys, muts) | ||
| if err != nil { | ||
| return err | ||
| } | ||
|
|
@@ -1531,7 +1531,7 @@ func (f *kvFSM) handleOnePhaseTxnRequest(ctx context.Context, r *pb.Request, com | |
| if err != nil { | ||
| return err | ||
| } | ||
| if err := f.store.ApplyMutationsRaftAt(ctx, storeMuts, r.ReadKeys, startTS, commitTS, f.pendingApplyIdx); err != nil { | ||
| if err := f.store.ApplyMutationsRaftAtFenced(ctx, storeMuts, r.ReadKeys, startTS, commitTS, f.pendingApplyIdx); err != nil { | ||
| return errors.WithStack(err) | ||
| } | ||
| f.notifyApplyObservers(commitTS, uniq) | ||
|
|
@@ -1630,7 +1630,7 @@ func (f *kvFSM) uniqueTxnMutationsAboveFloorWithSnapshot(muts []*pb.Mutation, co | |
| // Returns (true, nil) → the entry must no-op (prior attempt landed). | ||
| // Returns (false, nil) → fall through to normal apply. | ||
| // Returns (false, err) → propagate err; apply must not proceed. | ||
| func (f *kvFSM) dedupProbeOnePhase(ctx context.Context, meta TxnMeta, readKeys [][]byte) (bool, error) { | ||
| func (f *kvFSM) dedupProbeOnePhase(ctx context.Context, meta TxnMeta, readKeys [][]byte, muts []*pb.Mutation) (bool, error) { | ||
| if meta.PrevCommitTS == 0 { | ||
| return false, nil | ||
| } | ||
|
|
@@ -1639,20 +1639,56 @@ func (f *kvFSM) dedupProbeOnePhase(ctx context.Context, meta TxnMeta, readKeys [ | |
| return false, errors.WithStack(err) | ||
| } | ||
| if landed { | ||
| return true, nil | ||
| return f.priorMutationValuesMatch(ctx, muts, readKeys, meta.PrevCommitTS) | ||
| } | ||
| for _, stagedKey := range stagedVisibilityKeysForReadAlias(readKeys, meta.PrimaryKey, meta.PrimaryKey) { | ||
| landed, err = f.store.CommittedVersionAt(ctx, stagedKey, meta.PrevCommitTS) | ||
| if err != nil { | ||
| return false, errors.WithStack(err) | ||
| } | ||
| if landed { | ||
| return true, nil | ||
| return f.priorMutationValuesMatch(ctx, muts, readKeys, meta.PrevCommitTS) | ||
| } | ||
| } | ||
| return false, nil | ||
| } | ||
|
|
||
| // A timestamp may be issued by two leaders during a handoff. An exact | ||
| // version at PrevCommitTS alone cannot identify the retry, even for scalar | ||
| // writes. Compare every final PUT value in the retained write set. | ||
| func (f *kvFSM) priorMutationValuesMatch(ctx context.Context, muts []*pb.Mutation, readKeys [][]byte, prevTS uint64) (bool, error) { | ||
| uniq, err := uniqueMutations(muts) | ||
| if err != nil { | ||
| return false, err | ||
| } | ||
| for _, mut := range uniq { | ||
| if mut.Op != pb.Op_PUT { | ||
| continue | ||
| } | ||
| keys := append([][]byte{mut.Key}, stagedVisibilityKeysForReadAlias(readKeys, mut.Key, mut.Key)...) | ||
| matched := false | ||
| for _, key := range keys { | ||
| landed, err := f.store.CommittedVersionAt(ctx, key, prevTS) | ||
| if err != nil { | ||
| return false, errors.WithStack(err) | ||
| } | ||
| if !landed { | ||
| continue | ||
| } | ||
| value, err := f.store.GetAt(ctx, key, prevTS) | ||
| if err != nil { | ||
| return false, errors.WithStack(err) | ||
|
Comment on lines
+1678
to
+1680
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When Useful? React with 👍 / 👎.
Comment on lines
+1678
to
+1680
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a PUT retry's AGENTS.md reference: AGENTS.md:L33-L35 Useful? React with 👍 / 👎. |
||
| } | ||
| matched = bytes.Equal(value, mut.Value) | ||
| break | ||
|
Comment on lines
+1682
to
+1683
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When staged visibility is active and both the live key and a staged alias have versions at Useful? React with 👍 / 👎. |
||
| } | ||
| if !matched { | ||
| return false, nil | ||
| } | ||
| } | ||
| return true, nil | ||
| } | ||
|
|
||
| func stagedVisibilityJobIDsForReadAlias(readKeys [][]byte, aliasRawKey []byte) []uint64 { | ||
| if len(readKeys) == 0 { | ||
| return nil | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,74 @@ | ||
| package kv | ||
|
|
||
| import ( | ||
| "bytes" | ||
| "context" | ||
| "path/filepath" | ||
| "testing" | ||
|
|
||
| "github.com/bootjp/elastickv/internal/encryption" | ||
| pb "github.com/bootjp/elastickv/proto" | ||
| "github.com/bootjp/elastickv/store" | ||
| "github.com/stretchr/testify/require" | ||
| "google.golang.org/protobuf/proto" | ||
| ) | ||
|
|
||
| func TestDynamoAppendDistinctRaftEntryCannotAcknowledgeTimestampCollision(t *testing.T) { | ||
| for _, encrypted := range []bool{false, true} { | ||
| t.Run(map[bool]string{false: "plaintext", true: "encrypted"}[encrypted], func(t *testing.T) { | ||
| ctx := context.Background() | ||
| var opts []store.PebbleStoreOption | ||
| if encrypted { | ||
| keys := encryption.NewKeystore() | ||
| require.NoError(t, keys.Set(1, bytes.Repeat([]byte{0x37}, encryption.KeySize))) | ||
| cipher, err := encryption.NewCipher(keys) | ||
| require.NoError(t, err) | ||
| opts = append(opts, store.WithEncryption(cipher, store.NewCounterNonceFactory(1, 1), func() (uint32, bool) { return 1, true })) | ||
| } | ||
| st, err := store.NewPebbleStore(filepath.Join(t.TempDir(), "fsm.db"), opts...) | ||
| require.NoError(t, err) | ||
| t.Cleanup(func() { require.NoError(t, st.Close()) }) | ||
| fsm, ok := NewKvFSMWithHLC(st, NewHLC()).(*kvFSM) | ||
| require.True(t, ok) | ||
| key := []byte(DynamoItemPrefix + "amVwc2VuX2FwcGVuZA|2|16\x00\x01") | ||
| // Minimized from the same committed WAL that produced the missing | ||
| // acknowledged append37/key16: distinct entries371/372 in term5 | ||
| // carried different item values at this identical commit timestamp. | ||
| const collisionTS uint64 = 117485124856709124 | ||
| first := dynamoCollisionItem(t, "43") | ||
| lost := dynamoCollisionItem(t, "37") | ||
| fsm.SetApplyIndex(371) | ||
| require.NoError(t, applyFSMRequest(t, fsm, onePhaseReq(collisionTS-1, collisionTS, 0, key, first))) | ||
| // An actual replay of entry371 remains idempotent. | ||
| require.NoError(t, applyFSMRequest(t, fsm, onePhaseReq(collisionTS-1, collisionTS, 0, key, first))) | ||
| fsm.SetApplyIndex(372) | ||
| err = applyFSMRequest(t, fsm, onePhaseReq(collisionTS-2, collisionTS, 0, key, lost)) | ||
| require.ErrorIs(t, err, store.ErrWriteConflict, "another entry must not return success without writing its value") | ||
| got, err := st.GetAt(ctx, key, collisionTS) | ||
| require.NoError(t, err) | ||
| require.Equal(t, first, got) | ||
| // Re-read/recompute at a fresh timestamp preserves both appends. | ||
| both := dynamoCollisionItem(t, "43", "37") | ||
| fsm.SetApplyIndex(373) | ||
| require.NoError(t, applyFSMRequest(t, fsm, onePhaseReq(collisionTS, collisionTS+1, 0, key, both))) | ||
| got, err = st.GetAt(ctx, key, collisionTS+1) | ||
| require.NoError(t, err) | ||
| require.Equal(t, both, got) | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| func dynamoCollisionItem(t *testing.T, values ...string) []byte { | ||
| t.Helper() | ||
| list := &pb.DynamoAttributeValueList{} | ||
| for _, value := range values { | ||
| list.Values = append(list.Values, &pb.DynamoAttributeValue{Value: &pb.DynamoAttributeValue_N{N: value}}) | ||
| } | ||
| item := &pb.DynamoItem{Attributes: map[string]*pb.DynamoAttributeValue{ | ||
| "pk": {Value: &pb.DynamoAttributeValue_S{S: "16"}}, | ||
| "val": {Value: &pb.DynamoAttributeValue_L{L: list}}, | ||
| }} | ||
| body, err := (proto.MarshalOptions{Deterministic: true}).Marshal(item) | ||
| require.NoError(t, err) | ||
| return append([]byte{0, 'D', 'I', 1}, body...) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,75 @@ | ||
| package store | ||
|
|
||
| import ( | ||
| "context" | ||
| "testing" | ||
|
|
||
| "github.com/stretchr/testify/require" | ||
| ) | ||
|
|
||
| func TestOnePhaseApplyRejectsTimestampAtAppliedWatermark(t *testing.T) { | ||
| for _, tc := range []struct { | ||
| name string | ||
| new func(*testing.T) MVCCStore | ||
| }{ | ||
| {"memory", func(*testing.T) MVCCStore { return NewMVCCStore() }}, | ||
| {"pebble", newApplyIndexPebbleStore}, | ||
| } { | ||
| t.Run(tc.name, func(t *testing.T) { | ||
| ctx := context.Background() | ||
| st := tc.new(t) | ||
| put := func(key, val string) []*KVPairMutation { | ||
| return []*KVPairMutation{{Op: OpTypePut, Key: []byte(key), Value: []byte(val)}} | ||
| } | ||
| require.NoError(t, st.ApplyMutationsRaftAtFenced(ctx, put("first", "45"), nil, 99, 100, 10)) | ||
| for _, ts := range []uint64{100, 99} { | ||
| err := st.ApplyMutationsRaftAtFenced(ctx, put("later", "44"), nil, 98, ts, 11) | ||
| require.ErrorIs(t, err, ErrWriteConflict) | ||
| } | ||
| _, err := st.GetAt(ctx, []byte("later"), 100) | ||
| require.ErrorIs(t, err, ErrKeyNotFound) | ||
| require.Equal(t, uint64(100), st.LastCommitTS()) | ||
| require.NoError(t, st.ApplyMutationsRaftAtFenced(ctx, put("later", "44"), nil, 100, 101, 11)) | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| func TestOnePhaseRaftEntryRejectsCollidingAppend(t *testing.T) { | ||
| ctx := context.Background() | ||
| st := newApplyIndexPebbleStore(t) | ||
| applier := raftEntryApplier(t, st) | ||
| key := ListItemKey([]byte("jepsen-list"), 43) | ||
| put := func(value string) []*KVPairMutation { | ||
| return []*KVPairMutation{{Op: OpTypePut, Key: key, Value: []byte(value)}} | ||
| } | ||
| require.NoError(t, applier.ApplyRaftEntry(ctx, 10, func(ctx context.Context) error { | ||
| return st.ApplyMutationsRaftAtFenced(ctx, put("45"), nil, 99, 100, 10) | ||
| })) | ||
| require.NoError(t, applier.ApplyRaftEntry(ctx, 11, func(ctx context.Context) error { | ||
| err := st.ApplyMutationsRaftAtFenced(ctx, put("44"), nil, 99, 100, 11) | ||
| require.ErrorIs(t, err, ErrWriteConflict) | ||
| return nil | ||
| })) | ||
| got, err := st.GetAt(ctx, key, 100) | ||
| require.NoError(t, err) | ||
| require.Equal(t, []byte("45"), got) | ||
| } | ||
|
|
||
| func TestOnePhaseRaftEntryRejectsCollidingWriteInSameBatch(t *testing.T) { | ||
| ctx := context.Background() | ||
| st := newApplyIndexPebbleStore(t) | ||
| applier := raftEntryApplier(t, st) | ||
| key := ListItemKey([]byte("jepsen-list"), 43) | ||
| put := func(value string) []*KVPairMutation { | ||
| return []*KVPairMutation{{Op: OpTypePut, Key: key, Value: []byte(value)}} | ||
| } | ||
| require.NoError(t, applier.ApplyRaftEntry(ctx, 10, func(ctx context.Context) error { | ||
| require.NoError(t, st.ApplyMutationsRaftAtFenced(ctx, put("45"), nil, 99, 100, 10)) | ||
| err := st.ApplyMutationsRaftAtFenced(ctx, put("44"), nil, 99, 100, 10) | ||
| require.ErrorIs(t, err, ErrWriteConflict) | ||
| return nil | ||
| })) | ||
| got, err := st.GetAt(ctx, key, 100) | ||
| require.NoError(t, err) | ||
| require.Equal(t, []byte("45"), got) | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When an option-2 retry contains a delete and another transaction committed any version of its primary key at the colliding
PrevCommitTS, this loop skips the delete entirely; for a delete-only write, or when all PUTs happen to match, the function therefore returnstruewithout verifying that a tombstone exists at that timestamp. The FSM then no-ops the whole Redis EXEC or DynamoDB retry and can acknowledge a deletion that never occurred. Compare the exact operation/tombstone state for DEL mutations before declaring the prior attempt a match.Useful? React with 👍 / 👎.