Skip to content

Simplex orchestration layer preliminary implementation#427

Merged
yacovm merged 15 commits into
mainfrom
orchestration
Jul 13, 2026
Merged

Simplex orchestration layer preliminary implementation#427
yacovm merged 15 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
Comment thread instance.go Outdated
Comment thread instance.go Outdated
Comment thread instance.go
Comment thread instance.go Outdated
Comment thread instance.go Outdated
Comment thread adapters.go Outdated
@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 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
Comment thread instance.go
Comment thread instance.go Outdated
Comment thread instance.go Outdated
Comment thread instance.go
Comment thread adapters.go
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
Comment thread config.go Outdated
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 Outdated
}
}

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?

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 rebased and out of habit added it back

yacovm added 2 commits July 10, 2026 21:24
Signed-off-by: Yacov Manevich <yacov.manevich@avalabs.org>
…y after bootstrapping

Signed-off-by: Yacov Manevich <yacov.manevich@avalabs.org>
Comment thread msm/build_decision.go Outdated
Comment thread instance.go Outdated
ID common.NodeID
}

type MessageHandler interface {

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 be removed?

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 adapters.go Outdated
Comment thread adapters.go Outdated
Comment thread adapters.go Outdated
Comment thread instance.go Outdated
close(i.stopCh)
}

if i.e != nil {

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.

unnecessary since this is checked in stopValidator. Or is this defensive?

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 Outdated
Comment thread instance.go
Comment thread instance.go Outdated
Comment on lines +409 to +411
lastNonSimplexHeight := i.Config.LastNonSimplexInnerBlock.Height()
genesisValidatorSet := i.Config.PlatformChain.GenesisValidatorSet()
nodes, epochNum, err := constructEpochAndValidatorSet(lastNonSimplexHeight, genesisValidatorSet, numBlocks, ParsedBlock{StateMachineBlock: lastBlock}, i.Config.Storage)

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.

nit: this is duplicated in the Start function as well, might make sense to create another shared helper?

// currentEpochState reads the last persisted block and derives the epoch
// number and validator set the chain is currently in.
func (i *Instance) currentEpochState() (metadata.StateMachineBlock, common.Nodes, uint64, error) {
	lastBlock, numBlocks, err := i.lastBlock()
	if err != nil {
		return metadata.StateMachineBlock{}, nil, 0, err
	}

	lastNonSimplexHeight := i.Config.LastNonSimplexInnerBlock.Height()
	genesisValidatorSet := i.Config.PlatformChain.GenesisValidatorSet()
	nodes, epochNum, err := constructEpochAndValidatorSet(lastNonSimplexHeight, genesisValidatorSet, numBlocks, ParsedBlock{StateMachineBlock: lastBlock}, i.Config.Storage)
	if err != nil {
		return metadata.StateMachineBlock{}, nil, 0, fmt.Errorf("error determining latest epoch and validator set: %w", err)
	}

	return lastBlock, nodes, epochNum, nil
}

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 am not sure it's worth it, because we essentially add a new function that returns a bunch of parameters and then we lose their context/purpose, and we don't actually save a lot in the number of code lines saves as it's only duplicated once.

Comment thread instance.go Outdated
Comment thread instance.go Outdated
Comment thread instance.go Outdated
return i.startValidatorOrNonValidator(epochChange.validators, epochChange.epochNum)
}

