Skip to content
Draft

WIP #3834

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
55 changes: 10 additions & 45 deletions sei-tendermint/autobahn/types/epoch_duo.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,75 +18,40 @@ type EpochDuo struct {
// NewEpochDuo builds a Prev|Current window. Panics unless
// prev≠None ⇔ current.EpochIndex()>0, and when Prev is present it must be
// contiguous with Current.
func NewEpochDuo(current *Epoch, prev utils.Option[*Epoch]) EpochDuo {
func NewEpochDuo(current *Epoch, prev utils.Option[*Epoch]) (EpochDuo,error) {
cur := current.EpochIndex()
p, hasPrev := prev.Get()
if hasPrev != (cur > 0) {
panic(fmt.Sprintf("NewEpochDuo: Prev present=%v but Current epoch %d (want Prev iff Current>0)",
hasPrev, cur))
return EpochDuo{},fmt.Errorf("NewEpochDuo: Prev present=%v but Current epoch %d (want Prev iff Current>0)",
hasPrev, cur)
}
if hasPrev {
if p.EpochIndex()+1 != cur {
panic(fmt.Sprintf("NewEpochDuo: Prev epoch %d not contiguous with Current %d",
p.EpochIndex(), cur))
return EpochDuo{},fmt.Errorf("NewEpochDuo: Prev epoch %d not contiguous with Current %d",
p.EpochIndex(), cur)
}
if got, want := p.RoadRange().Next, current.RoadRange().First; got != want {
panic(fmt.Sprintf("NewEpochDuo: Prev roads end at %d, Current starts at %d", got, want))
panic(fmt.Errorf("NewEpochDuo: Prev roads end at %d, Current starts at %d", got, want))
}
}
return EpochDuo{Prev: prev, Current: current}
}

var ErrRoadBeforeWindow = errors.New("road before epoch duo window")
var ErrRoadAfterWindow = errors.New("road after epoch duo window")

// RoadStatus classifies a road relative to a Prev|Current window for admit waits.
type RoadStatus int

const (
RoadReady RoadStatus = iota // in the admitted window
RoadStale // behind the window (soft-drop / ErrPruned)
RoadFuture // ahead of the window (backpressure wait)
)

// RoadStatusCurrent classifies roadIdx against Current only (CommitQC tip).
func (w EpochDuo) RoadStatusCurrent(roadIdx RoadIndex) RoadStatus {
if w.Current.RoadRange().Has(roadIdx) {
return RoadReady
}
if roadIdx < w.Current.RoadRange().First {
return RoadStale
}
return RoadFuture
}

// RoadStatusDuo classifies roadIdx against Prev|Current (AppVote/AppQC).
func (w EpochDuo) RoadStatusDuo(roadIdx RoadIndex) RoadStatus {
_, err := w.EpochForRoad(roadIdx)
if err == nil {
return RoadReady
}
if errors.Is(err, ErrRoadBeforeWindow) {
return RoadStale
}
return RoadFuture
return EpochDuo{Prev: prev, Current: current},nil
}

// EpochForRoad returns the epoch containing roadIdx.
// Window is [Prev.First or Current.First, Current.Next). Outside →
// ErrRoadBeforeWindow / ErrRoadAfterWindow. Under contiguous Prev|Current there
// is no gap, so a miss after the after-window check is always before-window.
func (w EpochDuo) EpochForRoad(roadIdx RoadIndex) (*Epoch, error) {
func (w EpochDuo) ByRoad(roadIdx RoadIndex) (*Epoch, error) {
if roadIdx >= w.Current.RoadRange().Next {
return nil, fmt.Errorf("road %d after window %v: %w", roadIdx, w, ErrRoadAfterWindow)
return nil, errors.New("road belongs to future epoch")
}
if w.Current.RoadRange().Has(roadIdx) {
return w.Current, nil
}
if prev, ok := w.Prev.Get(); ok && prev.RoadRange().Has(roadIdx) {
return prev, nil
}
return nil, fmt.Errorf("road %d before window %v: %w", roadIdx, w, ErrRoadBeforeWindow)
return nil, ErrPruned
}

func (w EpochDuo) String() string {
Expand Down
41 changes: 24 additions & 17 deletions sei-tendermint/autobahn/types/proposal.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,45 +125,52 @@ func (v View) Next() View {
return v
}

type ConsensusSpec struct {
Epochs EpochDuo
CommitQC utils.Option[*CommitQC]
// Genesis floors used only when CommitQC is None (chain start).
GenesisFirstBlock GlobalBlockNumber
GenesisTimestamp time.Time
}

// Epoch is the proposing/voting epoch (Epochs.Current).
func (cs *ConsensusSpec) Epoch() *Epoch { return cs.Epochs.Current }

func (cs *ConsensusSpec) Index() RoadIndex { return NextIndexOpt(cs.CommitQC) }

// ViewSpec is the local context for starting a view: justification QCs plus a
// Prev|Current EpochDuo. Attached AppQC may be Current or Current-1 (Prev lag).
type ViewSpec struct {
// WARNING: currently we have implicit assumption that
// TimeoutQC.View().Index == CommitQC.Index.Next(),
// I.e. that TimeoutQC comes from the expected consensus instance.
CommitQC utils.Option[*CommitQC]
*ConsensusSpec
TimeoutQC utils.Option[*TimeoutQC]
Epochs EpochDuo
// Genesis floors used only when CommitQC is None (chain start).
// Copied from Registry; ignored when CommitQC is present.
GenesisFirstBlock GlobalBlockNumber
GenesisTimestamp time.Time
}

// Epoch is the proposing/voting epoch (Epochs.Current).
func (vs *ViewSpec) Epoch() *Epoch { return vs.Epochs.Current }

// NextGlobalBlock returns the first global block number expected in the next proposal.
// CommitQC is None only at chain start, in which case it returns GenesisFirstBlock.
// For all other views, including the first view of a non-genesis epoch, CommitQC is present and it returns CommitQC.GlobalRange().Next.
func (vs *ViewSpec) NextGlobalBlock() GlobalBlockNumber {
func (vs ViewSpec) NextGlobalBlock() GlobalBlockNumber {
if cQC, ok := vs.CommitQC.Get(); ok {
return cQC.GlobalRange().Next
}
return vs.GenesisFirstBlock
}

// View is the view justified by vs.
func (vs *ViewSpec) View() View {
idx := NextIndexOpt(vs.CommitQC)
if view := NextViewOpt(vs.TimeoutQC); view.Index == idx {
view.EpochIndex = vs.Epoch().EpochIndex()
return view
func (vs ViewSpec) View() View {
if qc,ok := vs.TimeoutQC.Get(); ok {
return qc.View()
}
return View{
EpochIndex: vs.Epoch().EpochIndex(),
Index: vs.Index(),
Number: 0,
}
return View{Index: idx, Number: 0, EpochIndex: vs.Epoch().EpochIndex()}
}

func (vs *ViewSpec) NextTimestamp() time.Time {
func (vs ViewSpec) NextTimestamp() time.Time {
if cQC, ok := vs.CommitQC.Get(); ok {
return cQC.Proposal().NextTimestamp()
}
Expand Down
6 changes: 3 additions & 3 deletions sei-tendermint/autobahn/types/testonly.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import (
// iff Current is epoch 0; otherwise a synthetic contiguous Prev is created.
func EpochDuoForTest(current *Epoch) EpochDuo {
if current.EpochIndex() == 0 {
return NewEpochDuo(current, utils.None[*Epoch]())
return utils.OrPanic1(NewEpochDuo(current, utils.None[*Epoch]()))
}
first := current.RoadRange().First
if first == 0 {
Expand All @@ -28,7 +28,7 @@ func EpochDuoForTest(current *Epoch) EpochDuo {
RoadRange{First: 0, Next: first},
current.Committee(),
)
return NewEpochDuo(current, utils.Some(prev))
return utils.OrPanic1(NewEpochDuo(current, utils.Some(prev)))
}

// BuildCommitQC builds a valid CommitQC from explicit lane QCs and an optional app QC.
Expand All @@ -46,7 +46,7 @@ func BuildCommitQC(
laneQCs map[LaneID]*LaneQC,
appQC utils.Option[*AppQC],
) *CommitQC {
vs := ViewSpec{CommitQC: prev, Epochs: EpochDuoForTest(epoch)}
vs := ViewSpec{ConsensusSpec:&ConsensusSpec{CommitQC: prev, Epochs: EpochDuoForTest(epoch)}}
if len(laneQCs) == 0 {
laneQCs = oneBlockLaneQCMap(vs, keys)
}
Expand Down
107 changes: 40 additions & 67 deletions sei-tendermint/internal/autobahn/avail/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package avail

import (
"context"
"errors"
"fmt"

"github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types"
Expand All @@ -10,31 +11,33 @@ import (

// appProgress owns the in-memory AppQC tip and AppVote accumulators.
type appProgress struct {
latestAppQC utils.Option[*types.AppQC]
votes *queue[types.GlobalBlockNumber, appVotes]
anchor utils.Option[*PruneAnchor]
votes *queue[types.GlobalBlockNumber, appVotes]
}

// LastAppQC returns the latest observed AppQC.
func (s *State) LastAppQC() utils.Option[*types.AppQC] {
for inner := range s.inner.Lock() {
return inner.app.latestAppQC
if anchor,ok := inner.app.anchor.Get(); ok {
return utils.Some(anchor.AppQC)
}
}
panic("unreachable")
return utils.None[*types.AppQC]()
}

// WaitForAppQC waits until there is an AppQC for the given index or higher.
// Returns this AppQC and the corresponding CommitQC.
// Together they provide enough information to prune the availability state.
func (s *State) WaitForAppQC(ctx context.Context, idx types.RoadIndex) (*types.AppQC, *types.CommitQC, error) {
func (s *State) waitForAnchor(ctx context.Context, idx types.RoadIndex) (*PruneAnchor, error) {
for inner, ctrl := range s.inner.Lock() {
for {
if appQC, ok := inner.app.latestAppQC.Get(); ok {
if x := appQC.Proposal().RoadIndex(); x >= idx && inner.commits.qcs.next > x {
return appQC, inner.commits.qcs.q[x], nil
if anchor, ok := inner.app.anchor.Get(); ok {
if x := anchor.AppQC.Proposal().RoadIndex(); x >= idx {
return anchor, nil
}
}
if err := ctrl.Wait(ctx); err != nil {
return nil, nil, err
return nil, err
}
}
}
Expand All @@ -45,38 +48,25 @@ func (s *State) WaitForAppQC(ctx context.Context, idx types.RoadIndex) (*types.A
// Same admit-then-verify as PushAppQC: far-future roads park until the duo
// and CommitQC tip catch up (one stream goroutine; does not block others).
func (s *State) PushAppVote(ctx context.Context, v *types.Signed[*types.AppVote]) error {
idx := v.Msg().Proposal().RoadIndex()
// A vote may arrive before its CommitQC advances the tip.
if err := s.waitForCommitQC(ctx, idx); err != nil {
return err
}
// Too-early roads (ahead of Prev|Current) backpressure; too-late are dropped.
admitted, err := s.waitForEpochDuoOrDropStale(ctx, "AppVote", idx)
qc,epoch,err := s.commitQCAndEpoch(ctx, v.Msg().Proposal().RoadIndex())
if err != nil {
if errors.Is(err,types.ErrPruned) {
return nil
}
return err
}
duo, ok := admitted.Get()
if !ok {
return nil
if err := v.Msg().Proposal().Verify(qc); err != nil {
return fmt.Errorf("invalid vote: %w", err)
}
ep := utils.OrPanic1(duo.EpochForRoad(idx))
if got, want := v.Msg().Proposal().EpochIndex(), ep.EpochIndex(); got != want {
return fmt.Errorf("appVote epoch_index %d, want %d", got, want)
}
committee := ep.Committee()
committee := epoch.Committee()
if err := v.VerifySig(committee); err != nil {
return fmt.Errorf("v.VerifySig(): %w", err)
}
for inner, ctrl := range s.inner.Lock() {
// Early exit if not useful (we collect <=1 AppQC per road index).
if idx < types.NextOpt(inner.app.latestAppQC) {
if qc.Index() < types.NextOpt(inner.app.anchor) {
return nil
}
// Verify the vote against the CommitQC.
qc := inner.commits.qcs.q[idx]
if err := v.Msg().Proposal().Verify(qc); err != nil {
return fmt.Errorf("invalid vote: %w", err)
}
// Push the vote.
n := v.Msg().Proposal().GlobalNumber()
q := inner.app.votes
Expand All @@ -87,30 +77,34 @@ func (s *State) PushAppVote(ctx context.Context, v *types.Signed[*types.AppVote]
if !ok {
return nil
}
updated, err := inner.pushPruneAnchor(&PruneAnchor{AppQC: appQC, CommitQC: qc})
if err != nil {
return err
}
// Anchor is always valid here.
updated := utils.OrPanic1(inner.pushPruneAnchor(&PruneAnchor{AppQC: appQC, CommitQC: qc, Epoch: epoch}))
if updated {
ctrl.Updated()
}
}
return nil
}

// PushAppQC requires a justifying CommitQC. Epoch slide is async in
// runAdvanceEpoch (same as PushCommitQC). Prune before insert so latestAppQC is
// visible before the advance task observes the new tip.
//
// Same admit-then-verify as PushCommitQC.
// PushAppQC requires a justifying CommitQC.
// Prunes state up to AppQC.Proposal().Index().
func (s *State) PushAppQC(ctx context.Context, appQC *types.AppQC, commitQC *types.CommitQC) error {
// If epoch is from the future then we are unable to process AppQC.
// If epoch is pruned, then it is from the past and there is no point participating in the consensus.
epoch, err := s.data.Registry().WaitForEpoch(ctx, appQC.Proposal().EpochIndex())
if err != nil {
if errors.Is(err,types.ErrPruned) {
return nil
}
return err
}
// Check whether it is needed before verifying.
for inner := range s.inner.Lock() {
if types.NextOpt(inner.app.latestAppQC) > appQC.Proposal().RoadIndex() {
if types.NextOpt(inner.app.anchor) >= appQC.Next() {
return nil
}
}
// Pair consistency only; ahead-of-window still waits in waitForEpochDuo.
// Verify appQC <-> commitQC consistency.
if appQC.Proposal().RoadIndex() != commitQC.Proposal().Index() {
return fmt.Errorf("mismatched QCs: appQC index %v, commitQC index %v", appQC.Proposal().RoadIndex(), commitQC.Proposal().Index())
}
Expand All @@ -120,38 +114,17 @@ func (s *State) PushAppQC(ctx context.Context, appQC *types.AppQC, commitQC *typ
if !commitQC.GlobalRange().Has(appQC.Proposal().GlobalNumber()) {
return fmt.Errorf("appQC GlobalNumber not in commitQC range")
}
idx := commitQC.Proposal().Index()
admitted, err := s.waitForEpochDuoOrDropStale(ctx, "AppQC", idx)
if err != nil {
return err
}
duo, ok := admitted.Get()
if !ok {
return nil
}
ep := utils.OrPanic1(duo.EpochForRoad(idx))
if err := appQC.Verify(ep); err != nil {
if err := appQC.Verify(epoch); err != nil {
return fmt.Errorf("appQC.Verify(): %w", err)
}
if err := commitQC.Verify(ep); err != nil {
if err := commitQC.Verify(epoch); err != nil {
return fmt.Errorf("commitQC.Verify(): %w", err)
}
// Seal CommitQC paired with this AppQC: incoming AppQC satisfies the prune
// leash; still wait on the execution leash when idx closes Current.
if duo.Current.RoadRange().Next-1 == idx && ep.EpochIndex() == duo.Current.EpochIndex() {
if err := s.waitSealLeashes(ctx, duo.Current, idx, utils.Some(appQC.Proposal().EpochIndex())); err != nil {
return err
}
}
anchor := &PruneAnchor{AppQC: appQC, CommitQC: commitQC, Epoch: epoch}
for inner, ctrl := range s.inner.Lock() {
updated, err := inner.pushPruneAnchor(&PruneAnchor{AppQC: appQC, CommitQC: commitQC})
if err != nil {
return err
}
if !updated {
return nil
if utils.OrPanic1(inner.pushPruneAnchor(anchor)) {
ctrl.Updated()
}
ctrl.Updated()
}
return nil
}
3 changes: 1 addition & 2 deletions sei-tendermint/internal/autobahn/avail/block_votes.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,7 @@ func (bv *blockVotes) pushVote(ep *types.Epoch, vote *types.Signed[*types.LaneVo
// reweight recomputes already-stored votes under new Current after advanceEpoch.
// Zero-weight signers are removed from byKey. Callers wake waiters via
// ctrl.Updated() after advanceEpoch (not via a return flag).
func (bv *blockVotes) reweight(newEpoch *types.Epoch) {
c := newEpoch.Committee()
func (bv *blockVotes) reweight(c *types.Committee) {
clear(bv.byHash)
quorum := c.LaneQuorum()
for k, vote := range bv.byKey {
Expand Down
Loading