Skip to content

Commit 4ddb0bb

Browse files
committed
Harden Ready advancement and KV durability
1 parent 94b5776 commit 4ddb0bb

14 files changed

Lines changed: 883 additions & 74 deletions

EPAXOS.MD

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,9 +78,11 @@ User commands are emitted to the embedding application through `Ready.Committed`
7878

7979
## Ready contract
8080

81-
`Ready.Records` are durable consensus state. `Ready.Messages` are transport work. `Ready.Committed` contains user commands that have passed dependency execution and must be applied by the embedding application before they are acknowledged. The embedding application must persist records before sending messages from the same `Ready`. `MustSync` is true when durable records are present.
81+
`Ready.Records` are durable consensus state. `Ready.Messages` are transport work. `Ready.Committed` contains user commands that have passed dependency execution and must be applied by the embedding application before they are acknowledged. The embedding application must persist every record in a returned `Ready` before sending messages or acknowledging committed commands from that same batch. `MustSync` is true when durable records are present.
8282

83-
`Advance` acknowledges the obligations in the previously returned `Ready`. For user commands, `Advance` queues the corresponding `StatusExecuted` records only after the application has observed `Ready.Committed`; those executed markers appear in a later `Ready.Records` batch and make replay after restart idempotent. Configuration changes and no-op commands do not appear in `Ready.Committed`; their executed records are queued internally when they execute.
83+
`Advance` returns an error and acknowledges only an exact prefix of the outstanding `Ready`. The acknowledged values must match the records, messages, committed commands, and `MustSync` bit returned by `Ready`; otherwise `Advance` returns `ErrInvalidReady` and leaves the outstanding batch unchanged for retry. A record-only prefix is valid, which lets storage persist part of a batch without marking application commands executed. `Messages` or `Committed` acknowledgements are accepted only after all earlier `Records` from that outstanding batch are acknowledged. When `MaxReadyMessages` caps `Ready.Messages`, records and committed commands still appear in full; after their successful acknowledgement the remaining message tail appears in later `Ready` batches with `MustSync` false.
84+
85+
For user commands, `Advance` queues the corresponding `StatusExecuted` records only after the application has observed and acknowledged `Ready.Committed`; those executed markers appear in a later `Ready.Records` batch and make replay after restart idempotent. Configuration changes and no-op commands do not appear in `Ready.Committed`; their executed records are queued internally when they execute.
8486

8587
## Checksums and serialization
8688

MODEL_EQ_REPORT.MD

Lines changed: 19 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,19 @@ This report maps the current finite TLA+ safety model to the current Go implemen
66

77
| Model anchor | Implementation anchor | Evidence |
88
| --- | --- | --- |
9-
| `tla/EPaxos.tla:32` TLA Init | `epaxos/node.go:98` Go node construction | Initialization sets empty instance state before transport steps; Go also restores durable state and configuration history before accepting input. |
9+
| `tla/EPaxos.tla:32` TLA Init | `epaxos/node.go:99` Go node construction | Initialization sets empty instance state before transport steps; Go also restores durable state and configuration history before accepting input. |
1010
| `tla/EPaxos.tla:9` TLA Conflicts | `epaxos/types.go:150` Go command conflict predicate and `examples/kv/kv.go:368` KV transaction command construction | The model configures command membership in conflict-key sets; Go conflicts commands by shared conflict-key bytes, and KV transactions deduplicate EPAXOS conflict keys while preserving every payload operation in order. |
11-
| `tla/EPaxos.tla:18` TLA SafeDeps | `epaxos/node.go:729` Go attribute computation | Both model and implementation require known conflicting commands to appear in dependency attributes. |
12-
| `tla/EPaxos.tla:42` TLA PreAccept | `epaxos/node.go:257` Go proposal path | Local proposal computes attributes, persists pre-accepted records, indexes conflicts, and emits pre-accept messages. |
13-
| `tla/EPaxos.tla:42` TLA PreAccept receive | `epaxos/node.go:422` Go pre-accept handler | Remote pre-accept merges local conflicts with proposer attributes and stores the pre-accepted value. |
14-
| `tla/EPaxos.tla:54` TLA Accept | `epaxos/node.go:478` Go accept handler | Accept stores slow-path attributes unless the instance is already committed or promised higher. |
15-
| `tla/EPaxos.tla:63` TLA Commit | `epaxos/node.go:522` Go commit handler | Commit stores final value/attributes and invokes dependency-closed execution. |
16-
| `tla/EPaxos.tla:69` TLA Prepare | `epaxos/node.go:535` Go prepare handler | Prepare persists higher promises and returns local accepted/committed state for recovery; TLA abstracts this as the ballot-raising recovery step. |
17-
| Not represented in current TLA safety model | `epaxos/node.go:561` Go prepare response handler | Recovery response quorum handling is verified by Go tests, but the current TLA model abstracts away recovery quorum collection. |
18-
| `tla/EPaxos.tla:76` TLA Execute | `epaxos/node.go:836` Go executor and `epaxos/node.go:367` Ready acknowledgement | Execution collapses committed dependency SCCs; user commands are emitted through `Ready.Committed`, and durable `StatusExecuted` records are emitted only after `Advance` acknowledges application. |
19-
| `tla/EPaxos.tla:96` TLA DependencyClosure | `epaxos/node.go:936` Go component readiness | Component readiness requires outside committed dependencies to be executed before application emission. |
20-
| `tla/EPaxos.tla:98` TLA ConflictOrder | `epaxos/types.go:150` Go command conflict predicate and `epaxos/node.go:985` Go dependency references | Both require conflicting executed commands to be ordered by dependencies; Go interprets dependency vectors as known per-replica prefixes through configuration history. |
21-
| `tla/EPaxos.tla:83` TLA Tick | `epaxos/node.go:237` Go logical tick | Tick advances logical time and dispatches logical timers; no wall-clock API is used in the core. |
11+
| `tla/EPaxos.tla:18` TLA SafeDeps | `epaxos/node.go:828` Go attribute computation | Both model and implementation require known conflicting commands to appear in dependency attributes. |
12+
| `tla/EPaxos.tla:42` TLA PreAccept | `epaxos/node.go:258` Go proposal path | Local proposal computes attributes, persists pre-accepted records, indexes conflicts, and emits pre-accept messages. |
13+
| `tla/EPaxos.tla:42` TLA PreAccept receive | `epaxos/node.go:521` Go pre-accept handler | Remote pre-accept merges local conflicts with proposer attributes and stores the pre-accepted value. |
14+
| `tla/EPaxos.tla:54` TLA Accept | `epaxos/node.go:577` Go accept handler | Accept stores slow-path attributes unless the instance is already committed or promised higher. |
15+
| `tla/EPaxos.tla:63` TLA Commit | `epaxos/node.go:621` Go commit handler | Commit stores final value/attributes and invokes dependency-closed execution. |
16+
| `tla/EPaxos.tla:69` TLA Prepare | `epaxos/node.go:634` Go prepare handler | Prepare persists higher promises and returns local accepted/committed state for recovery; TLA abstracts this as the ballot-raising recovery step. |
17+
| Not represented in current TLA safety model | `epaxos/node.go:660` Go prepare response handler | Recovery response quorum handling is verified by Go tests, but the current TLA model abstracts away recovery quorum collection. |
18+
| `tla/EPaxos.tla:76` TLA Execute | `epaxos/node.go:935` Go executor and `epaxos/node.go:368` Ready acknowledgement | Execution collapses committed dependency SCCs; user commands are emitted through `Ready.Committed`, and durable `StatusExecuted` records are emitted only after `Advance` validates the acknowledged Ready prefix and acknowledges application. |
19+
| `tla/EPaxos.tla:96` TLA DependencyClosure | `epaxos/node.go:1035` Go component readiness | Component readiness requires outside committed dependencies to be executed before application emission. |
20+
| `tla/EPaxos.tla:98` TLA ConflictOrder | `epaxos/types.go:150` Go command conflict predicate and `epaxos/node.go:1084` Go dependency references | Both require conflicting executed commands to be ordered by dependencies; Go interprets dependency vectors as known per-replica prefixes through configuration history. |
21+
| `tla/EPaxos.tla:83` TLA Tick | `epaxos/node.go:238` Go logical tick | Tick advances logical time and dispatches logical timers; no wall-clock API is used in the core. |
2222
| Not represented in current TLA safety model | `epaxos/checksum.go:65` Go record checksum | Durable record checksum coverage is implementation validation rather than modeled state. |
2323
| Not represented in current TLA safety model | `epaxos/checksum.go:84` Go message checksum | Transport checksum coverage is implementation validation rather than modeled state. |
2424
| Not represented in current TLA safety model | `epaxos/codec.go:41` Go decoder | Wire-decoder input tolerance and error-path destination cleanup are implementation validation rather than modeled state. |
@@ -30,15 +30,16 @@ This report maps the current finite TLA+ safety model to the current Go implemen
3030

3131
## Current verification evidence
3232

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.
34-
- 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, transport fault-route coverage, and storage fault-route coverage that rejects work before RawNode progress.
36-
- 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.
33+
- Observed `tests/ci.sh` passing after the strict Ready acknowledgement and durable in-process cluster 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.
34+
- 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, strict Ready acknowledgement validation, 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, durable in-process cluster restart through Pebble-backed EPaxOS storage, post-Advance executed-record persistence, negative scan-limit rejection, transport fault-route coverage, and storage fault-route coverage that rejects work before RawNode progress.
36+
- Observed `go test ./epaxos -coverprofile=coverage.out -count=1` reporting 100.0% statement coverage and `go tool cover -func=coverage.out` reporting `Advance`, `validateReadyAck`, `commandEqual`, and `instanceNumsEqual` at 100.0% after strict acknowledgement mismatch tests.
37+
- Observed `go test ./examples/kv -coverprofile=coverage.out -count=1` reporting 100.0% statement coverage and `go tool cover -func=coverage.out` reporting `drainWithLimit` at 100.0% after the durable-applier acknowledgement-error test.
3738
- 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.
3839
- 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`.
4040
- 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`.
41+
- Observed `tests/ci.sh` local transport profile reporting register `:linearizable {:valid? true}`, scan shape `:scan-shape {:valid? true, :checked 13, :bad-count 0}`, transaction `:txn-atomic {:valid? true, :checked 4, :bad-count 0}`, and overall `:valid? true`.
42+
- Observed `tests/ci.sh` local storage profile reporting register `:linearizable {:valid? true}`, scan shape `:scan-shape {:valid? true, :checked 9, :bad-count 0}`, transaction `:txn-atomic {:valid? true, :checked 9, :bad-count 0}`, and overall `:valid? true`.
4243
- Observed repository text audit with no disallowed external-project name, scaffolding marker, or wall-clock API matches.
4344

4445
## Open correspondence risks

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# moreconsensus
22

3-
`moreconsensus` is a Go library for building replicated services with Egalitarian Paxos (EPaxos). The public API follows the shape of etcd raft: applications drive a deterministic `RawNode`, persist `Ready` records, send `Ready` messages, apply committed commands, and then call `Advance`.
3+
`moreconsensus` is a Go library for building replicated services with Egalitarian Paxos (EPaxos). The public API follows the shape of etcd raft: applications drive a deterministic `RawNode`, persist `Ready` records, send `Ready` messages, apply committed commands, and then call `Advance` with the acknowledged `Ready` prefix.
44

55
## Core features
66

epaxos/branch_test.go

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,9 @@ func TestConstructionAndReadyBranches(t *testing.T) {
5050
if !rn.Ready().Empty() {
5151
t.Fatal("empty ready should be empty")
5252
}
53-
rn.Advance(Ready{})
53+
if err := rn.Advance(Ready{}); err != nil {
54+
t.Fatal(err)
55+
}
5456
if _, err := rn.Propose(Command{Kind: CommandConfChange}); err == nil {
5557
t.Fatal("expected conf command rejection")
5658
}
@@ -64,8 +66,15 @@ func TestConstructionAndReadyBranches(t *testing.T) {
6466
if !rn.Ready().Empty() {
6567
t.Fatal("ready while awaiting advance should be empty")
6668
}
67-
rn.Advance(Ready{Records: rd.Records[:1]})
68-
rn.Advance(rd)
69+
if err := rn.Advance(Ready{Records: rd.Records[:1], MustSync: rd.MustSync}); err != nil {
70+
t.Fatal(err)
71+
}
72+
tail := rn.Ready()
73+
if !tail.Empty() {
74+
if err := rn.Advance(tail); err != nil {
75+
t.Fatal(err)
76+
}
77+
}
6978
}
7079

7180
func TestDirectProtocolBranches(t *testing.T) {

epaxos/internal_test.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,9 @@ func TestRejectPathsAndTimers(t *testing.T) {
181181
if err := s.nodes[msg.To].Step(lowAccept); err != nil {
182182
t.Fatal(err)
183183
}
184-
s.nodes[1].Advance(rd)
184+
if err := s.nodes[1].Advance(rd); err != nil {
185+
t.Fatal(err)
186+
}
185187
inst := s.nodes[1].instances[ref]
186188
s.nodes[1].onTimer(inst, timerPreAccept)
187189
s.nodes[1].startAccept(inst, inst.rec.Attributes())

epaxos/node.go

Lines changed: 105 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package epaxos
22

33
import (
4+
"bytes"
45
"container/heap"
56
"encoding/binary"
67
"fmt"
@@ -363,15 +364,18 @@ func (n *RawNode) Ready() Ready {
363364
return rd
364365
}
365366

366-
// Advance acknowledges that the caller persisted and applied the Ready batch.
367-
func (n *RawNode) Advance(rd Ready) {
367+
// Advance acknowledges a prefix of the outstanding Ready batch or returns ErrInvalidReady without mutating node state.
368+
func (n *RawNode) Advance(rd Ready) error {
368369
if !n.awaitAdvance {
369-
return
370+
if rd.Empty() {
371+
return nil
372+
}
373+
return ErrInvalidReady
370374
}
371-
ackedCommitted := len(rd.Committed)
372-
if ackedCommitted > len(n.pendingReady.Committed) {
373-
ackedCommitted = len(n.pendingReady.Committed)
375+
if err := n.validateReadyAck(rd); err != nil {
376+
return err
374377
}
378+
ackedCommitted := len(rd.Committed)
375379
if len(rd.Records) >= len(n.pendingReady.Records) {
376380
n.pendingReady.Records = nil
377381
} else {
@@ -390,6 +394,101 @@ func (n *RawNode) Advance(rd Ready) {
390394
n.pendingReady.MustSync = len(n.pendingReady.Records) > 0
391395
n.enqueueExecutedRecords(rd.Committed[:ackedCommitted])
392396
n.awaitAdvance = false
397+
return nil
398+
}
399+
400+
func (n *RawNode) validateReadyAck(rd Ready) error {
401+
if len(rd.Records) == 0 && len(rd.Messages) == 0 && len(rd.Committed) == 0 {
402+
return ErrInvalidReady
403+
}
404+
if rd.MustSync != n.pendingReady.MustSync {
405+
return ErrInvalidReady
406+
}
407+
if len(rd.Records) > len(n.pendingReady.Records) || len(rd.Committed) > len(n.pendingReady.Committed) {
408+
return ErrInvalidReady
409+
}
410+
visibleMessages := len(n.pendingReady.Messages)
411+
if n.maxReadyMessages > 0 && visibleMessages > n.maxReadyMessages {
412+
visibleMessages = n.maxReadyMessages
413+
}
414+
if len(rd.Messages) > visibleMessages {
415+
return ErrInvalidReady
416+
}
417+
if (len(rd.Messages) > 0 || len(rd.Committed) > 0) && len(rd.Records) != len(n.pendingReady.Records) {
418+
return ErrInvalidReady
419+
}
420+
for i := range rd.Records {
421+
if !instanceRecordEqual(rd.Records[i], n.readyRecord(n.pendingReady.Records[i])) {
422+
return ErrInvalidReady
423+
}
424+
}
425+
for i := range rd.Messages {
426+
if !messageEqual(rd.Messages[i], n.pendingReady.Messages[i].Clone()) {
427+
return ErrInvalidReady
428+
}
429+
}
430+
for i := range rd.Committed {
431+
if !committedCommandEqual(rd.Committed[i], n.pendingReady.Committed[i].Clone()) {
432+
return ErrInvalidReady
433+
}
434+
}
435+
return nil
436+
}
437+
438+
func instanceRecordEqual(a, b InstanceRecord) bool {
439+
return a.Ref == b.Ref &&
440+
a.Ballot == b.Ballot &&
441+
a.Status == b.Status &&
442+
a.Seq == b.Seq &&
443+
a.Checksum == b.Checksum &&
444+
instanceNumsEqual(a.Deps, b.Deps) &&
445+
commandEqual(a.Command, b.Command)
446+
}
447+
448+
func messageEqual(a, b Message) bool {
449+
return a.Type == b.Type &&
450+
a.From == b.From &&
451+
a.To == b.To &&
452+
a.Ref == b.Ref &&
453+
a.Ballot == b.Ballot &&
454+
a.Seq == b.Seq &&
455+
a.Reject == b.Reject &&
456+
a.RejectHint == b.RejectHint &&
457+
a.RecordStatus == b.RecordStatus &&
458+
a.Checksum == b.Checksum &&
459+
instanceNumsEqual(a.Deps, b.Deps) &&
460+
commandEqual(a.Command, b.Command)
461+
}
462+
463+
func committedCommandEqual(a, b CommittedCommand) bool {
464+
return a.Ref == b.Ref &&
465+
a.Seq == b.Seq &&
466+
instanceNumsEqual(a.Deps, b.Deps) &&
467+
commandEqual(a.Command, b.Command)
468+
}
469+
470+
func commandEqual(a, b Command) bool {
471+
if a.ID != b.ID || a.Kind != b.Kind || !bytes.Equal(a.Payload, b.Payload) || len(a.ConflictKeys) != len(b.ConflictKeys) {
472+
return false
473+
}
474+
for i := range a.ConflictKeys {
475+
if !bytes.Equal(a.ConflictKeys[i], b.ConflictKeys[i]) {
476+
return false
477+
}
478+
}
479+
return true
480+
}
481+
482+
func instanceNumsEqual(a, b []InstanceNum) bool {
483+
if len(a) != len(b) {
484+
return false
485+
}
486+
for i := range a {
487+
if a[i] != b[i] {
488+
return false
489+
}
490+
}
491+
return true
393492
}
394493

395494
func (n *RawNode) enqueueExecutedRecords(committed []CommittedCommand) {

0 commit comments

Comments
 (0)