Skip to content

Simplex orchestration layer preliminary implementation#427

Open
yacovm wants to merge 7 commits into
mainfrom
orchestration
Open

Simplex orchestration layer preliminary implementation#427
yacovm wants to merge 7 commits into
mainfrom
orchestration

Conversation

@yacovm

@yacovm yacovm commented Jun 29, 2026

Copy link
Copy Markdown
Collaborator

This commit contains an implementation of an orchestration layer that orchestrates
epoch transition for validators and non-validators as they move through epochs.

The orchestration layer introduces an Instance that drives the epoch lifecycle,
switching between validator consensus and non-validator block tracking as the
node's role changes across epochs.

Key pieces:

  • Instance: manages the per-epoch lifecycle, ticks the active epoch or
    non-validator, and handles the handoff at epoch boundaries.
  • epochChangeSupression: drops outgoing messages and cancels VM operations
    while an epoch change is in progress, preventing side effects mid-transition.
  • EpochAwareStorage: ignores blocks from previous epochs and signals when a
    new epoch is detected.
  • Config: wiring for the P-chain, storage, crypto, and networking dependencies.

Comment thread instance.go
// Copyright (C) 2019-2025, Ava Labs, Inc. All rights reserved.
// See the file LICENSE for licensing terms.

package simplex

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should the orchastration be in its own package?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it makes sense to put this in the top most package, to make it clear that it is the entry point to simplex.

Comment thread instance.go Outdated
// Parameter config
Epoch: epochNum,
ReplicationEnabled: true,
StartTime: time.Now(),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should the orchestration layer have its own time field? i don't see where we forward/call advanceTime

Comment thread instance.go Outdated
Comment on lines +183 to +184
MaxProposalWait: i.Config.MaxProposalWait,
MaxRebroadcastWait: i.Config.MaxRebroadcastWait,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think we should change in a seperate pr these to be consistent with avalanche go

MaxNetworkDelay    time.Duration   `json:"maxNetworkDelay"    yaml:"maxNetworkDelay"`
MaxRebroadcastWait time.Duration   `json:"maxRebroadcastWait" yaml:"maxRebroadcastWait"`

Comment thread instance.go
)


