Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 10 additions & 7 deletions jepsen/src/elastickv/filesystem_workload.clj
Original file line number Diff line number Diff line change
Expand Up @@ -528,7 +528,7 @@
(assoc op :type :ok :value (independent/tuple k kind) :error (io-error-label e))
(fail op (io-error-label e))))))

(defrecord FilesystemClient [node->mount-dir write-mode key-count max-retries require-fuse-mount node mount-dir local?]
(defrecord FilesystemClient [node->mount-dir write-mode key-count max-retries require-fuse-mount setup-lock node mount-dir local?]
client/Client
(open! [this test node]
(let [dir (or (get node->mount-dir node)
Expand All @@ -542,11 +542,14 @@
(let [keys (range key-count)]
(when (and local? require-fuse-mount)
(require-local-fuse-mount! mount-dir))
(with-retries max-retries
(fn []
(if local?
(local-setup! mount-dir keys)
(remote-exec! test node (remote-setup-cmd mount-dir keys)))))))
(if local?
;; Every local client sees the same mounted filesystem. Serialize
;; setup writes to avoid self-conflicts, while still resetting the
;; registers each time setup is called for a new test.
(locking setup-lock
(with-retries max-retries #(local-setup! mount-dir keys)))
(with-retries max-retries
#(remote-exec! test node (remote-setup-cmd mount-dir keys))))))

(teardown! [_this test]
;; Jepsen runs teardown! before setup! and again after the test, so the
Expand Down Expand Up @@ -657,7 +660,7 @@
require-fuse (if (contains? opts :require-fuse-mount) (boolean (:require-fuse-mount opts)) true)
node->dir (or (:node->mount-dir opts)
(node->mount-dir default-nodes nil default-mount-dir))
client (->FilesystemClient node->dir write-mode key-count max-retries require-fuse nil nil false)]
client (->FilesystemClient node->dir write-mode key-count max-retries require-fuse (Object.) nil nil false)]
(when-not (contains? write-modes write-mode)
(throw (ex-info "unknown write mode" {:write-mode write-mode})))
{:client client
Expand Down
46 changes: 41 additions & 5 deletions kv/fsm.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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)
Expand Down Expand Up @@ -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
}
Expand All @@ -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
Comment on lines +1665 to +1666

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 Match delete mutations before deduplicating retries

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 returns true without 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 👍 / 👎.

}
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

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 Read retry values without applying the retention floor

When PrevCommitTS has fallen below a replica's local retention watermark but the exact version is still physically present, CommittedVersionAt deliberately reports the version so retry dedup remains deterministic, but this subsequent GetAt returns ErrReadTSCompacted. A genuine landed retry that previously no-op'ed therefore becomes an error, and replicas with different local retention floors can produce different apply results for the same Raft entry. Use an exact-value probe that bypasses the retention watermark, matching the existence probe's contract.

Useful? React with 👍 / 👎.

Comment on lines +1678 to +1680

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 Treat an exact tombstone as a nonmatching PUT

When a PUT retry's PrevCommitTS collides with another transaction's delete on the same key during a leader handoff, CommittedVersionAt reports the tombstone but this GetAt returns ErrKeyNotFound. The FSM consequently rejects the retry even though its prior PUT never landed, potentially surfacing a Redis or DynamoDB write failure; inspect the exact version's operation and treat a tombstone as a mismatch for PUT.

AGENTS.md reference: AGENTS.md:L33-L35

Useful? React with 👍 / 👎.

}
matched = bytes.Equal(value, mut.Value)
break
Comment on lines +1682 to +1683

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 Keep searching aliases after a mismatched live version

When staged visibility is active and both the live key and a staged alias have versions at PrevCommitTS, this unconditional break stops after the live candidate even if its value belongs to a colliding transaction. A matching staged copy of the actual prior attempt is therefore ignored, so a genuine landed retry falls through to normal application and may conflict or be recomputed. Continue through the remaining aliases when bytes.Equal is false, and stop only after finding a match.

Useful? React with 👍 / 👎.

}
if !matched {
return false, nil
}
}
return true, nil
}

func stagedVisibilityJobIDsForReadAlias(readKeys [][]byte, aliasRawKey []byte) []uint64 {
if len(readKeys) == 0 {
return nil
Expand Down
74 changes: 74 additions & 0 deletions kv/fsm_dynamodb_timestamp_collision_test.go
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...)
}
5 changes: 3 additions & 2 deletions kv/fsm_occ_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -180,14 +180,15 @@ func TestOnePhaseTxnNilReadKeysSkipsReadWriteValidation(t *testing.T) {
fsm, ok := NewKvFSMWithHLC(st, NewHLC()).(*kvFSM)
require.True(t, ok)

// nil ReadKeys: read-write check is skipped entirely.
// nil ReadKeys: read-write check is skipped entirely. Use a commit
// timestamp above the applied watermark so the apply-order fence permits it.
req := &pb.Request{
IsTxn: true,
Phase: pb.Phase_NONE,
Ts: 100,
ReadKeys: nil,
Mutations: []*pb.Mutation{
{Op: pb.Op_PUT, Key: []byte(txnMetaPrefix), Value: EncodeTxnMeta(TxnMeta{PrimaryKey: []byte("wk"), CommitTS: 120})},
{Op: pb.Op_PUT, Key: []byte(txnMetaPrefix), Value: EncodeTxnMeta(TxnMeta{PrimaryKey: []byte("wk"), CommitTS: 220})},
{Op: pb.Op_PUT, Key: []byte("wk"), Value: []byte("val")},
},
}
Expand Down
45 changes: 45 additions & 0 deletions kv/fsm_onephase_dedup_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,51 @@ func TestOnePhaseDedup_NoOpsWhenPriorAttemptLanded(t *testing.T) {
require.Equal(t, uint64(20), latest, "newest version must remain attempt 1's at 20")
}

func TestOnePhaseDedup_DifferentWriteAtPriorTimestampIsNotOurAttempt(t *testing.T) {
ctx := context.Background()
st := store.NewMVCCStore()
fsm, ok := NewKvFSMWithHLC(st, NewHLC()).(*kvFSM)
require.True(t, ok)
key := store.ListItemKey([]byte("jepsen-list"), 43)
require.NoError(t, applyFSMRequest(t, fsm, onePhaseReq(10, 20, 0, key, []byte("45"))))

// A second append allocated the same timestamp, but its first attempt
// never landed. Its retry must not mistake the first append for itself.
err := applyFSMRequest(t, fsm, onePhaseReq(10, 40, 20, key, []byte("44")))
require.ErrorIs(t, err, store.ErrWriteConflict)
got, err := st.GetAt(ctx, key, 40)
require.NoError(t, err)
require.Equal(t, []byte("45"), got)
}

func TestOnePhaseDedup_DifferentScalarWriteAtPriorTimestampIsNotOurAttempt(t *testing.T) {
ctx := context.Background()
st := store.NewMVCCStore()
fsm, ok := NewKvFSMWithHLC(st, NewHLC()).(*kvFSM)
require.True(t, ok)
key := []byte("jepsen-register")
require.NoError(t, applyFSMRequest(t, fsm, onePhaseReq(10, 20, 0, key, []byte("45"))))

err := applyFSMRequest(t, fsm, onePhaseReq(10, 40, 20, key, []byte("44")))
require.ErrorIs(t, err, store.ErrWriteConflict)
got, err := st.GetAt(ctx, key, 40)
require.NoError(t, err)
require.Equal(t, []byte("45"), got)
}

func TestOnePhaseDedup_ListItemRetryWithMatchingValueNoOps(t *testing.T) {
ctx := context.Background()
st := store.NewMVCCStore()
fsm, ok := NewKvFSMWithHLC(st, NewHLC()).(*kvFSM)
require.True(t, ok)
key := store.ListItemKey([]byte("jepsen-list"), 43)
require.NoError(t, applyFSMRequest(t, fsm, onePhaseReq(10, 20, 0, key, []byte("44"))))
require.NoError(t, applyFSMRequest(t, fsm, onePhaseReq(30, 40, 20, key, []byte("44"))))
landed, err := st.CommittedVersionAt(ctx, key, 40)
require.NoError(t, err)
require.False(t, landed)
}

func TestOnePhaseDedup_NoOpsWhenPriorAttemptLandedAsStagedVersion(t *testing.T) {
t.Parallel()
ctx := context.Background()
Expand Down
7 changes: 7 additions & 0 deletions kv/leader_routed_store.go
Original file line number Diff line number Diff line change
Expand Up @@ -570,6 +570,13 @@ func (s *LeaderRoutedStore) ApplyMutationsRaftAt(ctx context.Context, mutations
return errors.WithStack(s.local.ApplyMutationsRaftAt(ctx, mutations, readKeys, startTS, commitTS, appliedIndex))
}

func (s *LeaderRoutedStore) ApplyMutationsRaftAtFenced(ctx context.Context, mutations []*store.KVPairMutation, readKeys [][]byte, startTS, commitTS, appliedIndex uint64) error {
if s == nil || s.local == nil {
return errors.WithStack(store.ErrNotSupported)
}
return errors.WithStack(s.local.ApplyMutationsRaftAtFenced(ctx, mutations, readKeys, startTS, commitTS, appliedIndex))
}

func (s *LeaderRoutedStore) DeletePrefixAt(ctx context.Context, prefix []byte, excludePrefix []byte, commitTS uint64) error {
if s == nil || s.local == nil {
return errors.WithStack(store.ErrNotSupported)
Expand Down
13 changes: 13 additions & 0 deletions kv/shard_store.go
Original file line number Diff line number Diff line change
Expand Up @@ -5179,6 +5179,19 @@ func (s *ShardStore) ApplyMutationsRaftAt(ctx context.Context, mutations []*stor
return errors.WithStack(group.Store.ApplyMutationsRaftAt(ctx, mutations, readKeys, startTS, commitTS, appliedIndex))
}

func (s *ShardStore) ApplyMutationsRaftAtFenced(ctx context.Context, mutations []*store.KVPairMutation, readKeys [][]byte, startTS, commitTS, appliedIndex uint64) error {
group, err := s.resolveSingleShardGroup(mutations)
if err != nil || group == nil {
return err
}
if err := s.ensureMutationWriteTimestampFloors(mutations, commitTS); err != nil {
return err
}
readKeys = s.readKeysWithStagedVisibilityAliases(group, readKeys)
readKeys = s.readKeysWithStagedVisibilityMutationAliases(group, readKeys, mutations)
return errors.WithStack(group.Store.ApplyMutationsRaftAtFenced(ctx, mutations, readKeys, startTS, commitTS, appliedIndex))
}

func ensureRouteWriteTimestampFloor(route distribution.Route, key []byte, commitTS uint64) error {
if route.MinWriteTSExclusive == 0 || commitTS == 0 || commitTS > route.MinWriteTSExclusive {
return nil
Expand Down
75 changes: 75 additions & 0 deletions store/apply_order_fence_jepsen_test.go
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)
}
Loading
Loading