Skip to content

Commit 036b633

Browse files
committed
Add local storage fault profile
1 parent bdf85d7 commit 036b633

6 files changed

Lines changed: 349 additions & 11 deletions

File tree

MODEL_EQ_REPORT.MD

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,22 +26,23 @@ This report maps the current finite TLA+ safety model to the current Go implemen
2626
| `tla/Quorum.tla:9` and `tla/Quorum.tla:55` TLA quorum formulas/intersections | `epaxos/quorum.go:15` Go quorum calculation | Both define supported cluster sizes one through seven, majority slow quorum `n/2 + 1`, conservative fast quorum `n - ((n - 1) / 4)`, explicit expected tables, and quorum-intersection invariants checked by TLC and Go tests. |
2727
| `tla/EPaxosKVConflict.cfg:11` selective command set | `examples/kv/kv.go:368` Go transaction conflict-key construction | The KV conflict TLC config models independent single-key commands and a transaction-like command that conflicts with both keys; atomic multi-key apply and duplicate-key payload ordering remain implementation-tested at the example storage layer. |
2828
| Not represented in current TLA safety model | `examples/kv/kv.go:305` Go scan implementation | Advanced scan ordering, reverse ordering, timestamp collision behavior, and Pebble durability are example storage semantics verified by Go tests rather than by the current TLA state. |
29-
| Not represented in current TLA safety model | `examples/kv/cmd/kvnode/main.go:259` transport fault control and `jepsen/src/moreconsensus/epaxos_test.clj:183` transport nemesis | Local transport partition control is an external validation mechanism for the KV example and Jepsen workload rather than modeled state. |
29+
| Not represented in current TLA safety model | `examples/kv/cmd/kvnode/main.go:285` transport fault control, `examples/kv/cmd/kvnode/main.go:311` storage fault control, `jepsen/src/moreconsensus/epaxos_test.clj:196` transport nemesis, and `jepsen/src/moreconsensus/epaxos_test.clj:224` storage nemesis | Local transport partition and storage-unavailable controls are external validation mechanisms for the KV example and Jepsen workload rather than modeled state. |
3030

3131
## Current verification evidence
3232

33-
- Observed `tests/ci.sh` passing after the hardening changes. The gate ran root Go tests, root coverage at 100.0%, example Go tests, example coverage at 100.0%, tagged kvnode tests, root/example/kvnode race checks, TLC model checks, the local Jepsen restart profile, and the repository text audit.
33+
- Observed `tests/ci.sh` passing after the storage-fault hardening changes. The gate ran root Go tests, root coverage at 100.0%, example Go tests, example coverage at 100.0%, tagged kvnode tests, root/example/kvnode race checks, TLC model checks, local Jepsen restart, transport, and storage profiles, and the repository text audit.
3434
- Observed `go test ./epaxos` passing with deterministic randomized simulation coverage for three- and five-node clusters, duplicate/drop/reorder delivery, logical ticks, restarts, dependency-vector prefix execution ordering, Ready/Advance durability, allocation/pool ownership assertions, decoder fuzz seeds, and decoder error cleanup.
35-
- Observed `go test ./examples/kv` and `go test -tags kvnode ./examples/kv/cmd/kvnode` passing with KV duplicate-key transaction payload-order semantics, deduplicated EPAXOS conflict keys, post-Advance executed-record persistence, negative scan-limit rejection, and transport fault-route coverage.
35+
- Observed `go test ./examples/kv` and `go test -tags kvnode ./examples/kv/cmd/kvnode` passing with KV duplicate-key transaction payload-order semantics, deduplicated EPAXOS conflict keys, post-Advance executed-record persistence, negative scan-limit rejection, transport fault-route coverage, and storage fault-route coverage that rejects work before RawNode progress.
3636
- Observed `go test ./epaxos -coverprofile=coverage.out -count=1` reporting 100.0% statement coverage and `go tool cover -func=coverage.out` reporting `Advance` at 100.0% after the executed-record acknowledgement cap test.
3737
- Observed `tests/tla_model_check.sh` running TLC for `tla/EPaxos.cfg` with 11162 generated states and 2002 distinct states, `tla/EPaxosKVConflict.cfg` with 3042650 generated states and 166034 distinct states, `tla/EPaxosThreeReplica.cfg` with 1104121 generated states and 123821 distinct states, and `tla/Quorum.cfg` with 2 generated states and 1 distinct state; all completed with no invariant violation.
38-
- Observed targeted `lein test moreconsensus.epaxos-test-test` from `jepsen` passing 22 tests with 57 assertions for register delete normalization, indeterminate mutation response classification, transaction body encoding, grouped reads, scan shape checking, restart fault selection, transport fault selection, client/nemesis routing, restart nemesis behavior, and transport nemesis control requests.
39-
- Observed `JEPSEN_LOCAL_FAULTS=transport tests/jepsen_local.sh` reporting register `:linearizable {:valid? true}`, scan shape `:scan-shape {:valid? true, :checked 17, :bad-count 0}`, transaction `:txn-atomic {:valid? true, :checked 8, :bad-count 0}`, and overall `:valid? true`.
40-
- Observed `tests/ci.sh` local restart profile reporting register `:linearizable {:valid? true}`, scan shape `:scan-shape {:valid? true, :checked 4, :bad-count 0}`, transaction `:txn-atomic {:valid? true, :checked 2, :bad-count 0}`, and overall `:valid? true`.
38+
- Observed targeted `lein test moreconsensus.epaxos-test-test` from `jepsen` passing 26 tests with 67 assertions for register delete normalization, indeterminate mutation response classification, transaction body encoding, grouped reads, scan shape checking, restart fault selection, transport fault selection, storage fault selection, client/nemesis routing, restart nemesis behavior, transport nemesis control requests, and storage nemesis control requests.
39+
- Observed `tests/ci.sh` local transport profile reporting register `:linearizable {:valid? true}`, scan shape `:scan-shape {:valid? true, :checked 11, :bad-count 0}`, transaction `:txn-atomic {:valid? true, :checked 9, :bad-count 0}`, and overall `:valid? true`.
40+
- Observed `tests/ci.sh` local restart profile reporting register `:linearizable {:valid? true}`, scan shape `:scan-shape {:valid? true, :checked 6, :bad-count 0}`, transaction `:txn-atomic {:valid? true, :checked 2, :bad-count 0}`, and overall `:valid? true`.
41+
- Observed `tests/ci.sh` local storage profile reporting register `:linearizable {:valid? true}`, scan shape `:scan-shape {:valid? true, :checked 5, :bad-count 0}`, transaction `:txn-atomic {:valid? true, :checked 5, :bad-count 0}`, and overall `:valid? true`.
4142
- Observed repository text audit with no disallowed external-project name, scaffolding marker, or wall-clock API matches.
4243

4344
## Open correspondence risks
4445

4546
- The TLA+ model is finite-bounded for TLC, so it demonstrates correspondence for the configured state space rather than unbounded proof.
46-
- Checksum, wire-decoder tolerance, storage virtualization, recovery response collection, transport fault control, and example KV apply/scan storage semantics are verified by implementation and Jepsen tests but outside the current TLA safety state.
47-
- Local Jepsen now covers loopback process restart and transport partition profiles, but it still lacks SSH-managed multi-host deployment, disk-fault nemeses, and long-duration operational coverage.
47+
- Checksum, wire-decoder tolerance, storage virtualization, recovery response collection, transport fault control, storage fault control, and example KV apply/scan storage semantics are verified by implementation and Jepsen tests but outside the current TLA safety state.
48+
- Local Jepsen now covers loopback process restart, transport partition, and storage-unavailable profiles, but it still lacks SSH-managed multi-host deployment, destructive disk nemeses, and long-duration operational coverage.

examples/kv/cmd/kvnode/main.go

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ type service struct {
3838
sendq chan epaxos.Message
3939
nextSeq uint64
4040
transportDrops map[transportLink]struct{}
41+
storageFailed bool
4142
}
4243

4344
type transportLink struct {
@@ -51,6 +52,10 @@ type transportFaultRequest struct {
5152
Drop bool `json:"drop"`
5253
}
5354

55+
type storageFaultRequest struct {
56+
Fail bool `json:"fail"`
57+
}
58+
5459
func main() {
5560
idFlag := flag.Uint64("id", 1, "replica id")
5661
listen := flag.String("listen", ":8080", "HTTP listen address")
@@ -81,6 +86,7 @@ func main() {
8186
mux.HandleFunc("/scan", s.handleScan)
8287
mux.HandleFunc("/epaxos/message", s.handleMessage)
8388
mux.HandleFunc("/faults/transport", s.handleTransportFault)
89+
mux.HandleFunc("/faults/storage", s.handleStorageFault)
8490
mux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("ok")) })
8591
log.Printf("kvnode %d listening on %s", s.id, *listen)
8692
log.Fatal(http.ListenAndServe(*listen, mux))
@@ -117,6 +123,10 @@ func (s *service) handleKV(w http.ResponseWriter, r *http.Request) {
117123
key := []byte(keyText)
118124
switch r.Method {
119125
case http.MethodGet:
126+
if s.storageFaultActive() {
127+
http.Error(w, "storage fault active", http.StatusServiceUnavailable)
128+
return
129+
}
120130
if err := s.waitForKeys(r.Context(), key); err != nil {
121131
http.Error(w, err.Error(), http.StatusServiceUnavailable)
122132
return
@@ -132,6 +142,10 @@ func (s *service) handleKV(w http.ResponseWriter, r *http.Request) {
132142
}
133143
_, _ = w.Write(value)
134144
case http.MethodPut:
145+
if s.storageFaultActive() {
146+
http.Error(w, "storage fault active", http.StatusServiceUnavailable)
147+
return
148+
}
135149
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
136150
if err != nil {
137151
http.Error(w, err.Error(), http.StatusBadRequest)
@@ -143,6 +157,10 @@ func (s *service) handleKV(w http.ResponseWriter, r *http.Request) {
143157
}
144158
w.WriteHeader(http.StatusNoContent)
145159
case http.MethodDelete:
160+
if s.storageFaultActive() {
161+
http.Error(w, "storage fault active", http.StatusServiceUnavailable)
162+
return
163+
}
146164
if err := s.proposeAndWait(r.Context(), kv.CommandForDelete(uint64(s.id), s.next(), key)); err != nil {
147165
http.Error(w, err.Error(), http.StatusServiceUnavailable)
148166
return
@@ -181,6 +199,10 @@ func (s *service) handleTxn(w http.ResponseWriter, r *http.Request) {
181199
}
182200
ops = append(ops, kv.TxnOp{Delete: op.Delete, Key: []byte(op.Key), Value: []byte(op.Value)})
183201
}
202+
if s.storageFaultActive() {
203+
http.Error(w, "storage fault active", http.StatusServiceUnavailable)
204+
return
205+
}
184206
if err := s.proposeAndWait(r.Context(), kv.CommandForTxn(uint64(s.id), s.next(), ops)); err != nil {
185207
http.Error(w, err.Error(), http.StatusServiceUnavailable)
186208
return
@@ -222,6 +244,10 @@ func (s *service) handleScan(w http.ResponseWriter, r *http.Request) {
222244
}
223245
reverse = v
224246
}
247+
if s.storageFaultActive() {
248+
http.Error(w, "storage fault active", http.StatusServiceUnavailable)
249+
return
250+
}
225251
if raw := q.Get("barrier"); raw != "" {
226252
parts := strings.Split(raw, ",")
227253
keys := make([][]byte, 0, len(parts))
@@ -282,6 +308,39 @@ func (s *service) handleTransportFault(w http.ResponseWriter, r *http.Request) {
282308
}
283309
}
284310

311+
func (s *service) handleStorageFault(w http.ResponseWriter, r *http.Request) {
312+
switch r.Method {
313+
case http.MethodGet:
314+
w.Header().Set("Content-Type", "application/json")
315+
_ = json.NewEncoder(w).Encode(storageFaultRequest{Fail: s.storageFaultActive()})
316+
case http.MethodPost:
317+
var req storageFaultRequest
318+
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req); err != nil {
319+
http.Error(w, err.Error(), http.StatusBadRequest)
320+
return
321+
}
322+
s.setStorageFault(req.Fail)
323+
w.WriteHeader(http.StatusNoContent)
324+
case http.MethodDelete:
325+
s.setStorageFault(false)
326+
w.WriteHeader(http.StatusNoContent)
327+
default:
328+
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
329+
}
330+
}
331+
332+
func (s *service) setStorageFault(failed bool) {
333+
s.faultMu.Lock()
334+
defer s.faultMu.Unlock()
335+
s.storageFailed = failed
336+
}
337+
338+
func (s *service) storageFaultActive() bool {
339+
s.faultMu.RLock()
340+
defer s.faultMu.RUnlock()
341+
return s.storageFailed
342+
}
343+
285344
func (s *service) setTransportDrop(from, to epaxos.ReplicaID, drop bool) {
286345
s.faultMu.Lock()
287346
defer s.faultMu.Unlock()
@@ -342,12 +401,20 @@ func (s *service) handleMessage(w http.ResponseWriter, r *http.Request) {
342401
http.Error(w, "transport link dropped", http.StatusConflict)
343402
return
344403
}
404+
if s.storageFaultActive() {
405+
http.Error(w, "storage fault active", http.StatusServiceUnavailable)
406+
return
407+
}
345408
s.mu.Lock()
346409
err = s.node.Step(msg)
347410
out, drainErr := s.drainLocked()
348411
s.mu.Unlock()
349-
if err != nil || drainErr != nil {
350-
http.Error(w, errorsJoin(err, drainErr).Error(), http.StatusBadRequest)
412+
if drainErr != nil {
413+
http.Error(w, errorsJoin(err, drainErr).Error(), http.StatusServiceUnavailable)
414+
return
415+
}
416+
if err != nil {
417+
http.Error(w, err.Error(), http.StatusBadRequest)
351418
return
352419
}
353420
s.send(out)
@@ -377,6 +444,9 @@ func (s *service) waitForKeys(ctx context.Context, keys ...[]byte) error {
377444
}
378445

379446
func (s *service) proposeAndWait(ctx context.Context, cmd epaxos.Command) error {
447+
if s.storageFaultActive() {
448+
return fmt.Errorf("storage fault active")
449+
}
380450
s.mu.Lock()
381451
ref, err := s.node.Propose(cmd)
382452
out, drainErr := s.drainLocked()

examples/kv/cmd/kvnode/main_test.go

Lines changed: 140 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,6 @@ func TestHandleTxnAcceptsDuplicateKeysWithFinalDelete(t *testing.T) {
170170
}
171171
}
172172

173-
174173
func TestHandleKVRepeatedPutWaitsForNewAppliedRef(t *testing.T) {
175174
s := newTestService(t)
176175
for i := 1; i <= 2; i++ {
@@ -302,6 +301,122 @@ func TestHandleMessageRejectsMalformedTransportPayload(t *testing.T) {
302301
}
303302
}
304303

304+
func TestHandleStorageFaultSetsListsAndClearsFailure(t *testing.T) {
305+
s := newTestService(t)
306+
307+
requireStorageFault(t, s, false)
308+
309+
set := httptest.NewRecorder()
310+
s.handleStorageFault(set, httptest.NewRequest(http.MethodPost, "/faults/storage", bytes.NewReader([]byte(`{"fail":true}`))))
311+
if set.Code != http.StatusNoContent {
312+
t.Fatalf("set status=%d body=%q", set.Code, set.Body.String())
313+
}
314+
requireStorageFault(t, s, true)
315+
316+
clearByPost := httptest.NewRecorder()
317+
s.handleStorageFault(clearByPost, httptest.NewRequest(http.MethodPost, "/faults/storage", bytes.NewReader([]byte(`{"fail":false}`))))
318+
if clearByPost.Code != http.StatusNoContent {
319+
t.Fatalf("clear post status=%d body=%q", clearByPost.Code, clearByPost.Body.String())
320+
}
321+
requireStorageFault(t, s, false)
322+
323+
setAgain := httptest.NewRecorder()
324+
s.handleStorageFault(setAgain, httptest.NewRequest(http.MethodPost, "/faults/storage", bytes.NewReader([]byte(`{"fail":true}`))))
325+
if setAgain.Code != http.StatusNoContent {
326+
t.Fatalf("set again status=%d body=%q", setAgain.Code, setAgain.Body.String())
327+
}
328+
329+
clearByDelete := httptest.NewRecorder()
330+
s.handleStorageFault(clearByDelete, httptest.NewRequest(http.MethodDelete, "/faults/storage", nil))
331+
if clearByDelete.Code != http.StatusNoContent {
332+
t.Fatalf("delete status=%d body=%q", clearByDelete.Code, clearByDelete.Body.String())
333+
}
334+
requireStorageFault(t, s, false)
335+
}
336+
337+
func TestHandleStorageFaultRejectsMalformedRequests(t *testing.T) {
338+
tests := []struct {
339+
name string
340+
method string
341+
body []byte
342+
want int
343+
}{
344+
{name: "wrong method", method: http.MethodPut, want: http.StatusMethodNotAllowed},
345+
{name: "malformed json", method: http.MethodPost, body: []byte(`{"fail":`), want: http.StatusBadRequest},
346+
}
347+
for _, tc := range tests {
348+
t.Run(tc.name, func(t *testing.T) {
349+
s := newTestService(t)
350+
s.setStorageFault(true)
351+
rr := httptest.NewRecorder()
352+
s.handleStorageFault(rr, httptest.NewRequest(tc.method, "/faults/storage", bytes.NewReader(tc.body)))
353+
if rr.Code != tc.want {
354+
t.Fatalf("status=%d body=%q", rr.Code, rr.Body.String())
355+
}
356+
requireStorageFault(t, s, true)
357+
})
358+
}
359+
}
360+
361+
func TestStorageFaultRejectsClientRequestsBeforeConsensusProgress(t *testing.T) {
362+
tests := []struct {
363+
name string
364+
method string
365+
target string
366+
body []byte
367+
handle func(*service, http.ResponseWriter, *http.Request)
368+
}{
369+
{name: "put", method: http.MethodPut, target: "/kv/alpha", body: []byte("one"), handle: (*service).handleKV},
370+
{name: "delete", method: http.MethodDelete, target: "/kv/alpha", handle: (*service).handleKV},
371+
{name: "get", method: http.MethodGet, target: "/kv/alpha", handle: (*service).handleKV},
372+
{name: "txn", method: http.MethodPost, target: "/txn", body: []byte(`[{"key":"alpha","value":"one"}]`), handle: (*service).handleTxn},
373+
{name: "scan barrier", method: http.MethodGet, target: "/scan?barrier=alpha", handle: (*service).handleScan},
374+
}
375+
for _, tc := range tests {
376+
t.Run(tc.name, func(t *testing.T) {
377+
s := newTestService(t)
378+
set := httptest.NewRecorder()
379+
s.handleStorageFault(set, httptest.NewRequest(http.MethodPost, "/faults/storage", bytes.NewReader([]byte(`{"fail":true}`))))
380+
if set.Code != http.StatusNoContent {
381+
t.Fatalf("set fault status=%d body=%q", set.Code, set.Body.String())
382+
}
383+
384+
rr := httptest.NewRecorder()
385+
tc.handle(s, rr, httptest.NewRequest(tc.method, tc.target, bytes.NewReader(tc.body)))
386+
if rr.Code != http.StatusServiceUnavailable || rr.Body.String() != "storage fault active\n" {
387+
t.Fatalf("status=%d body=%q", rr.Code, rr.Body.String())
388+
}
389+
requireNoConsensusProgress(t, s)
390+
})
391+
}
392+
}
393+
394+
func TestStorageFaultRejectsInboundMessageBeforeSteppingNode(t *testing.T) {
395+
s := newTestClusterService(t, []epaxos.ReplicaID{1, 2})
396+
s.setStorageFault(true)
397+
msg := epaxos.Message{
398+
Type: epaxos.MsgPreAccept,
399+
From: 2,
400+
To: 1,
401+
Ref: epaxos.InstanceRef{Replica: 2, Instance: 1, Conf: 1},
402+
Ballot: epaxos.Ballot{Replica: 2},
403+
Seq: 1,
404+
Deps: []epaxos.InstanceNum{0, 0},
405+
Command: epaxos.Command{ID: epaxos.CommandID{Client: 2, Sequence: 1}, ConflictKeys: [][]byte{[]byte("blocked")}},
406+
}
407+
buf, err := epaxos.EncodeMessage(nil, msg)
408+
if err != nil {
409+
t.Fatal(err)
410+
}
411+
412+
rr := httptest.NewRecorder()
413+
s.handleMessage(rr, httptest.NewRequest(http.MethodPost, "/epaxos/message", bytes.NewReader(buf)))
414+
if rr.Code != http.StatusServiceUnavailable || rr.Body.String() != "storage fault active\n" {
415+
t.Fatalf("status=%d body=%q", rr.Code, rr.Body.String())
416+
}
417+
requireNoConsensusProgress(t, s)
418+
}
419+
305420
func TestHandleTransportFaultSetsListsAndClearsDroppedLinks(t *testing.T) {
306421
s := newTestService(t)
307422

@@ -422,6 +537,30 @@ func TestHandleMessageDropsConfiguredInboundTransportLinkBeforeSteppingNode(t *t
422537
}
423538
}
424539

540+
func requireStorageFault(t *testing.T, s *service, want bool) {
541+
t.Helper()
542+
rr := httptest.NewRecorder()
543+
s.handleStorageFault(rr, httptest.NewRequest(http.MethodGet, "/faults/storage", nil))
544+
if rr.Code != http.StatusOK {
545+
t.Fatalf("list status=%d body=%q", rr.Code, rr.Body.String())
546+
}
547+
var got storageFaultRequest
548+
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
549+
t.Fatal(err)
550+
}
551+
if got.Fail != want {
552+
t.Fatalf("storage fault active=%t, want %t", got.Fail, want)
553+
}
554+
}
555+
556+
func requireNoConsensusProgress(t *testing.T, s *service) {
557+
t.Helper()
558+
status := s.node.Status()
559+
if len(status.Instances) != 0 || len(status.Executed) != 0 {
560+
t.Fatalf("node status after storage fault: instances=%v executed=%v", status.Instances, status.Executed)
561+
}
562+
}
563+
425564
func requireTransportDrops(t *testing.T, body []byte, want []transportFaultRequest) {
426565
t.Helper()
427566
var got []transportFaultRequest

0 commit comments

Comments
 (0)