type Config struct {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe this type of structure is more organized? this way we can see exactly what is used for the MSM vs epoch vs shared

type Config struct {
      // Shared by both the metadata state machine and the epoch.
      Common CommonConfig
      // Used only to build the metadata state machine.
      MSM    MSMConfig
      // Used only to build the simplex epoch.
      Epoch  EpochParams
}

type CommonConfig struct {
      Logger                     common.Logger
      Signer                     common.Signer
      SignatureAggregatorCreator common.SignatureAggregatorCreator
      VM                         VM
}

type MSMConfig struct {
      GenesisValidatorSet         metadata.NodeBLSMappings
      LastNonSimplexInnerBlock    metadata.VMBlock
      GetPChainHeightForProposing func() uint64
      GetPChainHeightForVerifying func() uint64
      ComputeICMEpoch             metadata.ICMEpochTransition
}

type EpochParams struct {
      ID                         common.NodeID
      Sender                     Sender
      Storage                    Storage
      QCDeserializer             common.QCDeserializer
      BlockDeserializer          common.BlockDeserializer
      Verifier                   common.SignatureVerifier
      MaxProposalWait            time.Duration
      MaxRoundWindow             uint64
      MaxRebroadcastWait         time.Duration
      FinalizeRebroadcastTimeout time.Duration
      MaxWALSize                 int
      WALCreator                 wal.Creator
      WALs                       []wal.DeletableWAL
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this could also help with the organization we talked about at Monday's consensus sync. The caller can see which interfaces it needs to implement and which purposes

Comment thread instance.go Outdated
PChainProgressListener metadata.PChainProgressListener
// LastNonSimplexBlockPChainHeight is the P-chain height of the last block built by a non-Simplex proposer.
// It is used to determine the validator set of the first ever Simplex h.
LastNonSimplexBlockPChainHeight uint64

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dont see where this is uesd

Comment thread instance.go Outdated

ComputeICMEpoch metadata.ICMEpochTransition
// PChainProgressListener listens for changes in the P-chain height to trigger block building or h transitions.
PChainProgressListener metadata.PChainProgressListener

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same, this isn't used anywhere

Comment thread adapters.go

func (e *EpochAwareStorage) Index(ctx context.Context, block common.VerifiedBlock, certificate common.Finalization) error {
if block.BlockHeader().Epoch < e.Epoch {
// This is a Telock from a previous h, so we ignore it and do not index it.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how does this mean the block is a telock? don't telocks have the same epoch as the sealing block?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is a Telock from the previous epoch. We may collect a finalization on a Telock and then we first index the sealing block and then the Telocks. Once we index the sealing block, we increment our epoch, right?
We don't want to index the Telocks too, so this is a safeguard against that.

@yacovm yacovm force-pushed the orchestration branch 9 times, most recently from ed41178 to 55576be Compare July 6, 2026 22:46
@yacovm yacovm changed the title Epoch orchestration layer Simplex orchestration layer preliminary implementation Jul 7, 2026

@samliok samliok left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

leaving what i have, was not able to review as much as i hoped today

Comment thread adapters.go Outdated
Comment thread adapters.go Outdated
Comment thread adapters.go Outdated
Comment on lines +21 to +88
type epochChangeSupression struct {
lock sync.RWMutex
sealingBlockSeq uint64
active bool
}

func (ecs *epochChangeSupression) isSupressionActive() bool {
ecs.lock.RLock()
defer ecs.lock.RUnlock()
return ecs.active
}

func (ecs *epochChangeSupression) sendProhibited(seq uint64) bool {
ecs.lock.RLock()
defer ecs.lock.RUnlock()
if !ecs.active {
return false
}
return seq > ecs.sealingBlockSeq
}

func (ecs *epochChangeSupression) setSupression(sealingBlockSeq uint64) {
ecs.lock.Lock()
defer ecs.lock.Unlock()
ecs.sealingBlockSeq = sealingBlockSeq
ecs.active = true
}

func (ecs *epochChangeSupression) clearSupression() {
ecs.lock.Lock()
defer ecs.lock.Unlock()
ecs.active = false
}

type Communication struct {
epochChangeSupression *epochChangeSupression
nodes atomic.Value // common.Nodes
Sender
Broadcaster
}

func (c *Communication) SetValidators(nodes common.Nodes) {
c.nodes.Store(nodes)
}

func (c *Communication) Validators() common.Nodes {
nodes, ok := c.nodes.Load().(common.Nodes)
if !ok {
return nil
}
return nodes
}

func (c *Communication) Broadcast(msg *common.Message) {
if c.epochChangeSupression.sendProhibited(msg.Seq()) {
return
}

c.Broadcaster.Broadcast(msg)
}

func (c *Communication) Send(msg *common.Message, destination common.NodeID) {
if c.epochChangeSupression.sendProhibited(msg.Seq()) {
return
}

c.Sender.Send(msg, destination)
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think we should remove this comm adapter completely.

IMO the epoch should be the one filtering these messages themselves. I think it would simplify the orchestrator code a bit, remove some weirdness with the msg.Seq function, and make the epoch more explicit about how it handles future epoch messages.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

plus if we add other messages to the epoch we need to remember to properly adjust the msg.Seq which is not that intuitive. Because whether a message gets sent to the epoch is actually controlled by msg.Seq() which is not an ideal place.

For example the block digest request literally has a seq field and you would expect msg.seq to return BlockDigestRequest.seq but instead it returns 0.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it might make sense creating the epoch.go changes in a separate pr to be merged before this one?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment thread msm/build_decision.go Outdated
// P-chain height referenced by the last block in the chain and until the provided P-chain height.
hasValidatorSetChanged func(pChainHeight uint64) (bool, NodeBLSMappings, error)
getPChainHeight func() uint64
getPChainHeight func(ctx context.Context) (uint64, error)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are these changes important to the orchestrator? if not can we separate them?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment thread msm/msm.go
Comment thread msm/util_test.go Outdated
}

// mockBlockBuilder is a test BlockBuilder whose BuildBlock returns a preconfigured block/error.
type mockBlockBuilder struct {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think the use of the word mock will be triggering to some members of the avalanchego team

@yacovm yacovm force-pushed the orchestration branch 2 times, most recently from 1bfcbc4 to 5eeffd9 Compare July 9, 2026 16:05
yacovm added 5 commits July 9, 2026 18:27
This commit contains an implementation of an orchestration layer that orchestrates
epoch transition for validators and non-validators as they move through epochs.

The orchestration layer introduces an Instance that drives the epoch lifecycle,
switching between validator consensus and non-validator block tracking as the
node's role changes across epochs.

Key pieces:

- Instance: manages the per-epoch lifecycle, ticks the active epoch or
    non-validator, and handles the handoff at epoch boundaries.
- epochChangeSupression: drops outgoing messages and cancels VM operations
    while an epoch change is in progress, preventing side effects mid-transition.
- EpochAwareStorage: ignores blocks from previous epochs and signals when a
    new epoch is detected.
- Config: wiring for the P-chain, storage, crypto, and networking dependencies.

Signed-off-by: Yacov Manevich <yacov.manevich@avalabs.org>
Signed-off-by: Yacov Manevich <yacov.manevich@avalabs.org>
Signed-off-by: Yacov Manevich <yacov.manevich@avalabs.org>
Signed-off-by: Yacov Manevich <yacov.manevich@avalabs.org>
Signed-off-by: Yacov Manevich <yacov.manevich@avalabs.org>
Comment thread external.go
Comment thread msm/util_test.go
Comment thread nonvalidator/non_validator.go Outdated
)

const (
DefaultMaxSequenceWindow = 100

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think this should be a similar value to DefaultMaxRoundWindow, if not the same. Mainly because validators will reject replication that have more seqs than MaxRoundWindow.

BTW, I think we should change how we process replication requests, to not drop messages that request too many sequences but rather fill up the response with at most MaxRoundWindow. Also, we should ensure this message does not exceed the maximum number of bytes AvalancheGo clients can accept.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah good catch, I removed and changed it here.

Comment thread instance.go Outdated
return lastBlock, numBlocks, nil
}

func (i *Instance) determineValidatorOrNot(nodes common.Nodes) bool {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thoughts on changing this to nodes.Contains(myNodeId)?

or making the function header a bit more clear

Suggested change
func (i *Instance) determineValidatorOrNot(nodes common.Nodes) bool {
func (i *Instance) isMyNodeValidator(validators common.Nodes) bool {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed it to iAmValidator before seeing this comment, I think it's equivalent.

Comment thread instance.go
type MessageHandler interface {
}

type nodeRole byte

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this nodeRole variable is confusing. It represents the previous role of the node right? Why do we need to know this?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe something like this works?
#434

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to know this in order to know which node type to shutdown and whether to skip shutting down a non-validator in case it is still a non validator in the next epoch.

I changed the code significantly before looking at this suggestion so please let me know if you think it's still relevant.

Comment thread instance.go Outdated
Comment on lines +547 to +560
config, err := i.createEpochConfig()
if err != nil {
i.Config.Logger.Error("Error creating epoch config on epoch change", zap.Error(err))
return
}

if config.Epoch != epochChange.epochNum {
i.Config.Logger.Error("Epoch number mismatch on epoch change", zap.Uint64("expected", epochChange.epochNum), zap.Uint64("actual", config.Epoch))
return
}

if err := i.startEpoch(config); err != nil {
i.Config.Logger.Error("Error starting new epoch on epoch change", zap.Error(err))
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think we should have one unified way of starting a validator. We have this logic here, but we have a different method startValidator

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, I changed accordingly.

Comment thread instance.go Outdated
// First, figure out if I'm still a validator.
if i.determineValidatorOrNot(epochChange.validators) {
i.Config.Logger.Info("I am now a validator")
i.startValidator()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we are dropping the error here, shouldn't this be propagated up? Otherwise, we will just be dropping messages not knowing why we failed starting

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

whoops, good catch. Made us halting the node entirely if we fail.

Comment thread instance.go
cachedStorage := NewCachedStorage(i.Config.Storage)
i.cs = cachedStorage

lastBlock, numBlocks, err := i.lastBlock()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

missing error check

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed, thanks

Comment thread adapters.go
Comment on lines +38 to +42
msm *metadata.StateMachine
OnEpochChange func(seq uint64, validators common.Nodes) error
Storage
Epoch uint64
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why are some of these fields exported while others aren't?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed.

Comment thread config.go
// GetBlock retrieves the block and finalization at [seq] with the given digest.
// If the digest is nil, the block with the given sequence number is returned.
// If [seq] the block cannot be found, returns ErrBlockNotFound.
GetBlock(seq uint64) (metadata.StateMachineBlock, *common.Finalization, error)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we use the same terminology as simplex.Storage? maybe we can embed simplex.Storage in this API?

type Storage interface {
simplex.Storage

// CreateWAL creates a new Write-Ahead Log (WAL).
	CreateWAL() (wal.DeletableWAL, error)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do couple the WAL with the storage anyways?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we use the same terminology as simplex.Storage? maybe we can embed simplex.Storage in this API?

We can't, Retrieve conflicts because the parameters are different.

./instance.go:468:12: cannot use i.cs (variable of type *CachedStorage) as Storage value in struct literal: *CachedStorage does not implement Storage (wrong type for method Retrieve)
		have Retrieve(uint64, [32]byte) (common.VerifiedBlock, *common.Finalization, error)
		want Retrieve(uint64) (common.VerifiedBlock, common.Finalization, error)

why do couple the WAL with the storage anyways?

Yeah, so i was trying to minimize the number of dependencies. I was too frugal to dedicate an entire interface for a single method. I can do it though

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yea i think it makes sense to separate it

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment thread instance.go
defer i.lock.Unlock()

// Stop the non-validator before doing anything else, so that we don't process any more messages while we are changing epochs.
i.stopNonValidator()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i dont get why we need to stop and restart a non-validator for every epoch change. This actually seems like a problem during a non-validators bootstrapping phase.

Non-validators backwards hash-chain validate epochs. This process involves storing requested future sealing blocks in memory and then indexing in order. However, when we index a sealing block, we would restart the non-validator and therefore clear its cached memory of sealing blocks meaning it needs to restart the requesting phase.

The non-validator is epoch agnostic so I think if we transition from non-validator -> non-validator all we need to do is update the communication interface to return the latest validator set.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

even further, if we go from non-validator -> validator, and we are aware of a future epoch we should still be in the non-validating phase?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, all very good callouts 👍

I was aware that the current approach isn't ideal but was in a hurry to meet the end of month deadline.

Take a look at the latest code version in the meantime. I will add a test a bit later that will check that we don't needlessly restart non-validators.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Signed-off-by: Yacov Manevich <yacov.manevich@avalabs.org>
Signed-off-by: Yacov Manevich <yacov.manevich@avalabs.org>
Comment thread config.go

type CryptoOps interface {
Sign(message []byte) ([]byte, error)
AggregateKeys(keys ...[]byte) ([]byte, error)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

instead of redefining the functions should we just inline the interfaces from msm

Suggested change
AggregateKeys(keys ...[]byte) ([]byte, error)
metadata.KeyAggregator

same with the others

	common.Signer
	common.SignatureVerifier
	common.QCDeserializer
	metadata.KeyAggregator

Comment thread config.go
type ParameterConfig struct {
// WalMaxEntryCount is the maximum number of entries in the write-ahead log before it is closed.
WALMaxEntryCount int
// MaxnetworkDelay is the assumed upper bound on the network delay for messages to be delivered.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// MaxnetworkDelay is the assumed upper bound on the network delay for messages to be delivered.
// MaxNetworkDelay is the assumed upper bound on the network delay for messages to be delivered.

Comment thread config.go
// GetBlock retrieves the block and finalization at [seq] with the given digest.
// If the digest is nil, the block with the given sequence number is returned.
// If [seq] the block cannot be found, returns ErrBlockNotFound.
GetBlock(seq uint64) (metadata.StateMachineBlock, *common.Finalization, error)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yea i think it makes sense to separate it

Comment thread common/msg.go
}
}

func (m *Message) Seq() uint64 {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wait why is this added back?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants