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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .agent-tasks/epaxos-conflict-gc/GOALS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# EPaxos conflict engine and executed-instance GC

## Goal

Replace the $O(\text{all instances})$ conflict and attrs machinery with a per-lane conflict engine, add two-tier executed-instance retirement with an asynchronous record-load handshake, expose `VisitConflicts`, and enforce strict linting.

## Success criteria

- R1: Attribute computation and TryPreAccept conflict checks avoid resident-instance scans.
- R2: The conflict index remains coherent for every record mutation and removal.
- R3: Embeddings receive a minimal, zero-allocation public conflict query API.
- R4: Executed instances retire automatically with configurable per-lane retention.
- R5: Payload-drop and fold reclamation bound resident memory, with proposal backpressure.
- R6: Wire format and durable storage remain unchanged; durable compaction remains separate.
- R7: Folded-record recovery uses a deterministic asynchronous Ready handshake.
- R8: Property tests, invariants, TLA evidence, and resident-state benchmarks prove behavior.
- R9: Strict golangci-lint, CI enforcement, and task-goal scaffolding are in place.
- R10: The work is delivered as atomic GitHub issues and PRs targeting `main`.

## Verifiable-goals scaffolding

- [Conflict engine tests](../../epaxos/conflict_engine_test.go)
- [Retirement tests](../../epaxos/retire_test.go)

Remove this task-goal file after the final implementation unit merges.
11 changes: 11 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,5 +52,16 @@ jobs:
chmod +x /tmp/lein
echo /tmp >> "$GITHUB_PATH"

- name: Run golangci-lint
uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9
with:
version: v2.12.2

- name: Run golangci-lint (examples/kv)
uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9
with:
version: v2.12.2
working-directory: examples/kv

- name: Run repository verification gates
run: tests/ci.sh
27 changes: 27 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
version: "2"

run:
go: "1.26"

linters:
# govet: DEFAULT analyzer set only. Do NOT set enable-all (fieldalignment/shadow noise).
# All of errcheck, errorlint, exhaustive, gocritic, gosec, govet, ineffassign, revive,
# staticcheck, unused, wastedassign stay enabled — R9 correctness/style gate.
enable:
- errcheck
- errorlint
- exhaustive
- gocritic
- gosec
- govet
- ineffassign
- revive
- staticcheck
- unused
- wastedassign
settings:
exhaustive:
default-signifies-exhaustive: false
staticcheck:
checks:
- all
14 changes: 14 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Agent Instructions

Run these commands before completing relevant changes:

```sh
go test -race -count=1 ./...
go vet ./...
golangci-lint run ./...
(cd examples/kv && golangci-lint run ./...)
tests/ci.sh
```

Per-task goals live in `.agent-tasks/<task-id>/GOALS.md`.
Richer project conventions are init's job.
28 changes: 24 additions & 4 deletions epaxos/bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ func validateBootstrapFrontier(f BootstrapFrontier, base ConfState) error {
if coverage > InstanceNum(maxBootstrapSparseRefs-totalCoverage) {
return ErrBootstrapBounds
}
totalCoverage += int(coverage)
totalCoverage += int(coverage) //nolint:gosec // G115: conversion is bounded by protocol or test-fixture limits.
for j, instance := range lane.Sparse {
if instance == 0 || instance <= lane.CompactedExecutedThrough || instance > lane.ObservedThrough ||
(j > 0 && lane.Sparse[j-1] >= instance) {
Expand Down Expand Up @@ -340,6 +340,7 @@ func (c ReadyCertificate) Clone() ReadyCertificate {
// BootstrapPhase is the durable phase of a voter plan.
type BootstrapPhase uint8

// BootstrapPhase values describe voter-plan progress.
const (
BootstrapPhaseUnspecified BootstrapPhase = iota
BootstrapPhasePreparing
Expand All @@ -356,6 +357,7 @@ const (
// BootstrapOutcome is a terminal replicated membership-control outcome.
type BootstrapOutcome uint8

// BootstrapOutcome values describe terminal bootstrap results.
const (
BootstrapOutcomeUnspecified BootstrapOutcome = iota
BootstrapOutcomeActivated
Expand Down Expand Up @@ -424,6 +426,7 @@ func (r BootstrapRecord) Clone() BootstrapRecord {
// LocalVoterStatus is the durable local admission state.
type LocalVoterStatus uint8

// LocalVoterStatus values describe local admission eligibility.
const (
LocalVoterStatusUnspecified LocalVoterStatus = iota
LocalVoterStatusStaged
Expand Down Expand Up @@ -523,6 +526,7 @@ type BootstrapStatusSnapshot struct {
// BootstrapExit selects one reserved terminal control ref for recovery.
type BootstrapExit uint8

// BootstrapExit values select reserved terminal controls.
const (
BootstrapExitActivate BootstrapExit = iota + 1
BootstrapExitAbort
Expand All @@ -531,6 +535,7 @@ const (
// BootstrapMessageType identifies an authenticated out-of-band bootstrap message.
type BootstrapMessageType uint8

// BootstrapMessageType values identify bootstrap control messages.
const (
BootstrapMsgFenceRequest BootstrapMessageType = iota + 1
BootstrapMsgFenceAck
Expand Down Expand Up @@ -1405,6 +1410,8 @@ func validateBootstrapRecord(record BootstrapRecord) error {
if record.Outcome != BootstrapOutcomeAborted || record.TerminalRef != record.Plan.Reservations.Abort {
return ErrBootstrapControl
}
case BootstrapPhaseUnspecified, BootstrapPhasePreparing, BootstrapPhasePrepared, BootstrapPhaseLocalFenced, BootstrapPhaseFenced, BootstrapPhaseCertified, BootstrapPhaseTargetReady, BootstrapPhaseFinalizing:
fallthrough
default:
if record.Outcome != BootstrapOutcomeUnspecified || !record.TerminalRef.IsZero() {
return ErrBootstrapControl
Expand Down Expand Up @@ -1455,6 +1462,8 @@ func validateMembershipResult(record InstanceRecord) error {
if !confStateIsZero(result.Successor) || record.ConfChangeResult.Outcome == ConfChangeApplied {
return fmt.Errorf("%w: rejected membership result has successor", ErrInvalidConfig)
}
case BootstrapOutcomeUnspecified:
fallthrough
default:
return fmt.Errorf("%w: unknown membership outcome", ErrInvalidConfig)
}
Expand Down Expand Up @@ -1523,7 +1532,7 @@ func DecodeBootstrapMessage(src []byte, message *BootstrapMessage) error {
if p.uvarint() != bootstrapWireVersion {
return ErrInvalidBootstrapMessage
}
message.Type = BootstrapMessageType(p.uvarint())
message.Type = BootstrapMessageType(p.uvarint8())
p.fixed((*[32]byte)(&message.Cluster))
p.fixed((*[32]byte)(&message.Plan))
message.From = ReplicaID(p.uvarint())
Expand Down Expand Up @@ -1657,6 +1666,15 @@ func (p *bootstrapParser) uvarint() uint64 {
return value
}

func (p *bootstrapParser) uvarint8() uint8 {
v := p.uvarint()
if v > uint64(^uint8(0)) {
p.err = true
return 0
}
return uint8(v)
}

func (p *bootstrapParser) fixed(dst *[32]byte) {
if p.err || len(p.b) < len(dst) {
p.err = true
Expand All @@ -1666,9 +1684,9 @@ func (p *bootstrapParser) fixed(dst *[32]byte) {
p.b = p.b[len(dst):]
}

func (p *bootstrapParser) bytes(max int) []byte {
func (p *bootstrapParser) bytes(maxLen int) []byte {
length := p.uvarint()
if p.err || length > uint64(max) || length > uint64(len(p.b)) {
if p.err || length > uint64(maxLen) || length > uint64(len(p.b)) { //nolint:gosec // G115: conversion is bounded by protocol or test-fixture limits.
p.err = true
return nil
}
Expand Down Expand Up @@ -2823,6 +2841,7 @@ func (n *RawNode) admitWhileFenced(message Message) error {
if message.Command.Kind == CommandUser && len(message.Command.Payload) == 0 {
return nil
}
case MsgPreAccept, MsgAccept, MsgCommit, MsgTryPreAccept, MsgTryPreAcceptResp, MsgEvidence, MsgEvidenceResp:
}
wire, err := decodeMembershipCommand(message.Command)
if err != nil || wire.Operation != operation || wire.Plan.Request.Plan != state.record.Plan.Request.Plan {
Expand Down Expand Up @@ -2858,6 +2877,7 @@ func (n *RawNode) admitWhileFenced(message Message) error {
if inst == nil {
return ErrBootstrapContradiction
}
case MsgPreAcceptResp, MsgAcceptResp, MsgPrepareResp, MsgTryPreAccept, MsgTryPreAcceptResp, MsgEvidence, MsgEvidenceResp:
}
return nil
}
Expand Down
4 changes: 2 additions & 2 deletions epaxos/bootstrap_core_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,9 @@ func newBootstrapTestFixture(t *testing.T, voters int, local ReplicaID) bootstra
for i := range identities {
identities[i] = VoterIdentity{Replica: ReplicaID(i + 1), Incarnation: 1}
}
target := VoterIdentity{Replica: ReplicaID(voters + 1), Incarnation: 1}
target := VoterIdentity{Replica: ReplicaID(voters + 1), Incarnation: 1} //nolint:gosec // G115: conversion is bounded by protocol or test-fixture limits.
cluster := ClusterID{1, 2, 3}
planID := BootstrapID{9, byte(voters), byte(local)}
planID := BootstrapID{9, byte(voters), byte(local)} //nolint:gosec // G115: conversion is bounded by protocol or test-fixture limits.
store := NewMemoryStorage()
store.Hard = HardState{Conf: conf.Clone()}
store.ConfigHistory = []ConfigHistoryEntry{{Conf: conf.Clone()}}
Expand Down
43 changes: 43 additions & 0 deletions epaxos/bootstrap_fence_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package epaxos

import (
"encoding/binary"
"bytes"
"errors"
"reflect"
Expand Down Expand Up @@ -435,3 +436,45 @@ func TestBootstrapEnvelopeAndChunkValidationIsCanonicalBoundedAndIdempotent(t *t
t.Fatalf("same-index total conflict err=%v", err)
}
}


func TestDecodeBootstrapMessageRejectsOversizedType(t *testing.T) {
f := newBootstrapTestFixture(t, 1, 1)
plan := prepareBootstrapPlan(t, f)
message, err := BuildBootstrapMessage(BootstrapMessage{
Type: BootstrapMsgReadyQuery, Cluster: f.cluster, Plan: plan.Request.Plan,
From: 1, FromIncarnation: 1, To: 1, BaseID: plan.Request.Base.ID,
BaseDigest: plan.RequestDigest, Payload: []byte("{}"),
})
if err != nil {
t.Fatal(err)
}
encoded, err := EncodeBootstrapMessage(nil, message)
if err != nil {
t.Fatal(err)
}
// Replace the type uvarint (immediately after magic+version) with 257 so it
// would truncate to BootstrapMsgFenceRequest without range checking.
frame := append([]byte(nil), encoded[:len(bootstrapWireMagic)]...)
// re-encode version then oversized type then rest after original version+type
// Parse original after magic: version uvarint then type uvarint.
rest := encoded[len(bootstrapWireMagic):]
_, nVer := binary.Uvarint(rest)
if nVer <= 0 {
t.Fatal("version uvarint")
}
_, nType := binary.Uvarint(rest[nVer:])
if nType <= 0 {
t.Fatal("type uvarint")
}
frame = append(frame, rest[:nVer]...)
frame = binary.AppendUvarint(frame, 257)
frame = append(frame, rest[nVer+nType:]...)
var got BootstrapMessage
if err := DecodeBootstrapMessage(frame, &got); !errors.Is(err, ErrInvalidBootstrapMessage) {
t.Fatalf("oversized bootstrap type err=%v, want ErrInvalidBootstrapMessage; got=%#v", err, got)
}
if got.Type != 0 || got.From != 0 || len(got.Payload) != 0 {
t.Fatalf("failed decode left residue: %#v", got)
}
}
2 changes: 1 addition & 1 deletion epaxos/bootstrap_recovery_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -772,7 +772,7 @@ func TestDueRecoveryTimersShareBoundedFairDriveBudget(t *testing.T) {

func TestLiveOldQuorumAfterFencerCrashesCanFinalizeButCannotChooseOrdinaryWork(t *testing.T) {
for voters := 3; voters <= 6; voters++ {
t.Run(string(rune('0'+voters)), func(t *testing.T) {
t.Run(string(rune('0'+voters)), func(t *testing.T) { //nolint:gosec // G115: conversion is bounded by protocol or test-fixture limits.
fixture, plan := standaloneBootstrapPlan(t, voters)
frontier := standaloneFrontier(plan, 0)
quorum := slowQuorumSize(voters)
Expand Down
36 changes: 27 additions & 9 deletions epaxos/branch_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,13 +110,21 @@ func TestDirectProtocolBranches(t *testing.T) {
t.Fatal(err)
}
inst := rn.instances[ref2]
rn.handlePreAcceptResp(Message{Type: MsgPreAcceptResp, From: 2, To: 1, Ref: ref2, Ballot: Ballot{Number: 5, Replica: 2}, Reject: true, RejectHint: Ballot{Number: 5, Replica: 2}, Deps: rn.q.deps()})
if err := rn.handlePreAcceptResp(Message{Type: MsgPreAcceptResp, From: 2, To: 1, Ref: ref2, Ballot: Ballot{Number: 5, Replica: 2}, Reject: true, RejectHint: Ballot{Number: 5, Replica: 2}, Deps: rn.q.deps()}); err != nil {
panic(err)
}
if inst.phase != phasePrepare {
t.Fatal("reject did not start prepare")
}
rn.handlePrepareResp(Message{Type: MsgPrepareResp, From: 2, To: 1, Ref: ref2, RecordStatus: StatusAccepted, Ballot: Ballot{Number: 6, Replica: 2}, RecordBallot: Ballot{Number: 6, Replica: 2}, Seq: 3, Deps: rn.q.deps(), Command: inst.rec.Command})
rn.handlePrepareResp(Message{Type: MsgPrepareResp, From: 2, To: 1, Ref: ref2, RecordStatus: StatusAccepted, Ballot: Ballot{Number: 6, Replica: 2}, RecordBallot: Ballot{Number: 6, Replica: 2}, Seq: 3, Deps: rn.q.deps(), Command: inst.rec.Command})
rn.handlePrepareResp(Message{Type: MsgPrepareResp, From: 3, To: 1, Ref: ref2, RecordStatus: StatusCommitted, Ballot: Ballot{Number: 7, Replica: 3}, RecordBallot: Ballot{Number: 7, Replica: 3}, Seq: 4, Deps: rn.q.deps(), Command: inst.rec.Command})
if err := rn.handlePrepareResp(Message{Type: MsgPrepareResp, From: 2, To: 1, Ref: ref2, RecordStatus: StatusAccepted, Ballot: Ballot{Number: 6, Replica: 2}, RecordBallot: Ballot{Number: 6, Replica: 2}, Seq: 3, Deps: rn.q.deps(), Command: inst.rec.Command}); err != nil {
panic(err)
}
if err := rn.handlePrepareResp(Message{Type: MsgPrepareResp, From: 2, To: 1, Ref: ref2, RecordStatus: StatusAccepted, Ballot: Ballot{Number: 6, Replica: 2}, RecordBallot: Ballot{Number: 6, Replica: 2}, Seq: 3, Deps: rn.q.deps(), Command: inst.rec.Command}); err != nil {
panic(err)
}
if err := rn.handlePrepareResp(Message{Type: MsgPrepareResp, From: 3, To: 1, Ref: ref2, RecordStatus: StatusCommitted, Ballot: Ballot{Number: 7, Replica: 3}, RecordBallot: Ballot{Number: 7, Replica: 3}, Seq: 4, Deps: rn.q.deps(), Command: inst.rec.Command}); err != nil {
panic(err)
}
rn.startAccept(inst, inst.rec.Attributes())
rn.commit(inst, inst.rec.Attributes())
}
Expand All @@ -132,13 +140,19 @@ func TestAcceptResponseAndConfigBranches(t *testing.T) {
}
inst := rn.instances[ref]
rn.startAccept(inst, inst.rec.Attributes())
rn.handleAcceptResp(Message{Type: MsgAcceptResp, From: 2, To: 1, Ref: ref, Ballot: Ballot{Number: 9, Replica: 2}, Reject: true, RejectHint: Ballot{Number: 9, Replica: 2}, Deps: rn.q.deps()})
if err := rn.handleAcceptResp(Message{Type: MsgAcceptResp, From: 2, To: 1, Ref: ref, Ballot: Ballot{Number: 9, Replica: 2}, Reject: true, RejectHint: Ballot{Number: 9, Replica: 2}, Deps: rn.q.deps()}); err != nil {
panic(err)
}
inst.phase = phaseAccept
inst.rec.Status = StatusAccepted
inst.rec.RecordBallot = inst.rec.Ballot
inst.accOK = 0
rn.handleAcceptResp(Message{Type: MsgAcceptResp, From: 2, To: 1, Ref: ref, Ballot: inst.rec.Ballot, RecordBallot: inst.rec.Ballot, RecordStatus: StatusAccepted, Seq: inst.rec.Seq, Deps: rn.q.deps()})
rn.handleAcceptResp(Message{Type: MsgAcceptResp, From: 2, To: 1, Ref: ref, Ballot: inst.rec.Ballot, RecordBallot: inst.rec.Ballot, RecordStatus: StatusAccepted, Seq: inst.rec.Seq, Deps: rn.q.deps()})
if err := rn.handleAcceptResp(Message{Type: MsgAcceptResp, From: 2, To: 1, Ref: ref, Ballot: inst.rec.Ballot, RecordBallot: inst.rec.Ballot, RecordStatus: StatusAccepted, Seq: inst.rec.Seq, Deps: rn.q.deps()}); err != nil {
panic(err)
}
if err := rn.handleAcceptResp(Message{Type: MsgAcceptResp, From: 2, To: 1, Ref: ref, Ballot: inst.rec.Ballot, RecordBallot: inst.rec.Ballot, RecordStatus: StatusAccepted, Seq: inst.rec.Seq, Deps: rn.q.deps()}); err != nil {
panic(err)
}
rn.enqueueRecord(InstanceRecord{Ref: InstanceRef{Replica: 1, Instance: 99, Conf: 1}, Deps: rn.q.deps(), Command: Command{Kind: CommandNoop}})
rn.enqueueMessage(Message{Type: MsgPrepareResp, From: 1, To: 2, Ref: ref, Ballot: Ballot{Number: 1, Replica: 2}, Deps: rn.q.deps()})
if _, err := FastQuorum(0); err == nil {
Expand Down Expand Up @@ -183,7 +197,9 @@ func TestRestartAcceptedForeignInstanceSchedulesPrepareRecovery(t *testing.T) {
}
advanceOK(t, rn, initial)
for tick := uint64(1); tick < 4; tick++ {
rn.Tick()
if err := rn.Tick(); err != nil {
panic(err)
}
tickReady := rn.Ready()
wantTick := HardState{Conf: wantInitial.Conf, Tick: tick}
if !tickReady.HardState.Equal(wantTick) || !tickReady.MustSync ||
Expand All @@ -196,7 +212,9 @@ func TestRestartAcceptedForeignInstanceSchedulesPrepareRecovery(t *testing.T) {
advanceOK(t, rn, tickReady)
}

rn.Tick()
if err := rn.Tick(); err != nil {
panic(err)
}
if inst.phase != phasePrepare {
t.Fatalf("foreign accepted recovery phase = %d, want prepare after recovery deadline", inst.phase)
}
Expand Down
Loading
Loading