diff --git a/tests/robustness/linearizability_test.go b/tests/robustness/linearizability_test.go index 217ff2f22..c2b965786 100644 --- a/tests/robustness/linearizability_test.go +++ b/tests/robustness/linearizability_test.go @@ -270,7 +270,7 @@ func runScenario(ctx context.Context, t *testing.T, lg *zap.Logger, clus *e2e.Et func operationsMaxRevision(operations []porcupine.Operation) int64 { var maxRevision int64 for _, op := range operations { - revision := op.Output.(model.EtcdResponse).Revision + revision := op.Output.(model.EtcdNonDeterministicResponse).Revision if revision > maxRevision { maxRevision = revision } diff --git a/tests/robustness/model/describe.go b/tests/robustness/model/describe.go new file mode 100644 index 000000000..2d6d6ca07 --- /dev/null +++ b/tests/robustness/model/describe.go @@ -0,0 +1,140 @@ +// Copyright 2023 The etcd Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package model + +import ( + "fmt" + "strings" +) + +func describeEtcdNonDeterministicResponse(request EtcdRequest, response EtcdNonDeterministicResponse) string { + if response.Err != nil { + return fmt.Sprintf("err: %q", response.Err) + } + if response.ResultUnknown { + return fmt.Sprintf("unknown, rev: %d", response.Revision) + } + return describeEtcdResponse(request, response.EtcdResponse) +} + +func describeEtcdResponse(request EtcdRequest, response EtcdResponse) string { + if request.Type == Txn { + return fmt.Sprintf("%s, rev: %d", describeTxnResponse(request.Txn, response.Txn), response.Revision) + } + if response.Revision == 0 { + return "ok" + } + return fmt.Sprintf("ok, rev: %d", response.Revision) +} + +func describeEtcdRequest(request EtcdRequest) string { + switch request.Type { + case Txn: + describeOperations := describeEtcdOperations(request.Txn.Ops) + if len(request.Txn.Conds) != 0 { + return fmt.Sprintf("if(%s).then(%s)", describeEtcdConditions(request.Txn.Conds), describeOperations) + } + return describeOperations + case LeaseGrant: + return fmt.Sprintf("leaseGrant(%d)", request.LeaseGrant.LeaseID) + case LeaseRevoke: + return fmt.Sprintf("leaseRevoke(%d)", request.LeaseRevoke.LeaseID) + case Defragment: + return fmt.Sprintf("defragment()") + default: + return fmt.Sprintf("", request.Type) + } +} + +func describeEtcdConditions(conds []EtcdCondition) string { + opsDescription := make([]string, len(conds)) + for i := range conds { + opsDescription[i] = fmt.Sprintf("mod_rev(%s)==%d", conds[i].Key, conds[i].ExpectedRevision) + } + return strings.Join(opsDescription, " && ") +} + +func describeEtcdOperations(ops []EtcdOperation) string { + opsDescription := make([]string, len(ops)) + for i := range ops { + opsDescription[i] = describeEtcdOperation(ops[i]) + } + return strings.Join(opsDescription, ", ") +} + +func describeTxnResponse(request *TxnRequest, response *TxnResponse) string { + if response.TxnResult { + return fmt.Sprintf("txn failed") + } + respDescription := make([]string, len(response.OpsResult)) + for i := range response.OpsResult { + respDescription[i] = describeEtcdOperationResponse(request.Ops[i], response.OpsResult[i]) + } + return strings.Join(respDescription, ", ") +} + +func describeEtcdOperation(op EtcdOperation) string { + switch op.Type { + case Range: + if op.WithPrefix { + return fmt.Sprintf("range(%q)", op.Key) + } + return fmt.Sprintf("get(%q)", op.Key) + case Put: + if op.LeaseID != 0 { + return fmt.Sprintf("put(%q, %s, %d)", op.Key, describeValueOrHash(op.Value), op.LeaseID) + } + return fmt.Sprintf("put(%q, %s)", op.Key, describeValueOrHash(op.Value)) + case Delete: + return fmt.Sprintf("delete(%q)", op.Key) + default: + return fmt.Sprintf("", op.Type) + } +} + +func describeEtcdOperationResponse(req EtcdOperation, resp EtcdOperationResult) string { + switch req.Type { + case Range: + if req.WithPrefix { + kvs := make([]string, len(resp.KVs)) + for i, kv := range resp.KVs { + kvs[i] = describeValueOrHash(kv.Value) + } + return fmt.Sprintf("[%s]", strings.Join(kvs, ",")) + } else { + if len(resp.KVs) == 0 { + return "nil" + } else { + return describeValueOrHash(resp.KVs[0].Value) + } + } + case Put: + return fmt.Sprintf("ok") + case Delete: + return fmt.Sprintf("deleted: %d", resp.Deleted) + default: + return fmt.Sprintf("", req.Type) + } +} + +func describeValueOrHash(value ValueOrHash) string { + if value.Hash != 0 { + return fmt.Sprintf("hash: %d", value.Hash) + } + if value.Value == "" { + return "nil" + } + return fmt.Sprintf("%q", value.Value) +} diff --git a/tests/robustness/model/describe_test.go b/tests/robustness/model/describe_test.go new file mode 100644 index 000000000..88928f30c --- /dev/null +++ b/tests/robustness/model/describe_test.go @@ -0,0 +1,126 @@ +// Copyright 2023 The etcd Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package model + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" + + "go.etcd.io/etcd/api/v3/mvccpb" +) + +func TestModelDescribe(t *testing.T) { + tcs := []struct { + req EtcdRequest + resp EtcdNonDeterministicResponse + expectDescribe string + }{ + { + req: getRequest("key1"), + resp: emptyGetResponse(1), + expectDescribe: `get("key1") -> nil, rev: 1`, + }, + { + req: getRequest("key2"), + resp: getResponse("key", "2", 2, 2), + expectDescribe: `get("key2") -> "2", rev: 2`, + }, + { + req: getRequest("key2b"), + resp: getResponse("key2b", "01234567890123456789", 2, 2), + expectDescribe: `get("key2b") -> hash: 2945867837, rev: 2`, + }, + { + req: putRequest("key3", "3"), + resp: putResponse(3), + expectDescribe: `put("key3", "3") -> ok, rev: 3`, + }, + { + req: putWithLeaseRequest("key3b", "3b", 3), + resp: putResponse(3), + expectDescribe: `put("key3b", "3b", 3) -> ok, rev: 3`, + }, + { + req: putRequest("key3c", "01234567890123456789"), + resp: putResponse(3), + expectDescribe: `put("key3c", hash: 2945867837) -> ok, rev: 3`, + }, + { + req: putRequest("key4", "4"), + resp: failedResponse(errors.New("failed")), + expectDescribe: `put("key4", "4") -> err: "failed"`, + }, + { + req: putRequest("key4b", "4b"), + resp: unknownResponse(42), + expectDescribe: `put("key4b", "4b") -> unknown, rev: 42`, + }, + { + req: deleteRequest("key5"), + resp: deleteResponse(1, 5), + expectDescribe: `delete("key5") -> deleted: 1, rev: 5`, + }, + { + req: deleteRequest("key6"), + resp: failedResponse(errors.New("failed")), + expectDescribe: `delete("key6") -> err: "failed"`, + }, + { + req: compareAndSetRequest("key7", 7, "77"), + resp: compareAndSetResponse(false, 7), + expectDescribe: `if(mod_rev(key7)==7).then(put("key7", "77")) -> txn failed, rev: 7`, + }, + { + req: compareAndSetRequest("key8", 8, "88"), + resp: compareAndSetResponse(true, 8), + expectDescribe: `if(mod_rev(key8)==8).then(put("key8", "88")) -> ok, rev: 8`, + }, + { + req: compareAndSetRequest("key9", 9, "99"), + resp: failedResponse(errors.New("failed")), + expectDescribe: `if(mod_rev(key9)==9).then(put("key9", "99")) -> err: "failed"`, + }, + { + req: txnRequest(nil, []EtcdOperation{{Type: Range, Key: "10"}, {Type: Put, Key: "11", Value: ValueOrHash{Value: "111"}}, {Type: Delete, Key: "12"}}), + resp: txnResponse([]EtcdOperationResult{{KVs: []KeyValue{{ValueRevision: ValueRevision{Value: ValueOrHash{Value: "110"}}}}}, {}, {Deleted: 1}}, true, 10), + expectDescribe: `get("10"), put("11", "111"), delete("12") -> "110", ok, deleted: 1, rev: 10`, + }, + { + req: defragmentRequest(), + resp: defragmentResponse(10), + expectDescribe: `defragment() -> ok, rev: 10`, + }, + { + req: rangeRequest("key11", true), + resp: rangeResponse(nil, 11), + expectDescribe: `range("key11") -> [], rev: 11`, + }, + { + req: rangeRequest("key12", true), + resp: rangeResponse([]*mvccpb.KeyValue{{Value: []byte("12")}}, 12), + expectDescribe: `range("key12") -> ["12"], rev: 12`, + }, + { + req: rangeRequest("key13", true), + resp: rangeResponse([]*mvccpb.KeyValue{{Value: []byte("01234567890123456789")}}, 13), + expectDescribe: `range("key13") -> [hash: 2945867837], rev: 13`, + }, + } + for _, tc := range tcs { + assert.Equal(t, tc.expectDescribe, NonDeterministicModel.DescribeOperation(tc.req, tc.resp)) + } +} diff --git a/tests/robustness/model/model.go b/tests/robustness/model/deterministic.go similarity index 52% rename from tests/robustness/model/model.go rename to tests/robustness/model/deterministic.go index 8dde5764d..3d13bd4c2 100644 --- a/tests/robustness/model/model.go +++ b/tests/robustness/model/deterministic.go @@ -1,4 +1,4 @@ -// Copyright 2022 The etcd Authors +// Copyright 2023 The etcd Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -25,290 +25,52 @@ import ( "github.com/anishathalye/porcupine" ) -type OperationType string - -const ( - Range OperationType = "range" - Put OperationType = "put" - Delete OperationType = "delete" -) - -var Etcd = porcupine.Model{ +// DeterministicModel assumes that all requests succeed and have a correct response. +var DeterministicModel = porcupine.Model{ Init: func() interface{} { - return "[]" // empty PossibleStates - }, - Step: func(st interface{}, in interface{}, out interface{}) (bool, interface{}) { - var states PossibleStates - err := json.Unmarshal([]byte(st.(string)), &states) + var s etcdState + data, err := json.Marshal(s) if err != nil { panic(err) } - ok, states := step(states, in.(EtcdRequest), out.(EtcdResponse)) - data, err := json.Marshal(states) + return string(data) + }, + Step: func(st interface{}, in interface{}, out interface{}) (bool, interface{}) { + var s etcdState + err := json.Unmarshal([]byte(st.(string)), &s) + if err != nil { + panic(err) + } + ok, s := s.Step(in.(EtcdRequest), out.(EtcdResponse)) + data, err := json.Marshal(s) if err != nil { panic(err) } return ok, string(data) }, DescribeOperation: func(in, out interface{}) string { - return describeEtcdRequestResponse(in.(EtcdRequest), out.(EtcdResponse)) + return fmt.Sprintf("%s -> %s", describeEtcdRequest(in.(EtcdRequest)), describeEtcdResponse(in.(EtcdRequest), out.(EtcdResponse))) }, } -type RequestType string - -const ( - Txn RequestType = "txn" - LeaseGrant RequestType = "leaseGrant" - LeaseRevoke RequestType = "leaseRevoke" - Defragment RequestType = "defragment" -) - -type EtcdRequest struct { - Type RequestType - LeaseGrant *LeaseGrantRequest - LeaseRevoke *LeaseRevokeRequest - Txn *TxnRequest - Defragment *DefragmentRequest -} - -type TxnRequest struct { - Conds []EtcdCondition - Ops []EtcdOperation -} - -type EtcdCondition struct { - Key string - ExpectedRevision int64 -} - -type EtcdOperation struct { - Type OperationType - Key string - WithPrefix bool - Value ValueOrHash - LeaseID int64 -} - -type LeaseGrantRequest struct { - LeaseID int64 -} -type LeaseRevokeRequest struct { - LeaseID int64 -} -type DefragmentRequest struct{} - -type EtcdResponse struct { - Err error - Revision int64 - ResultUnknown bool - Txn *TxnResponse - LeaseGrant *LeaseGrantReponse - LeaseRevoke *LeaseRevokeResponse - Defragment *DefragmentResponse -} - -type TxnResponse struct { - TxnResult bool - OpsResult []EtcdOperationResult -} - -type LeaseGrantReponse struct { - LeaseID int64 -} -type LeaseRevokeResponse struct{} -type DefragmentResponse struct{} - -func Match(r1, r2 EtcdResponse) bool { - return ((r1.ResultUnknown || r2.ResultUnknown) && (r1.Revision == r2.Revision)) || reflect.DeepEqual(r1, r2) -} - -type EtcdOperationResult struct { - KVs []KeyValue - Deleted int64 -} - -type KeyValue struct { - Key string - ValueRevision -} - -var leased = struct{}{} - -type EtcdLease struct { - LeaseID int64 - Keys map[string]struct{} -} -type PossibleStates []EtcdState - -type EtcdState struct { +type etcdState struct { Revision int64 KeyValues map[string]ValueRevision KeyLeases map[string]int64 Leases map[int64]EtcdLease } -type ValueRevision struct { - Value ValueOrHash - ModRevision int64 -} - -type ValueOrHash struct { - Value string - Hash uint32 -} - -func ToValueOrHash(value string) ValueOrHash { - v := ValueOrHash{} - if len(value) < 20 { - v.Value = value - } else { - h := fnv.New32a() - h.Write([]byte(value)) - v.Hash = h.Sum32() +func (s etcdState) Step(request EtcdRequest, response EtcdResponse) (bool, etcdState) { + if s.Revision == 0 { + return true, initState(request, response) } - return v -} - -func describeEtcdRequestResponse(request EtcdRequest, response EtcdResponse) string { - return fmt.Sprintf("%s -> %s", describeEtcdRequest(request), describeEtcdResponse(request, response)) -} - -func describeEtcdResponse(request EtcdRequest, response EtcdResponse) string { - if response.Err != nil { - return fmt.Sprintf("err: %q", response.Err) - } - if response.ResultUnknown { - return fmt.Sprintf("unknown, rev: %d", response.Revision) - } - if request.Type == Txn { - return fmt.Sprintf("%s, rev: %d", describeTxnResponse(request.Txn, response.Txn), response.Revision) - } - if response.Revision == 0 { - return "ok" - } - return fmt.Sprintf("ok, rev: %d", response.Revision) -} - -func describeEtcdRequest(request EtcdRequest) string { - switch request.Type { - case Txn: - describeOperations := describeEtcdOperations(request.Txn.Ops) - if len(request.Txn.Conds) != 0 { - return fmt.Sprintf("if(%s).then(%s)", describeEtcdConditions(request.Txn.Conds), describeOperations) - } - return describeOperations - case LeaseGrant: - return fmt.Sprintf("leaseGrant(%d)", request.LeaseGrant.LeaseID) - case LeaseRevoke: - return fmt.Sprintf("leaseRevoke(%d)", request.LeaseRevoke.LeaseID) - case Defragment: - return fmt.Sprintf("defragment()") - default: - return fmt.Sprintf("", request.Type) - } -} - -func describeEtcdConditions(conds []EtcdCondition) string { - opsDescription := make([]string, len(conds)) - for i := range conds { - opsDescription[i] = fmt.Sprintf("mod_rev(%s)==%d", conds[i].Key, conds[i].ExpectedRevision) - } - return strings.Join(opsDescription, " && ") -} - -func describeEtcdOperations(ops []EtcdOperation) string { - opsDescription := make([]string, len(ops)) - for i := range ops { - opsDescription[i] = describeEtcdOperation(ops[i]) - } - return strings.Join(opsDescription, ", ") -} - -func describeTxnResponse(request *TxnRequest, response *TxnResponse) string { - if response.TxnResult { - return fmt.Sprintf("txn failed") - } - respDescription := make([]string, len(response.OpsResult)) - for i := range response.OpsResult { - respDescription[i] = describeEtcdOperationResponse(request.Ops[i], response.OpsResult[i]) - } - return strings.Join(respDescription, ", ") -} - -func describeEtcdOperation(op EtcdOperation) string { - switch op.Type { - case Range: - if op.WithPrefix { - return fmt.Sprintf("range(%q)", op.Key) - } - return fmt.Sprintf("get(%q)", op.Key) - case Put: - if op.LeaseID != 0 { - return fmt.Sprintf("put(%q, %s, %d)", op.Key, describeValueOrHash(op.Value), op.LeaseID) - } - return fmt.Sprintf("put(%q, %s)", op.Key, describeValueOrHash(op.Value)) - case Delete: - return fmt.Sprintf("delete(%q)", op.Key) - default: - return fmt.Sprintf("", op.Type) - } -} - -func describeEtcdOperationResponse(req EtcdOperation, resp EtcdOperationResult) string { - switch req.Type { - case Range: - if req.WithPrefix { - kvs := make([]string, len(resp.KVs)) - for i, kv := range resp.KVs { - kvs[i] = describeValueOrHash(kv.Value) - } - return fmt.Sprintf("[%s]", strings.Join(kvs, ",")) - } else { - if len(resp.KVs) == 0 { - return "nil" - } else { - return describeValueOrHash(resp.KVs[0].Value) - } - } - case Put: - return fmt.Sprintf("ok") - case Delete: - return fmt.Sprintf("deleted: %d", resp.Deleted) - default: - return fmt.Sprintf("", req.Type) - } -} - -func describeValueOrHash(value ValueOrHash) string { - if value.Hash != 0 { - return fmt.Sprintf("hash: %d", value.Hash) - } - if value.Value == "" { - return "nil" - } - return fmt.Sprintf("%q", value.Value) -} - -func step(states PossibleStates, request EtcdRequest, response EtcdResponse) (bool, PossibleStates) { - if len(states) == 0 { - // states were not initialized - if response.Err != nil || response.ResultUnknown || response.Revision == 0 { - return true, nil - } - return true, PossibleStates{initState(request, response)} - } - if response.Err != nil { - states = applyFailedRequest(states, request) - } else { - states = applyRequest(states, request, response) - } - return len(states) > 0, states + newState, gotResponse := s.step(request) + return reflect.DeepEqual(response, gotResponse), newState } // initState tries to create etcd state based on the first request. -func initState(request EtcdRequest, response EtcdResponse) EtcdState { - state := EtcdState{ +func initState(request EtcdRequest, response EtcdResponse) etcdState { + state := etcdState{ Revision: response.Revision, KeyValues: map[string]ValueRevision{}, KeyLeases: map[string]int64{}, @@ -356,31 +118,8 @@ func initState(request EtcdRequest, response EtcdResponse) EtcdState { return state } -// applyFailedRequest handles a failed requests, one that it's not known if it was persisted or not. -func applyFailedRequest(states PossibleStates, request EtcdRequest) PossibleStates { - for _, s := range states { - newState, _ := applyRequestToSingleState(s, request) - if !reflect.DeepEqual(newState, s) { - states = append(states, newState) - } - } - return states -} - -// applyRequest handles a successful request by applying it to possible states and checking if they match the response. -func applyRequest(states PossibleStates, request EtcdRequest, response EtcdResponse) PossibleStates { - newStates := make(PossibleStates, 0, len(states)) - for _, s := range states { - newState, expectResponse := applyRequestToSingleState(s, request) - if Match(expectResponse, response) { - newStates = append(newStates, newState) - } - } - return newStates -} - -// applyRequestToSingleState handles a successful request, returning updated state and response it would generate. -func applyRequestToSingleState(s EtcdState, request EtcdRequest) (EtcdState, EtcdResponse) { +// step handles a successful request, returning updated state and response it would generate. +func (s etcdState) step(request EtcdRequest) (etcdState, EtcdResponse) { newKVs := map[string]ValueRevision{} for k, v := range s.KeyValues { newKVs[k] = v @@ -480,13 +219,13 @@ func applyRequestToSingleState(s EtcdState, request EtcdRequest) (EtcdState, Etc } return s, EtcdResponse{Revision: s.Revision, LeaseRevoke: &LeaseRevokeResponse{}} case Defragment: - return s, defragmentResponse() + return s, EtcdResponse{Defragment: &DefragmentResponse{}, Revision: s.Revision} default: panic(fmt.Sprintf("Unknown request type: %v", request.Type)) } } -func detachFromOldLease(s EtcdState, key string) EtcdState { +func detachFromOldLease(s etcdState, key string) etcdState { if oldLeaseId, ok := s.KeyLeases[key]; ok { delete(s.Leases[oldLeaseId].Keys, key) delete(s.KeyLeases, key) @@ -494,8 +233,109 @@ func detachFromOldLease(s EtcdState, key string) EtcdState { return s } -func attachToNewLease(s EtcdState, leaseID int64, key string) EtcdState { +func attachToNewLease(s etcdState, leaseID int64, key string) etcdState { s.KeyLeases[key] = leaseID s.Leases[leaseID].Keys[key] = leased return s } + +type RequestType string + +const ( + Txn RequestType = "txn" + LeaseGrant RequestType = "leaseGrant" + LeaseRevoke RequestType = "leaseRevoke" + Defragment RequestType = "defragment" +) + +type EtcdRequest struct { + Type RequestType + LeaseGrant *LeaseGrantRequest + LeaseRevoke *LeaseRevokeRequest + Txn *TxnRequest + Defragment *DefragmentRequest +} + +type TxnRequest struct { + Conds []EtcdCondition + Ops []EtcdOperation +} + +type EtcdCondition struct { + Key string + ExpectedRevision int64 +} + +type EtcdOperation struct { + Type OperationType + Key string + WithPrefix bool + Value ValueOrHash + LeaseID int64 +} + +type LeaseGrantRequest struct { + LeaseID int64 +} +type LeaseRevokeRequest struct { + LeaseID int64 +} +type DefragmentRequest struct{} + +type EtcdResponse struct { + Revision int64 + Txn *TxnResponse + LeaseGrant *LeaseGrantReponse + LeaseRevoke *LeaseRevokeResponse + Defragment *DefragmentResponse +} + +type TxnResponse struct { + TxnResult bool + OpsResult []EtcdOperationResult +} + +type LeaseGrantReponse struct { + LeaseID int64 +} +type LeaseRevokeResponse struct{} +type DefragmentResponse struct{} + +type EtcdOperationResult struct { + KVs []KeyValue + Deleted int64 +} + +type KeyValue struct { + Key string + ValueRevision +} + +var leased = struct{}{} + +type EtcdLease struct { + LeaseID int64 + Keys map[string]struct{} +} + +type ValueRevision struct { + Value ValueOrHash + ModRevision int64 +} + +type ValueOrHash struct { + Value string + Hash uint32 +} + +func ToValueOrHash(value string) ValueOrHash { + v := ValueOrHash{} + if len(value) < 20 { + v.Value = value + } else { + h := fnv.New32a() + h.Write([]byte(value)) + v.Hash = h.Sum32() + } + return v +} diff --git a/tests/robustness/model/deterministic_test.go b/tests/robustness/model/deterministic_test.go new file mode 100644 index 000000000..23ca7e799 --- /dev/null +++ b/tests/robustness/model/deterministic_test.go @@ -0,0 +1,362 @@ +// Copyright 2023 The etcd Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package model + +import ( + "testing" + + "go.etcd.io/etcd/api/v3/mvccpb" +) + +func TestModelBase(t *testing.T) { + type testOperation struct { + req EtcdRequest + resp EtcdResponse + failure bool + } + tcs := []struct { + name string + operations []testOperation + }{ + { + name: "First Get can start from non-empty value and non-zero revision", + operations: []testOperation{ + {req: getRequest("key"), resp: getResponse("key", "1", 42, 42).EtcdResponse}, + {req: getRequest("key"), resp: getResponse("key", "1", 42, 42).EtcdResponse}, + }, + }, + { + name: "First Range can start from non-empty value and non-zero revision", + operations: []testOperation{ + {req: rangeRequest("key", true), resp: rangeResponse([]*mvccpb.KeyValue{{Key: []byte("key"), Value: []byte("1")}}, 42).EtcdResponse}, + {req: rangeRequest("key", true), resp: rangeResponse([]*mvccpb.KeyValue{{Key: []byte("key"), Value: []byte("1")}}, 42).EtcdResponse}, + }, + }, + { + name: "First Range can start from non-zero revision", + operations: []testOperation{ + {req: rangeRequest("key", true), resp: rangeResponse(nil, 1).EtcdResponse}, + {req: rangeRequest("key", true), resp: rangeResponse(nil, 1).EtcdResponse}, + }, + }, + { + name: "First Put can start from non-zero revision", + operations: []testOperation{ + {req: putRequest("key", "1"), resp: putResponse(42).EtcdResponse}, + }, + }, + { + name: "First delete can start from non-zero revision", + operations: []testOperation{ + {req: deleteRequest("key"), resp: deleteResponse(0, 42).EtcdResponse}, + }, + }, + { + name: "First Txn can start from non-zero revision", + operations: []testOperation{ + {req: compareAndSetRequest("key", 0, "42"), resp: compareAndSetResponse(false, 42).EtcdResponse}, + }, + }, + { + name: "Get response data should match put", + operations: []testOperation{ + {req: putRequest("key1", "11"), resp: putResponse(1).EtcdResponse}, + {req: putRequest("key2", "12"), resp: putResponse(2).EtcdResponse}, + {req: getRequest("key1"), resp: getResponse("key1", "11", 1, 1).EtcdResponse, failure: true}, + {req: getRequest("key1"), resp: getResponse("key1", "12", 1, 1).EtcdResponse, failure: true}, + {req: getRequest("key1"), resp: getResponse("key1", "12", 2, 2).EtcdResponse, failure: true}, + {req: getRequest("key1"), resp: getResponse("key1", "11", 1, 2).EtcdResponse}, + {req: getRequest("key2"), resp: getResponse("key2", "11", 2, 2).EtcdResponse, failure: true}, + {req: getRequest("key2"), resp: getResponse("key2", "12", 1, 1).EtcdResponse, failure: true}, + {req: getRequest("key2"), resp: getResponse("key2", "11", 1, 1).EtcdResponse, failure: true}, + {req: getRequest("key2"), resp: getResponse("key2", "12", 2, 2).EtcdResponse}, + }, + }, + { + name: "Range response data should match put", + operations: []testOperation{ + {req: putRequest("key1", "1"), resp: putResponse(1).EtcdResponse}, + {req: putRequest("key2", "2"), resp: putResponse(2).EtcdResponse}, + {req: rangeRequest("key", true), resp: rangeResponse([]*mvccpb.KeyValue{{Key: []byte("key1"), Value: []byte("1"), ModRevision: 1}, {Key: []byte("key2"), Value: []byte("2"), ModRevision: 2}}, 2).EtcdResponse}, + {req: rangeRequest("key", true), resp: rangeResponse([]*mvccpb.KeyValue{{Key: []byte("key1"), Value: []byte("1"), ModRevision: 1}, {Key: []byte("key2"), Value: []byte("2"), ModRevision: 2}}, 2).EtcdResponse}, + }, + }, + { + name: "Range response should be ordered by key", + operations: []testOperation{ + {req: rangeRequest("key", true), resp: rangeResponse([]*mvccpb.KeyValue{ + {Key: []byte("key1"), Value: []byte("2"), ModRevision: 3}, + {Key: []byte("key2"), Value: []byte("1"), ModRevision: 2}, + {Key: []byte("key3"), Value: []byte("3"), ModRevision: 1}, + }, 3).EtcdResponse}, + }, + }, + { + name: "Range response data should match large put", + operations: []testOperation{ + {req: putRequest("key", "012345678901234567890"), resp: putResponse(1).EtcdResponse}, + {req: getRequest("key"), resp: getResponse("key", "123456789012345678901", 1, 1).EtcdResponse, failure: true}, + {req: getRequest("key"), resp: getResponse("key", "012345678901234567890", 1, 1).EtcdResponse}, + {req: putRequest("key", "123456789012345678901"), resp: putResponse(2).EtcdResponse}, + {req: getRequest("key"), resp: getResponse("key", "123456789012345678901", 2, 2).EtcdResponse}, + {req: getRequest("key"), resp: getResponse("key", "012345678901234567890", 2, 2).EtcdResponse, failure: true}, + }, + }, + { + name: "Put must increase revision by 1", + operations: []testOperation{ + {req: getRequest("key"), resp: emptyGetResponse(1).EtcdResponse}, + {req: putRequest("key", "1"), resp: putResponse(1).EtcdResponse, failure: true}, + {req: putRequest("key", "1"), resp: putResponse(3).EtcdResponse, failure: true}, + {req: putRequest("key", "1"), resp: putResponse(2).EtcdResponse}, + }, + }, + { + name: "Delete only increases revision on success", + operations: []testOperation{ + {req: putRequest("key1", "11"), resp: putResponse(1).EtcdResponse}, + {req: putRequest("key2", "12"), resp: putResponse(2).EtcdResponse}, + {req: deleteRequest("key1"), resp: deleteResponse(1, 2).EtcdResponse, failure: true}, + {req: deleteRequest("key1"), resp: deleteResponse(1, 3).EtcdResponse}, + {req: deleteRequest("key1"), resp: deleteResponse(0, 4).EtcdResponse, failure: true}, + {req: deleteRequest("key1"), resp: deleteResponse(0, 3).EtcdResponse}, + }, + }, + { + name: "Delete not existing key", + operations: []testOperation{ + {req: getRequest("key"), resp: emptyGetResponse(1).EtcdResponse}, + {req: deleteRequest("key"), resp: deleteResponse(1, 2).EtcdResponse, failure: true}, + {req: deleteRequest("key"), resp: deleteResponse(0, 1).EtcdResponse}, + }, + }, + { + name: "Delete clears value", + operations: []testOperation{ + {req: getRequest("key"), resp: getResponse("key", "1", 1, 1).EtcdResponse}, + {req: deleteRequest("key"), resp: deleteResponse(1, 2).EtcdResponse}, + {req: getRequest("key"), resp: getResponse("key", "1", 1, 1).EtcdResponse, failure: true}, + {req: getRequest("key"), resp: getResponse("key", "1", 2, 2).EtcdResponse, failure: true}, + {req: getRequest("key"), resp: getResponse("key", "1", 1, 2).EtcdResponse, failure: true}, + {req: getRequest("key"), resp: emptyGetResponse(2).EtcdResponse}, + }, + }, + { + name: "Txn sets new value if value matches expected", + operations: []testOperation{ + {req: getRequest("key"), resp: getResponse("key", "1", 1, 1).EtcdResponse}, + {req: compareAndSetRequest("key", 1, "2"), resp: compareAndSetResponse(true, 1).EtcdResponse, failure: true}, + {req: compareAndSetRequest("key", 1, "2"), resp: compareAndSetResponse(false, 2).EtcdResponse, failure: true}, + {req: compareAndSetRequest("key", 1, "2"), resp: compareAndSetResponse(false, 1).EtcdResponse, failure: true}, + {req: compareAndSetRequest("key", 1, "2"), resp: compareAndSetResponse(true, 2).EtcdResponse}, + {req: getRequest("key"), resp: getResponse("key", "1", 1, 1).EtcdResponse, failure: true}, + {req: getRequest("key"), resp: getResponse("key", "1", 1, 2).EtcdResponse, failure: true}, + {req: getRequest("key"), resp: getResponse("key", "1", 2, 2).EtcdResponse, failure: true}, + {req: getRequest("key"), resp: getResponse("key", "2", 1, 1).EtcdResponse, failure: true}, + {req: getRequest("key"), resp: getResponse("key", "2", 2, 2).EtcdResponse}, + }, + }, + { + name: "Txn can expect on empty key", + operations: []testOperation{ + {req: getRequest("key1"), resp: emptyGetResponse(1).EtcdResponse}, + {req: compareAndSetRequest("key1", 0, "2"), resp: compareAndSetResponse(true, 2).EtcdResponse}, + {req: compareAndSetRequest("key2", 0, "3"), resp: compareAndSetResponse(true, 3).EtcdResponse}, + {req: compareAndSetRequest("key3", 4, "4"), resp: compareAndSetResponse(false, 4).EtcdResponse, failure: true}, + }, + }, + { + name: "Txn doesn't do anything if value doesn't match expected", + operations: []testOperation{ + {req: getRequest("key"), resp: getResponse("key", "1", 1, 1).EtcdResponse}, + {req: compareAndSetRequest("key", 2, "3"), resp: compareAndSetResponse(true, 2).EtcdResponse, failure: true}, + {req: compareAndSetRequest("key", 2, "3"), resp: compareAndSetResponse(true, 1).EtcdResponse, failure: true}, + {req: compareAndSetRequest("key", 2, "3"), resp: compareAndSetResponse(false, 2).EtcdResponse, failure: true}, + {req: compareAndSetRequest("key", 2, "3"), resp: compareAndSetResponse(false, 1).EtcdResponse}, + {req: getRequest("key"), resp: getResponse("key", "2", 1, 1).EtcdResponse, failure: true}, + {req: getRequest("key"), resp: getResponse("key", "2", 2, 2).EtcdResponse, failure: true}, + {req: getRequest("key"), resp: getResponse("key", "3", 1, 1).EtcdResponse, failure: true}, + {req: getRequest("key"), resp: getResponse("key", "3", 1, 2).EtcdResponse, failure: true}, + {req: getRequest("key"), resp: getResponse("key", "3", 2, 2).EtcdResponse, failure: true}, + {req: getRequest("key"), resp: getResponse("key", "1", 1, 1).EtcdResponse}, + }, + }, + { + name: "Put with valid lease id should succeed. Put with invalid lease id should fail", + operations: []testOperation{ + {req: leaseGrantRequest(1), resp: leaseGrantResponse(1).EtcdResponse}, + {req: putWithLeaseRequest("key", "2", 1), resp: putResponse(2).EtcdResponse}, + {req: putWithLeaseRequest("key", "3", 2), resp: putResponse(3).EtcdResponse, failure: true}, + {req: getRequest("key"), resp: getResponse("key", "2", 2, 2).EtcdResponse}, + }, + }, + { + name: "Put with valid lease id should succeed. Put with expired lease id should fail", + operations: []testOperation{ + {req: leaseGrantRequest(1), resp: leaseGrantResponse(1).EtcdResponse}, + {req: putWithLeaseRequest("key", "2", 1), resp: putResponse(2).EtcdResponse}, + {req: getRequest("key"), resp: getResponse("key", "2", 2, 2).EtcdResponse}, + {req: leaseRevokeRequest(1), resp: leaseRevokeResponse(3).EtcdResponse}, + {req: putWithLeaseRequest("key", "4", 1), resp: putResponse(4).EtcdResponse, failure: true}, + {req: getRequest("key"), resp: emptyGetResponse(3).EtcdResponse}, + }, + }, + { + name: "Revoke should increment the revision", + operations: []testOperation{ + {req: leaseGrantRequest(1), resp: leaseGrantResponse(1).EtcdResponse}, + {req: putWithLeaseRequest("key", "2", 1), resp: putResponse(2).EtcdResponse}, + {req: leaseRevokeRequest(1), resp: leaseRevokeResponse(3).EtcdResponse}, + {req: getRequest("key"), resp: emptyGetResponse(3).EtcdResponse}, + }, + }, + { + name: "Put following a PutWithLease will detach the key from the lease", + operations: []testOperation{ + {req: leaseGrantRequest(1), resp: leaseGrantResponse(1).EtcdResponse}, + {req: putWithLeaseRequest("key", "2", 1), resp: putResponse(2).EtcdResponse}, + {req: putRequest("key", "3"), resp: putResponse(3).EtcdResponse}, + {req: leaseRevokeRequest(1), resp: leaseRevokeResponse(3).EtcdResponse}, + {req: getRequest("key"), resp: getResponse("key", "3", 3, 3).EtcdResponse}, + }, + }, + { + name: "Change lease. Revoking older lease should not increment revision", + operations: []testOperation{ + {req: leaseGrantRequest(1), resp: leaseGrantResponse(1).EtcdResponse}, + {req: leaseGrantRequest(2), resp: leaseGrantResponse(1).EtcdResponse}, + {req: putWithLeaseRequest("key", "2", 1), resp: putResponse(2).EtcdResponse}, + {req: putWithLeaseRequest("key", "3", 2), resp: putResponse(3).EtcdResponse}, + {req: leaseRevokeRequest(1), resp: leaseRevokeResponse(3).EtcdResponse}, + {req: getRequest("key"), resp: getResponse("key", "3", 3, 3).EtcdResponse}, + {req: leaseRevokeRequest(2), resp: leaseRevokeResponse(4).EtcdResponse}, + {req: getRequest("key"), resp: emptyGetResponse(4).EtcdResponse}, + }, + }, + { + name: "Update key with same lease", + operations: []testOperation{ + {req: leaseGrantRequest(1), resp: leaseGrantResponse(1).EtcdResponse}, + {req: putWithLeaseRequest("key", "2", 1), resp: putResponse(2).EtcdResponse}, + {req: putWithLeaseRequest("key", "3", 1), resp: putResponse(3).EtcdResponse}, + {req: getRequest("key"), resp: getResponse("key", "3", 3, 3).EtcdResponse}, + }, + }, + { + name: "Deleting a leased key - revoke should not increment revision", + operations: []testOperation{ + {req: leaseGrantRequest(1), resp: leaseGrantResponse(1).EtcdResponse}, + {req: putWithLeaseRequest("key", "2", 1), resp: putResponse(2).EtcdResponse}, + {req: deleteRequest("key"), resp: deleteResponse(1, 3).EtcdResponse}, + {req: leaseRevokeRequest(1), resp: leaseRevokeResponse(4).EtcdResponse, failure: true}, + {req: leaseRevokeRequest(1), resp: leaseRevokeResponse(3).EtcdResponse}, + }, + }, + { + name: "Lease a few keys - revoke should increment revision only once", + operations: []testOperation{ + {req: leaseGrantRequest(1), resp: leaseGrantResponse(1).EtcdResponse}, + {req: putWithLeaseRequest("key1", "1", 1), resp: putResponse(2).EtcdResponse}, + {req: putWithLeaseRequest("key2", "2", 1), resp: putResponse(3).EtcdResponse}, + {req: putWithLeaseRequest("key3", "3", 1), resp: putResponse(4).EtcdResponse}, + {req: putWithLeaseRequest("key4", "4", 1), resp: putResponse(5).EtcdResponse}, + {req: leaseRevokeRequest(1), resp: leaseRevokeResponse(6).EtcdResponse}, + }, + }, + { + name: "Lease some keys then delete some of them. Revoke should increment revision since some keys were still leased", + operations: []testOperation{ + {req: leaseGrantRequest(1), resp: leaseGrantResponse(1).EtcdResponse}, + {req: putWithLeaseRequest("key1", "1", 1), resp: putResponse(2).EtcdResponse}, + {req: putWithLeaseRequest("key2", "2", 1), resp: putResponse(3).EtcdResponse}, + {req: putWithLeaseRequest("key3", "3", 1), resp: putResponse(4).EtcdResponse}, + {req: putWithLeaseRequest("key4", "4", 1), resp: putResponse(5).EtcdResponse}, + {req: deleteRequest("key1"), resp: deleteResponse(1, 6).EtcdResponse}, + {req: deleteRequest("key3"), resp: deleteResponse(1, 7).EtcdResponse}, + {req: deleteRequest("key4"), resp: deleteResponse(1, 8).EtcdResponse}, + {req: leaseRevokeRequest(1), resp: leaseRevokeResponse(9).EtcdResponse}, + {req: deleteRequest("key2"), resp: deleteResponse(0, 9).EtcdResponse}, + {req: getRequest("key1"), resp: emptyGetResponse(9).EtcdResponse}, + {req: getRequest("key2"), resp: emptyGetResponse(9).EtcdResponse}, + {req: getRequest("key3"), resp: emptyGetResponse(9).EtcdResponse}, + {req: getRequest("key4"), resp: emptyGetResponse(9).EtcdResponse}, + }, + }, + { + name: "Lease some keys then delete all of them. Revoke should not increment", + operations: []testOperation{ + {req: leaseGrantRequest(1), resp: leaseGrantResponse(1).EtcdResponse}, + {req: putWithLeaseRequest("key1", "1", 1), resp: putResponse(2).EtcdResponse}, + {req: putWithLeaseRequest("key2", "2", 1), resp: putResponse(3).EtcdResponse}, + {req: putWithLeaseRequest("key3", "3", 1), resp: putResponse(4).EtcdResponse}, + {req: putWithLeaseRequest("key4", "4", 1), resp: putResponse(5).EtcdResponse}, + {req: deleteRequest("key1"), resp: deleteResponse(1, 6).EtcdResponse}, + {req: deleteRequest("key2"), resp: deleteResponse(1, 7).EtcdResponse}, + {req: deleteRequest("key3"), resp: deleteResponse(1, 8).EtcdResponse}, + {req: deleteRequest("key4"), resp: deleteResponse(1, 9).EtcdResponse}, + {req: leaseRevokeRequest(1), resp: leaseRevokeResponse(9).EtcdResponse}, + }, + }, + { + name: "All request types", + operations: []testOperation{ + {req: leaseGrantRequest(1), resp: leaseGrantResponse(1).EtcdResponse}, + {req: putWithLeaseRequest("key", "1", 1), resp: putResponse(2).EtcdResponse}, + {req: leaseRevokeRequest(1), resp: leaseRevokeResponse(3).EtcdResponse}, + {req: putRequest("key", "4"), resp: putResponse(4).EtcdResponse}, + {req: getRequest("key"), resp: getResponse("key", "4", 4, 4).EtcdResponse}, + {req: compareAndSetRequest("key", 4, "5"), resp: compareAndSetResponse(true, 5).EtcdResponse}, + {req: deleteRequest("key"), resp: deleteResponse(1, 6).EtcdResponse}, + {req: defragmentRequest(), resp: defragmentResponse(6).EtcdResponse}, + }, + }, + { + name: "Defragment success between all other request types", + operations: []testOperation{ + {req: defragmentRequest(), resp: defragmentResponse(1).EtcdResponse}, + {req: leaseGrantRequest(1), resp: leaseGrantResponse(1).EtcdResponse}, + {req: defragmentRequest(), resp: defragmentResponse(1).EtcdResponse}, + {req: putWithLeaseRequest("key", "1", 1), resp: putResponse(2).EtcdResponse}, + {req: defragmentRequest(), resp: defragmentResponse(2).EtcdResponse}, + {req: leaseRevokeRequest(1), resp: leaseRevokeResponse(3).EtcdResponse}, + {req: defragmentRequest(), resp: defragmentResponse(3).EtcdResponse}, + {req: putRequest("key", "4"), resp: putResponse(4).EtcdResponse}, + {req: defragmentRequest(), resp: defragmentResponse(4).EtcdResponse}, + {req: getRequest("key"), resp: getResponse("key", "4", 4, 4).EtcdResponse}, + {req: defragmentRequest(), resp: defragmentResponse(4).EtcdResponse}, + {req: compareAndSetRequest("key", 4, "5"), resp: compareAndSetResponse(true, 5).EtcdResponse}, + {req: defragmentRequest(), resp: defragmentResponse(5).EtcdResponse}, + {req: deleteRequest("key"), resp: deleteResponse(1, 6).EtcdResponse}, + {req: defragmentRequest(), resp: defragmentResponse(6).EtcdResponse}, + }, + }, + } + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + state := DeterministicModel.Init() + for _, op := range tc.operations { + t.Logf("state: %v", state) + ok, newState := DeterministicModel.Step(state, op.req, op.resp) + if op.failure == ok { + t.Errorf("Unexpected operation result, expect: %v, got: %v, operation: %s", !op.failure, ok, DeterministicModel.DescribeOperation(op.req, op.resp)) + break + } + if ok { + state = newState + } + } + }) + } +} diff --git a/tests/robustness/model/history.go b/tests/robustness/model/history.go index a38f38902..cda2c1306 100644 --- a/tests/robustness/model/history.go +++ b/tests/robustness/model/history.go @@ -30,7 +30,7 @@ import ( // ValidateOperationHistoryAndReturnVisualize return visualize as porcupine.linearizationInfo used to generate visualization is private. func ValidateOperationHistoryAndReturnVisualize(t *testing.T, lg *zap.Logger, operations []porcupine.Operation) (visualize func(basepath string)) { - linearizable, info := porcupine.CheckOperationsVerbose(Etcd, operations, 5*time.Minute) + linearizable, info := porcupine.CheckOperationsVerbose(NonDeterministicModel, operations, 5*time.Minute) if linearizable == porcupine.Illegal { t.Error("Model is not linearizable") } @@ -39,7 +39,7 @@ func ValidateOperationHistoryAndReturnVisualize(t *testing.T, lg *zap.Logger, op } return func(path string) { lg.Info("Saving visualization", zap.String("path", path)) - err := porcupine.VisualizePath(Etcd, info, path) + err := porcupine.VisualizePath(NonDeterministicModel, info, path) if err != nil { t.Errorf("Failed to visualize, err: %v", err) } @@ -295,11 +295,15 @@ func (h *AppendableHistory) AppendDefragment(start, end time.Duration, resp *cli h.appendFailed(request, start, err) return } + var revision int64 + if resp != nil && resp.Header != nil { + revision = resp.Header.Revision + } h.successful = append(h.successful, porcupine.Operation{ ClientId: h.id, Input: request, Call: start.Nanoseconds(), - Output: defragmentResponse(), + Output: defragmentResponse(revision), Return: end.Nanoseconds(), }) } @@ -325,15 +329,15 @@ func rangeRequest(key string, withPrefix bool) EtcdRequest { return EtcdRequest{Type: Txn, Txn: &TxnRequest{Ops: []EtcdOperation{{Type: Range, Key: key, WithPrefix: withPrefix}}}} } -func emptyGetResponse(revision int64) EtcdResponse { +func emptyGetResponse(revision int64) EtcdNonDeterministicResponse { return rangeResponse([]*mvccpb.KeyValue{}, revision) } -func getResponse(key, value string, modRevision, revision int64) EtcdResponse { +func getResponse(key, value string, modRevision, revision int64) EtcdNonDeterministicResponse { return rangeResponse([]*mvccpb.KeyValue{{Key: []byte(key), Value: []byte(value), ModRevision: modRevision}}, revision) } -func rangeResponse(kvs []*mvccpb.KeyValue, revision int64) EtcdResponse { +func rangeResponse(kvs []*mvccpb.KeyValue, revision int64) EtcdNonDeterministicResponse { result := EtcdOperationResult{KVs: make([]KeyValue, len(kvs))} for i, kv := range kvs { @@ -345,38 +349,38 @@ func rangeResponse(kvs []*mvccpb.KeyValue, revision int64) EtcdResponse { }, } } - return EtcdResponse{Txn: &TxnResponse{OpsResult: []EtcdOperationResult{result}}, Revision: revision} + return EtcdNonDeterministicResponse{EtcdResponse: EtcdResponse{Txn: &TxnResponse{OpsResult: []EtcdOperationResult{result}}, Revision: revision}} } -func failedResponse(err error) EtcdResponse { - return EtcdResponse{Err: err} +func failedResponse(err error) EtcdNonDeterministicResponse { + return EtcdNonDeterministicResponse{Err: err} } -func unknownResponse(revision int64) EtcdResponse { - return EtcdResponse{ResultUnknown: true, Revision: revision} +func unknownResponse(revision int64) EtcdNonDeterministicResponse { + return EtcdNonDeterministicResponse{ResultUnknown: true, EtcdResponse: EtcdResponse{Revision: revision}} } func putRequest(key, value string) EtcdRequest { return EtcdRequest{Type: Txn, Txn: &TxnRequest{Ops: []EtcdOperation{{Type: Put, Key: key, Value: ToValueOrHash(value)}}}} } -func putResponse(revision int64) EtcdResponse { - return EtcdResponse{Txn: &TxnResponse{OpsResult: []EtcdOperationResult{{}}}, Revision: revision} +func putResponse(revision int64) EtcdNonDeterministicResponse { + return EtcdNonDeterministicResponse{EtcdResponse: EtcdResponse{Txn: &TxnResponse{OpsResult: []EtcdOperationResult{{}}}, Revision: revision}} } func deleteRequest(key string) EtcdRequest { return EtcdRequest{Type: Txn, Txn: &TxnRequest{Ops: []EtcdOperation{{Type: Delete, Key: key}}}} } -func deleteResponse(deleted int64, revision int64) EtcdResponse { - return EtcdResponse{Txn: &TxnResponse{OpsResult: []EtcdOperationResult{{Deleted: deleted}}}, Revision: revision} +func deleteResponse(deleted int64, revision int64) EtcdNonDeterministicResponse { + return EtcdNonDeterministicResponse{EtcdResponse: EtcdResponse{Txn: &TxnResponse{OpsResult: []EtcdOperationResult{{Deleted: deleted}}}, Revision: revision}} } func compareAndSetRequest(key string, expectedRevision int64, value string) EtcdRequest { return txnRequest([]EtcdCondition{{Key: key, ExpectedRevision: expectedRevision}}, []EtcdOperation{{Type: Put, Key: key, Value: ToValueOrHash(value)}}) } -func compareAndSetResponse(succeeded bool, revision int64) EtcdResponse { +func compareAndSetResponse(succeeded bool, revision int64) EtcdNonDeterministicResponse { var result []EtcdOperationResult if succeeded { result = []EtcdOperationResult{{}} @@ -388,8 +392,8 @@ func txnRequest(conds []EtcdCondition, onSuccess []EtcdOperation) EtcdRequest { return EtcdRequest{Type: Txn, Txn: &TxnRequest{Conds: conds, Ops: onSuccess}} } -func txnResponse(result []EtcdOperationResult, succeeded bool, revision int64) EtcdResponse { - return EtcdResponse{Txn: &TxnResponse{OpsResult: result, TxnResult: !succeeded}, Revision: revision} +func txnResponse(result []EtcdOperationResult, succeeded bool, revision int64) EtcdNonDeterministicResponse { + return EtcdNonDeterministicResponse{EtcdResponse: EtcdResponse{Txn: &TxnResponse{OpsResult: result, TxnResult: !succeeded}, Revision: revision}} } func putWithLeaseRequest(key, value string, leaseID int64) EtcdRequest { @@ -400,24 +404,24 @@ func leaseGrantRequest(leaseID int64) EtcdRequest { return EtcdRequest{Type: LeaseGrant, LeaseGrant: &LeaseGrantRequest{LeaseID: leaseID}} } -func leaseGrantResponse(revision int64) EtcdResponse { - return EtcdResponse{LeaseGrant: &LeaseGrantReponse{}, Revision: revision} +func leaseGrantResponse(revision int64) EtcdNonDeterministicResponse { + return EtcdNonDeterministicResponse{EtcdResponse: EtcdResponse{LeaseGrant: &LeaseGrantReponse{}, Revision: revision}} } func leaseRevokeRequest(leaseID int64) EtcdRequest { return EtcdRequest{Type: LeaseRevoke, LeaseRevoke: &LeaseRevokeRequest{LeaseID: leaseID}} } -func leaseRevokeResponse(revision int64) EtcdResponse { - return EtcdResponse{LeaseRevoke: &LeaseRevokeResponse{}, Revision: revision} +func leaseRevokeResponse(revision int64) EtcdNonDeterministicResponse { + return EtcdNonDeterministicResponse{EtcdResponse: EtcdResponse{LeaseRevoke: &LeaseRevokeResponse{}, Revision: revision}} } func defragmentRequest() EtcdRequest { return EtcdRequest{Type: Defragment, Defragment: &DefragmentRequest{}} } -func defragmentResponse() EtcdResponse { - return EtcdResponse{Defragment: &DefragmentResponse{}} +func defragmentResponse(revision int64) EtcdNonDeterministicResponse { + return EtcdNonDeterministicResponse{EtcdResponse: EtcdResponse{Defragment: &DefragmentResponse{}, Revision: revision}} } type History struct { diff --git a/tests/robustness/model/non_deterministic.go b/tests/robustness/model/non_deterministic.go new file mode 100644 index 000000000..318b47390 --- /dev/null +++ b/tests/robustness/model/non_deterministic.go @@ -0,0 +1,120 @@ +// Copyright 2022 The etcd Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package model + +import ( + "encoding/json" + "fmt" + "reflect" + + "github.com/anishathalye/porcupine" +) + +type OperationType string + +const ( + Range OperationType = "range" + Put OperationType = "put" + Delete OperationType = "delete" +) + +// NonDeterministicModel extends DeterministicModel to handle requests that have unknown or error response. +// Unknown/error response doesn't inform whether request was persisted or not, so model +// considers both cases. This is represented as multiple equally possible deterministic states. +// Failed requests fork the possible states, while successful requests merge and filter them. +var NonDeterministicModel = porcupine.Model{ + Init: func() interface{} { + var states nonDeterministicState + data, err := json.Marshal(states) + if err != nil { + panic(err) + } + return string(data) + }, + Step: func(st interface{}, in interface{}, out interface{}) (bool, interface{}) { + var states nonDeterministicState + err := json.Unmarshal([]byte(st.(string)), &states) + if err != nil { + panic(err) + } + ok, states := states.Step(in.(EtcdRequest), out.(EtcdNonDeterministicResponse)) + data, err := json.Marshal(states) + if err != nil { + panic(err) + } + return ok, string(data) + }, + DescribeOperation: func(in, out interface{}) string { + return fmt.Sprintf("%s -> %s", describeEtcdRequest(in.(EtcdRequest)), describeEtcdNonDeterministicResponse(in.(EtcdRequest), out.(EtcdNonDeterministicResponse))) + }, +} + +type nonDeterministicState []etcdState + +type EtcdNonDeterministicResponse struct { + EtcdResponse + Err error + ResultUnknown bool +} + +func (states nonDeterministicState) Step(request EtcdRequest, response EtcdNonDeterministicResponse) (bool, nonDeterministicState) { + if len(states) == 0 { + // states were not initialized + if response.Err != nil || response.ResultUnknown || response.Revision == 0 { + return true, nil + } + return true, initNonDeterministicState(request, response) + } + var newStates nonDeterministicState + if response.Err != nil { + newStates = states.stepFailedRequest(request) + } else { + newStates = states.stepSuccessfulRequest(request, response) + } + return len(newStates) > 0, newStates +} + +func initNonDeterministicState(request EtcdRequest, response EtcdNonDeterministicResponse) nonDeterministicState { + return nonDeterministicState{initState(request, response.EtcdResponse)} +} + +// stepFailedRequest duplicates number of states by considering request persisted and lost. +func (states nonDeterministicState) stepFailedRequest(request EtcdRequest) nonDeterministicState { + newStates := make(nonDeterministicState, 0, len(states)*2) + for _, s := range states { + newStates = append(newStates, s) + newState, _ := s.step(request) + if !reflect.DeepEqual(newState, s) { + newStates = append(newStates, newState) + } + } + return newStates +} + +// stepSuccessfulRequest filters possible states by leaving ony states that would respond correctly. +func (states nonDeterministicState) stepSuccessfulRequest(request EtcdRequest, response EtcdNonDeterministicResponse) nonDeterministicState { + newStates := make(nonDeterministicState, 0, len(states)) + for _, s := range states { + newState, gotResponse := s.step(request) + if Match(EtcdNonDeterministicResponse{EtcdResponse: gotResponse}, response) { + newStates = append(newStates, newState) + } + } + return newStates +} + +func Match(r1, r2 EtcdNonDeterministicResponse) bool { + return ((r1.ResultUnknown || r2.ResultUnknown) && (r1.Revision == r2.Revision)) || reflect.DeepEqual(r1, r2) +} diff --git a/tests/robustness/model/model_test.go b/tests/robustness/model/non_deterministic_test.go similarity index 87% rename from tests/robustness/model/model_test.go rename to tests/robustness/model/non_deterministic_test.go index e412eaa24..ec26671a1 100644 --- a/tests/robustness/model/model_test.go +++ b/tests/robustness/model/non_deterministic_test.go @@ -15,17 +15,20 @@ package model import ( - "encoding/json" "errors" "testing" - "github.com/google/go-cmp/cmp" "github.com/stretchr/testify/assert" "go.etcd.io/etcd/api/v3/mvccpb" ) -func TestModelStep(t *testing.T) { +func TestModelNonDeterministic(t *testing.T) { + type testOperation struct { + req EtcdRequest + resp EtcdNonDeterministicResponse + failure bool + } tcs := []struct { name string operations []testOperation @@ -583,27 +586,27 @@ func TestModelStep(t *testing.T) { {req: getRequest("key"), resp: getResponse("key", "4", 4, 4)}, {req: compareAndSetRequest("key", 4, "5"), resp: compareAndSetResponse(true, 5)}, {req: deleteRequest("key"), resp: deleteResponse(1, 6)}, - {req: defragmentRequest(), resp: defragmentResponse()}, + {req: defragmentRequest(), resp: defragmentResponse(6)}, }, }, { name: "Defragment success between all other request types", operations: []testOperation{ - {req: defragmentRequest(), resp: defragmentResponse()}, + {req: defragmentRequest(), resp: defragmentResponse(1)}, {req: leaseGrantRequest(1), resp: leaseGrantResponse(1)}, - {req: defragmentRequest(), resp: defragmentResponse()}, + {req: defragmentRequest(), resp: defragmentResponse(1)}, {req: putWithLeaseRequest("key", "1", 1), resp: putResponse(2)}, - {req: defragmentRequest(), resp: defragmentResponse()}, + {req: defragmentRequest(), resp: defragmentResponse(2)}, {req: leaseRevokeRequest(1), resp: leaseRevokeResponse(3)}, - {req: defragmentRequest(), resp: defragmentResponse()}, + {req: defragmentRequest(), resp: defragmentResponse(3)}, {req: putRequest("key", "4"), resp: putResponse(4)}, - {req: defragmentRequest(), resp: defragmentResponse()}, + {req: defragmentRequest(), resp: defragmentResponse(4)}, {req: getRequest("key"), resp: getResponse("key", "4", 4, 4)}, - {req: defragmentRequest(), resp: defragmentResponse()}, + {req: defragmentRequest(), resp: defragmentResponse(4)}, {req: compareAndSetRequest("key", 4, "5"), resp: compareAndSetResponse(true, 5)}, - {req: defragmentRequest(), resp: defragmentResponse()}, + {req: defragmentRequest(), resp: defragmentResponse(5)}, {req: deleteRequest("key"), resp: deleteResponse(1, 6)}, - {req: defragmentRequest(), resp: defragmentResponse()}, + {req: defragmentRequest(), resp: defragmentResponse(6)}, }, }, { @@ -629,20 +632,12 @@ func TestModelStep(t *testing.T) { } for _, tc := range tcs { t.Run(tc.name, func(t *testing.T) { - state := Etcd.Init() + state := NonDeterministicModel.Init() for _, op := range tc.operations { - ok, newState := Etcd.Step(state, op.req, op.resp) + ok, newState := NonDeterministicModel.Step(state, op.req, op.resp) if ok != !op.failure { - t.Errorf("Unexpected operation result, expect: %v, got: %v, operation: %s", !op.failure, ok, Etcd.DescribeOperation(op.req, op.resp)) - var states PossibleStates - err := json.Unmarshal([]byte(state.(string)), &states) - if err != nil { - panic(err) - } - for _, s := range states { - _, gotResp := applyRequestToSingleState(s, op.req) - t.Logf("For state: %v, diff: %s", state, cmp.Diff(op.resp, gotResp)) - } + t.Logf("state: %v", state) + t.Errorf("Unexpected operation result, expect: %v, got: %v, operation: %s", !op.failure, ok, NonDeterministicModel.DescribeOperation(op.req, op.resp)) } if ok { state = newState @@ -653,118 +648,10 @@ func TestModelStep(t *testing.T) { } } -type testOperation struct { - req EtcdRequest - resp EtcdResponse - failure bool -} - -func TestModelDescribe(t *testing.T) { - tcs := []struct { - req EtcdRequest - resp EtcdResponse - expectDescribe string - }{ - { - req: getRequest("key1"), - resp: emptyGetResponse(1), - expectDescribe: `get("key1") -> nil, rev: 1`, - }, - { - req: getRequest("key2"), - resp: getResponse("key", "2", 2, 2), - expectDescribe: `get("key2") -> "2", rev: 2`, - }, - { - req: getRequest("key2b"), - resp: getResponse("key2b", "01234567890123456789", 2, 2), - expectDescribe: `get("key2b") -> hash: 2945867837, rev: 2`, - }, - { - req: putRequest("key3", "3"), - resp: putResponse(3), - expectDescribe: `put("key3", "3") -> ok, rev: 3`, - }, - { - req: putWithLeaseRequest("key3b", "3b", 3), - resp: putResponse(3), - expectDescribe: `put("key3b", "3b", 3) -> ok, rev: 3`, - }, - { - req: putRequest("key3c", "01234567890123456789"), - resp: putResponse(3), - expectDescribe: `put("key3c", hash: 2945867837) -> ok, rev: 3`, - }, - { - req: putRequest("key4", "4"), - resp: failedResponse(errors.New("failed")), - expectDescribe: `put("key4", "4") -> err: "failed"`, - }, - { - req: putRequest("key4b", "4b"), - resp: unknownResponse(42), - expectDescribe: `put("key4b", "4b") -> unknown, rev: 42`, - }, - { - req: deleteRequest("key5"), - resp: deleteResponse(1, 5), - expectDescribe: `delete("key5") -> deleted: 1, rev: 5`, - }, - { - req: deleteRequest("key6"), - resp: failedResponse(errors.New("failed")), - expectDescribe: `delete("key6") -> err: "failed"`, - }, - { - req: compareAndSetRequest("key7", 7, "77"), - resp: compareAndSetResponse(false, 7), - expectDescribe: `if(mod_rev(key7)==7).then(put("key7", "77")) -> txn failed, rev: 7`, - }, - { - req: compareAndSetRequest("key8", 8, "88"), - resp: compareAndSetResponse(true, 8), - expectDescribe: `if(mod_rev(key8)==8).then(put("key8", "88")) -> ok, rev: 8`, - }, - { - req: compareAndSetRequest("key9", 9, "99"), - resp: failedResponse(errors.New("failed")), - expectDescribe: `if(mod_rev(key9)==9).then(put("key9", "99")) -> err: "failed"`, - }, - { - req: txnRequest(nil, []EtcdOperation{{Type: Range, Key: "10"}, {Type: Put, Key: "11", Value: ValueOrHash{Value: "111"}}, {Type: Delete, Key: "12"}}), - resp: txnResponse([]EtcdOperationResult{{KVs: []KeyValue{{ValueRevision: ValueRevision{Value: ValueOrHash{Value: "110"}}}}}, {}, {Deleted: 1}}, true, 10), - expectDescribe: `get("10"), put("11", "111"), delete("12") -> "110", ok, deleted: 1, rev: 10`, - }, - { - req: defragmentRequest(), - resp: defragmentResponse(), - expectDescribe: `defragment() -> ok`, - }, - { - req: rangeRequest("key11", true), - resp: rangeResponse(nil, 11), - expectDescribe: `range("key11") -> [], rev: 11`, - }, - { - req: rangeRequest("key12", true), - resp: rangeResponse([]*mvccpb.KeyValue{{Value: []byte("12")}}, 12), - expectDescribe: `range("key12") -> ["12"], rev: 12`, - }, - { - req: rangeRequest("key13", true), - resp: rangeResponse([]*mvccpb.KeyValue{{Value: []byte("01234567890123456789")}}, 13), - expectDescribe: `range("key13") -> [hash: 2945867837], rev: 13`, - }, - } - for _, tc := range tcs { - assert.Equal(t, tc.expectDescribe, Etcd.DescribeOperation(tc.req, tc.resp)) - } -} - func TestModelResponseMatch(t *testing.T) { tcs := []struct { - resp1 EtcdResponse - resp2 EtcdResponse + resp1 EtcdNonDeterministicResponse + resp2 EtcdNonDeterministicResponse expectMatch bool }{ { diff --git a/tests/robustness/watch.go b/tests/robustness/watch.go index 836e0ce2d..89ad5bf48 100644 --- a/tests/robustness/watch.go +++ b/tests/robustness/watch.go @@ -269,7 +269,7 @@ func patchOperationBasedOnWatchEvents(operations []porcupine.Operation, watchEve for _, op := range operations { request := op.Input.(model.EtcdRequest) - resp := op.Output.(model.EtcdResponse) + resp := op.Output.(model.EtcdNonDeterministicResponse) if resp.Err == nil || op.Call > lastObservedOperation.Call || request.Type != model.Txn { // Cannot patch those requests. newOperations = append(newOperations, op) @@ -279,8 +279,8 @@ func patchOperationBasedOnWatchEvents(operations []porcupine.Operation, watchEve if event != nil { // Set revision and time based on watchEvent. op.Return = event.Time.UnixNano() - op.Output = model.EtcdResponse{ - Revision: event.Revision, + op.Output = model.EtcdNonDeterministicResponse{ + EtcdResponse: model.EtcdResponse{Revision: event.Revision}, ResultUnknown: true, } newOperations = append(newOperations, op)