func constructEpochAndValidatorSet(lastNonSimplexInnerBlockHeight uint64, genesisValidatorSet metadata.NodeBLSMappings, numBlocks uint64, lastBlock ParsedBlock, storage Storage) (common.Nodes, uint64, error) {

@samliok samliok Jul 10, 2026

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.

since this is an isolated function, should we have claude write some tests that we review for it?

alternatively since this is only used once should we just update the caller? I told claude to do this and it seemed to have made some good cleanups

// currentEpochState reads the last persisted block and derives the epoch
// number and validator set the chain is currently in.
func (i *Instance) currentEpochState() (metadata.StateMachineBlock, common.Nodes, uint64, error) {
	lastBlock, numBlocks, err := i.lastBlock()
	if err != nil {
		return metadata.StateMachineBlock{}, nil, 0, err
	}

	parsed := ParsedBlock{StateMachineBlock: lastBlock}
	sealingInfo := parsed.SealingBlockInfo()

	switch {
	// If all we have in the ledger is non-Simplex blocks, load the validator set from genesis.
	case i.Config.LastNonSimplexInnerBlock.Height()+1 == numBlocks:
		genesisValidatorSet := i.Config.PlatformChain.GenesisValidatorSet()
		return lastBlock, validatorSetToNodes(genesisValidatorSet), i.Config.LastNonSimplexInnerBlock.Height() + 1, nil
	// If the last block persisted is a sealing block, then we are in the next epoch,
	// which is identified by the sealing block's sequence and carries its validator set.
	case sealingInfo != nil:
		return lastBlock, sealingInfo.ValidatorSet, parsed.BlockHeader().Seq, nil
	// Else, we have at least one Simplex block in the ledger, and it's not a sealing block.
	// Its Epoch field is the sequence of the sealing block that started this epoch.
	default:
		sealingBlockSeq := parsed.BlockHeader().Epoch
		sealingBlock, _, err := i.Config.Storage.GetBlock(sealingBlockSeq)
		if err != nil {
			return metadata.StateMachineBlock{}, nil, 0, fmt.Errorf("error retrieving sealing block from storage: %w", err)
		}
		info := (&ParsedBlock{StateMachineBlock: sealingBlock}).SealingBlockInfo()
		if info == nil {
			return metadata.StateMachineBlock{}, nil, 0, fmt.Errorf("expected sealing block at seq %d, but got a non-sealing block", sealingBlockSeq)
		}
		return lastBlock, info.ValidatorSet, sealingBlockSeq, nil
	}
}

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>
@yacovm
yacovm marked this pull request as draft July 13, 2026 11:27
Signed-off-by: Yacov Manevich <yacov.manevich@avalabs.org>
@yacovm
yacovm marked this pull request as ready for review July 13, 2026 18:27
Comment thread external.go Outdated
Comment thread common/msg.go
Comment thread adapters.go Outdated
Comment thread external.go
Comment thread instance.go
yacovm added 3 commits July 13, 2026 22:50
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>
@yacovm
yacovm merged commit 2ed8980 into main Jul 13, 2026
6 checks passed
Comment thread instance.go

epochAwareStorage := &EpochAwareStorage{
epoch: epochNum,
Storage: i.Config.Storage,

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.

is there a reason we are not using the cached storage?

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 only need it for MSM

@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.

i think my previous approval was premature, so i'm just noting some more comments while i'm working on related code.

Comment thread instance.go
Comment on lines +69 to +76
cs *CachedStorage
wal *wal.GarbageCollectedWAL
msm *metadata.StateMachine
e *simplex.Epoch
nv *nonvalidator.NonValidator
epochOrNV timeAdvancer
epochChanges chan epochChange
stopCh chan 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.

can we add some helpful comments to these fields?

Comment thread instance.go
select {
case now := <-ticker.C:
i.lock.Lock()
timeAdvancer := i.epochOrNV

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 remove i.epochOrNV? it think it would be fine to just check if i.e != nil { } or i.nonValidator != nil

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 can remove it... sure. Do you want to make a PR and I'll approve?

Comment thread instance.go
return err
}

nonValidator, err := nonvalidator.NewNonValidator(config)

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, doesn't this restart the validator? so in the case of non-validator -> non-validator we still run into the problem of resetting the memory & needing to rebroadcast

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.

		onEpochChange: func(epoch uint64, validators common.Nodes) error {
			height := i.Config.PlatformChain.GetCurrentHeight()
			vdrs, err := i.Config.PlatformChain.GetValidatorSet(height)
			if err != nil {
				i.Config.Logger.Error("error getting validator set", zap.Error(err))
				return fmt.Errorf("error getting validator set from platform chain: %w", err)
			}
			comm.SetValidators(validators)
			if i.iAmValidator(vdrs.Nodes()) {
				i.notifyEpochChange(epoch, validators, nonValidator)
			} else {
				i.Config.Logger.Debug("I am still a non-validator at the tip of the P-chain, skipping role change",
					zap.Uint64("height", height))
			}
			return nil
		},

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 think this can work because the tip of the pchain might not correspond to the latest validator set.

I.e. our epoch may be transitioning to a new epoch, which means the latest pchain tip has a different validator set than the validators transitioning the epoch.

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.

all I'm saying is, if we remain a non-validator we do not do an epoch change.

Comment thread msm/msm.go
Comment on lines 1523 to 1528
// This is the first auxiliary info we're generating for this epoch,
// so we need to initialize it.
auxInfo = &AuxiliaryInfo{
VersionID: versionID,
Info: auxInf,
}

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.

What happens if other nodes timed out and we call generate twice? maybe the validator set is small so if 2 nodes time out, we will come back to our node and generate again

Comment thread adapters.go
return block.(*ParsedBlock).StateMachineBlock, finalization, nil
}

func (cs *CachedStorage) Retrieve(seq uint64, digest common.Digest) (common.VerifiedBlock, *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.

Retrieve may return a block if its only been verified. Is this a problem in the MSM code on the GetBlock callsites? I initially thought GetBlock would return a notarized or finalized block, in this case it can return neither.

Comment thread msm/msm.go
// Optimistically sign the epoch transition even if we have already did so in a previous round.
// We'll just deduplicate this approval later on.

sig, err := sm.createSelfApproval(prevBlockNextPChainReferenceHeight, auxInfoDigest)

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.

is it possible for the AuxInfoDigest to change once we are in the collecting approvals phase? i.e we are in the approvals phase if IsSufficient returns true, but can't another block add more aux info and IsSufficient would still be true, however the last digest would change.

Comment thread msm/msm.go
copy(payloadToSignBuff[8:], auxInfoDigest[:])

signedMsg := common.SignedMessage{Payload: payloadToSignBuff, Context: signatureContext}
return asn1.Marshal(signedMsg)

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.

do we ever marshal using canato? doesn't seem like it, so why do we include canator data?

Comment thread instance.go
i.Config.Logger.Error("error getting validator set", zap.Error(err))
return fmt.Errorf("error getting validator set from platform chain: %w", err)
}
comm.SetValidators(validators)

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 is actually a pretty big bug. The non-validator expects comm.Validators to be the most up-to-date validator set, rather than the validator set of the transitioning epoch.

What if we have a completely new set of validators at the tip and set the validators to a disjoint set here? Any replication requests would fail, and we would be stuck.

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.

Also we can't just use the latest pchain tip, we need to know the pchain height that is referenced by the last epoch...

Comment thread instance.go
return nil
}

func (i *Instance) transitionEpochNonValidator(epochChange epochChange) 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.

i think i mentioned this before, or maybe you did but we need to ensure the epoch we are changing into is the most current. If they are in this epochs validator set, yet are behind we should restart them as a non-validator.

Just noting this so we don't forget

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