diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS deleted file mode 100644 index 4b621c78cf..0000000000 --- a/.github/CODEOWNERS +++ /dev/null @@ -1,2 +0,0 @@ -* @patrick-ogrady -/x/programs/ @dboehm-avalabs @iFrostizz @richardpringle diff --git a/builder/time.go b/builder/time.go index a6e654f40e..c15e740d73 100644 --- a/builder/time.go +++ b/builder/time.go @@ -76,7 +76,7 @@ func (b *Time) Queue(ctx context.Context) { return } now := time.Now().UnixMilli() - next := b.nextTime(now, preferredBlk.Tmstmp) + next := b.nextTime(now, preferredBlk.StatefulBlock.Timestamp) if next < 0 { if err := b.Force(ctx); err != nil { b.vm.Logger().Warn("unable to build", zap.Error(err)) diff --git a/chain/block.go b/chain/block.go index 3ca5c97fe5..c6b7c1ebec 100644 --- a/chain/block.go +++ b/chain/block.go @@ -4,10 +4,10 @@ package chain import ( - "bytes" "context" - "encoding/binary" + "encoding/hex" "encoding/json" + "errors" "fmt" "time" @@ -15,19 +15,15 @@ import ( "github.com/ava-labs/avalanchego/snow/choices" "github.com/ava-labs/avalanchego/snow/consensus/snowman" "github.com/ava-labs/avalanchego/snow/engine/snowman/block" + "github.com/ava-labs/avalanchego/utils/crypto/bls" "github.com/ava-labs/avalanchego/utils/set" - "github.com/ava-labs/avalanchego/x/merkledb" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" "go.uber.org/zap" "github.com/AnomalyFi/hypersdk/codec" "github.com/AnomalyFi/hypersdk/consts" - "github.com/AnomalyFi/hypersdk/fees" - "github.com/AnomalyFi/hypersdk/state" "github.com/AnomalyFi/hypersdk/utils" - "github.com/AnomalyFi/hypersdk/window" - "github.com/AnomalyFi/hypersdk/workers" "github.com/celestiaorg/nmt" ethhex "github.com/ethereum/go-ethereum/common/hexutil" @@ -39,34 +35,30 @@ var ( ) type StatefulBlock struct { - Prnt ids.ID `json:"parent"` - Tmstmp int64 `json:"timestamp"` - Hght uint64 `json:"height"` + PHeight uint64 `json:"pHeight"` - Txs []*Transaction `json:"txs"` + Parent ids.ID `json:"parent"` + Timestamp int64 `json:"timestamp"` + Height uint64 `json:"height"` + // Ethereum L1 block number, when this block is built. L1Head int64 `json:"l1_head"` - // StateRoot is the root of the post-execution state - // of [Prnt]. - // - // This "deferred root" design allows for merklization - // to be done asynchronously instead of during [Build] - // or [Verify], which reduces the amount of time we are - // blocking the consensus engine from voting on the block, - // starting the verification of another block, etc. - StateRoot ids.ID `json:"stateRoot"` + // AvailableChunks is a collection of valid Chunks that will be executed in + // the future. + AvailableChunks []*ChunkCertificate `json:"availableChunks"` + + ExecutedChunks []ids.ID `json:"executedChunks"` + Checksum ids.ID `json:"checksum"` NMTRoot []byte `json:"nmtRoot"` NMTNamespaceToTxIndexes map[string][][2]int `json:"namespaceToTxIndex"` NMTProofs map[string]nmt.Proof `json:"nmtProofs"` - size int + built bool - // authCounts can be used by batch signature verification - // to preallocate memory - authCounts map[uint8]int + size int } func (b *StatefulBlock) Size() int { @@ -81,7 +73,123 @@ func (b *StatefulBlock) ID() (ids.ID, error) { return utils.ToID(blk), nil } -func NewGenesisBlock(root ids.ID) *StatefulBlock { +func (b *StatefulBlock) Marshal() ([]byte, error) { + size := consts.Uint64Len + ids.IDLen + consts.Int64Len + consts.Uint64Len + consts.Int64Len + + consts.IntLen + codec.CummSize(b.AvailableChunks) + + consts.IntLen + len(b.ExecutedChunks)*ids.IDLen + ids.IDLen + len(b.NMTRoot) + + p := codec.NewWriter(size, consts.NetworkSizeLimit) + + p.PackUint64(b.PHeight) + p.PackID(b.Parent) + p.PackInt64(b.Timestamp) + p.PackUint64(b.Height) + p.PackInt64(b.L1Head) + + p.PackInt(len(b.AvailableChunks)) + for _, cert := range b.AvailableChunks { + if err := cert.MarshalPacker(p); err != nil { + return nil, err + } + } + + p.PackInt(len(b.ExecutedChunks)) + for _, chunk := range b.ExecutedChunks { + p.PackID(chunk) + } + p.PackID(b.Checksum) + + p.PackBytes(b.NMTRoot) + + proofsJson, err := json.Marshal(b.NMTProofs) + if err != nil { + return nil, err + } + p.PackBytes(proofsJson) + + nmtNSTxMapping, err := json.Marshal(b.NMTNamespaceToTxIndexes) + if err != nil { + return nil, err + } + p.PackBytes(nmtNSTxMapping) + + bytes := p.Bytes() + if err := p.Err(); err != nil { + return nil, err + } + b.size = len(bytes) + return bytes, nil +} + +func UnmarshalBlock(raw []byte) (*StatefulBlock, error) { + var ( + p = codec.NewReader(raw, consts.NetworkSizeLimit) + b StatefulBlock + ) + + b.PHeight = p.UnpackUint64(false) // 0 when building without context + + p.UnpackID(false, &b.Parent) + b.Timestamp = p.UnpackInt64(false) + b.Height = p.UnpackUint64(false) + b.L1Head = p.UnpackInt64(false) + + // Parse available chunks + availableChunks := p.UnpackInt(false) // can produce empty blocks + b.AvailableChunks = make([]*ChunkCertificate, 0, 16) // don't preallocate all to avoid DoS + seen := set.NewSet[ids.ID](16) // TODO: make prealloc a config + for i := 0; i < availableChunks; i++ { + cert, err := UnmarshalChunkCertificatePacker(p) + if err != nil { + return nil, err + } + b.AvailableChunks = append(b.AvailableChunks, cert) + if seen.Contains(cert.Chunk) { + return nil, fmt.Errorf("duplicate chunk %s in block %d", cert.Chunk, b.Height) + } + seen.Add(cert.Chunk) + } + + // Parse executed chunks + executedChunks := p.UnpackInt(false) // can produce empty blocks + b.ExecutedChunks = []ids.ID{} // don't preallocate all to avoid DoS + for i := 0; i < executedChunks; i++ { + var id ids.ID + p.UnpackID(true, &id) + b.ExecutedChunks = append(b.ExecutedChunks, id) + } + p.UnpackID(false, &b.Checksum) // some batches may be empty + + p.UnpackBytes(consts.NMTRootLen, false, &b.NMTRoot) + proofs := make(map[string]nmt.Proof) + + proofBytes := make([]byte, 0, 1024) + p.UnpackBytes(consts.MaxNMTProofBytes, false, &proofBytes) + err := json.Unmarshal(proofBytes, &proofs) + if err != nil { + return nil, err + } + + txNSMappingBytes := make([]byte, 0, 256) + txNsMapping := make(map[string][][2]int) + p.UnpackBytes(consts.MaxNSTxMappingBytes, false, &txNSMappingBytes) + err = json.Unmarshal(txNSMappingBytes, &txNsMapping) + if err != nil { + fmt.Println("unable to json.unmarshal tx to ns mapping") + return nil, err + } + + b.NMTProofs = proofs + b.NMTNamespaceToTxIndexes = txNsMapping + + // Ensure no leftover bytes + if !p.Empty() { + return nil, fmt.Errorf("%w: message=%d remaining=%d extra=%x", ErrInvalidObject, len(raw), len(raw)-p.Offset(), raw[p.Offset():]) + } + return &b, p.Err() +} + +func NewGenesisBlock(checksum ids.ID) *StatefulBlock { return &StatefulBlock{ // We set the genesis block timestamp to be after the ProposerVM fork activation. // @@ -91,10 +199,10 @@ func NewGenesisBlock(root ids.ID) *StatefulBlock { // // Link: https://github.com/ava-labs/avalanchego/blob/0ec52a9c6e5b879e367688db01bb10174d70b212 // .../vms/proposervm/pre_fork_block.go#L201 - Tmstmp: time.Date(2023, time.January, 1, 0, 0, 0, 0, time.UTC).UnixMilli(), + Timestamp: time.Date(2023, time.January, 1, 0, 0, 0, 0, time.UTC).UnixMilli(), // StateRoot should include all allocates made when loading the genesis file - StateRoot: root, + Checksum: checksum, } } @@ -108,28 +216,30 @@ type ETHBlock struct { type StatelessBlock struct { *StatefulBlock `json:"block"` - id ids.ID - st choices.Status - t time.Time - bytes []byte - txsSet set.Set[ids.ID] + vm VM + + id ids.ID + st choices.Status + t time.Time + bytes []byte - results []*Result - feeManager *fees.Manager + verifiedCerts bool - vm VM - view merkledb.View + parent *StatelessBlock + execHeight *uint64 + // feeManager *fees.Manager - sigJob workers.Job + chunks set.Set[ids.ID] } -func NewBlock(vm VM, parent snowman.Block, tmstp int64) *StatelessBlock { +func NewBlock(vm VM, pHeight uint64, parent snowman.Block, tmstp int64) *StatelessBlock { return &StatelessBlock{ StatefulBlock: &StatefulBlock{ - Prnt: parent.ID(), - Tmstmp: tmstp, - Hght: parent.Height() + 1, - L1Head: vm.LastL1Head(), + PHeight: pHeight, + Parent: parent.ID(), + Timestamp: tmstp, + Height: parent.Height() + 1, + L1Head: vm.LastL1Head(), }, vm: vm, st: choices.Processing, @@ -145,7 +255,7 @@ func ParseBlock( ctx, span := vm.Tracer().Start(ctx, "chain.ParseBlock") defer span.End() - blk, err := UnmarshalBlock(source, vm) + blk, err := UnmarshalBlock(source) if err != nil { return nil, err } @@ -153,49 +263,6 @@ func ParseBlock( return ParseStatefulBlock(ctx, blk, source, status, vm) } -// populateTxs is only called on blocks we did not build -func (b *StatelessBlock) populateTxs(ctx context.Context) error { - ctx, span := b.vm.Tracer().Start(ctx, "StatelessBlock.populateTxs") - defer span.End() - - // Setup signature verification job - _, sigVerifySpan := b.vm.Tracer().Start(ctx, "StatelessBlock.verifySignatures") //nolint:spancheck - job, err := b.vm.AuthVerifiers().NewJob(len(b.Txs)) - if err != nil { - return err //nolint:spancheck - } - b.sigJob = job - batchVerifier := NewAuthBatch(b.vm, b.sigJob, b.authCounts) - - // Make sure to always call [Done], otherwise we will block all future [Workers] - defer func() { - // BatchVerifier is given the responsibility to call [b.sigJob.Done()] because it may add things - // to the work queue async and that may not have completed by this point. - go batchVerifier.Done(func() { sigVerifySpan.End() }) - }() - - // Confirm no transaction duplicates and setup - // AWM processing - b.txsSet = set.NewSet[ids.ID](len(b.Txs)) - for _, tx := range b.Txs { - // Ensure there are no duplicate transactions - if b.txsSet.Contains(tx.ID()) { - return ErrDuplicateTx - } - b.txsSet.Add(tx.ID()) - - // Verify signature async - if b.vm.GetVerifyAuth() { - txDigest, err := tx.Digest() - if err != nil { - return err - } - batchVerifier.Add(txDigest, tx.Auth) - } - } - return nil -} - func ParseStatefulBlock( ctx context.Context, blk *StatefulBlock, @@ -203,11 +270,11 @@ func ParseStatefulBlock( status choices.Status, vm VM, ) (*StatelessBlock, error) { - ctx, span := vm.Tracer().Start(ctx, "chain.ParseStatefulBlock") + _, span := vm.Tracer().Start(ctx, "chain.ParseStatefulBlock") defer span.End() // Perform basic correctness checks before doing any expensive work - if blk.Tmstmp > time.Now().Add(FutureBound).UnixMilli() { + if blk.Timestamp > time.Now().Add(FutureBound).UnixMilli() { return nil, ErrTimestampTooLate } @@ -220,7 +287,7 @@ func ParseStatefulBlock( } b := &StatelessBlock{ StatefulBlock: blk, - t: time.UnixMilli(blk.Tmstmp), + t: time.UnixMilli(blk.Timestamp), bytes: source, st: status, vm: vm, @@ -230,59 +297,62 @@ func ParseStatefulBlock( // If we are parsing an older block, it will not be re-executed and should // not be tracked as a parsed block lastAccepted := b.vm.LastAcceptedBlock() - if lastAccepted == nil || b.Hght <= lastAccepted.Hght { // nil when parsing genesis + if lastAccepted == nil || blk.Height <= lastAccepted.StatefulBlock.Height { // nil when parsing genesis return b, nil } - - // Populate hashes and tx set - return b, b.populateTxs(ctx) + // Update set (handle this on built) + b.chunks = set.NewSet[ids.ID](len(blk.AvailableChunks)) + for _, cert := range blk.AvailableChunks { + b.chunks.Add(cert.Chunk) + } + return b, nil } // [initializeBuilt] is invoked after a block is built -func (b *StatelessBlock) initializeBuilt( - ctx context.Context, - view merkledb.View, - results []*Result, - feeManager *fees.Manager, -) error { - ctx, span := b.vm.Tracer().Start(ctx, "StatelessBlock.initializeBuilt") - defer span.End() - - // NMT generation must before block marshal - _, nmtSpan := b.vm.Tracer().Start(ctx, "StatelessBlock.NMTGen") - txsDataToProve, nmtNSs, NMTNamespaceToTxIndexes, err := ExtractTxsDataToApproveFromTxs(b.Txs, results) - if err != nil { - b.vm.Logger().Error("unable to extract data from txs", zap.Error(err)) - return err - } - - _, nmtRoot, NMTProofs, err := BuildNMTTree(txsDataToProve, nmtNSs) - if err != nil { - b.vm.Logger().Error("unable to build NMT tree", zap.Error(err)) - return err - } - nmtSpan.End() - - b.NMTRoot = nmtRoot - b.NMTNamespaceToTxIndexes = NMTNamespaceToTxIndexes - b.NMTProofs = NMTProofs - - blk, err := b.StatefulBlock.Marshal() - if err != nil { - return err - } - b.bytes = blk - b.id = utils.ToID(b.bytes) - b.view = view - b.t = time.UnixMilli(b.StatefulBlock.Tmstmp) - b.results = results - b.feeManager = feeManager - b.txsSet = set.NewSet[ids.ID](len(b.Txs)) - for _, tx := range b.Txs { - b.txsSet.Add(tx.ID()) - } - return nil -} +// func (b *StatelessBlock) initializeBuilt( +// ctx context.Context, +// view merkledb.View, +// results []*Result, +// feeManager *fees.Manager, +// ) error { +// ctx, span := b.vm.Tracer().Start(ctx, "StatelessBlock.initializeBuilt") +// defer span.End() + +// // NMT generation must before block marshal +// _, nmtSpan := b.vm.Tracer().Start(ctx, "StatelessBlock.NMTGen") +// txsDataToProve, nmtNSs, NMTNamespaceToTxIndexes, err := ExtractTxsDataToApproveFromTxs(b.Txs, results) +// if err != nil { +// b.vm.Logger().Error("unable to extract data from txs", zap.Error(err)) +// return err +// } + +// _, nmtRoot, NMTProofs, err := BuildNMTTree(txsDataToProve, nmtNSs) +// if err != nil { +// b.vm.Logger().Error("unable to build NMT tree", zap.Error(err)) +// return err +// } +// nmtSpan.End() + +// b.NMTRoot = nmtRoot +// b.NMTNamespaceToTxIndexes = NMTNamespaceToTxIndexes +// b.NMTProofs = NMTProofs + +// blk, err := b.StatefulBlock.Marshal() +// if err != nil { +// return err +// } +// b.bytes = blk +// b.id = utils.ToID(b.bytes) +// b.view = view +// b.t = time.UnixMilli(b.StatefulBlock.Tmstmp) +// b.results = results +// b.feeManager = feeManager +// b.txsSet = set.NewSet[ids.ID](len(b.Txs)) +// for _, tx := range b.Txs { +// b.txsSet.Add(tx.ID()) +// } +// return nil +// } // implements "snowman.Block.choices.Decidable" func (b *StatelessBlock) ID() ids.ID { return b.id } @@ -290,281 +360,231 @@ func (b *StatelessBlock) ID() ids.ID { return b.id } // implements "snowman.Block" func (b *StatelessBlock) Verify(ctx context.Context) error { start := time.Now() + success := false defer func() { - b.vm.RecordBlockVerify(time.Since(start)) + if success { + b.vm.RecordBlockVerify(time.Since(start)) + } else { + b.vm.RecordBlockVerifyFail() + } }() - stateReady := b.vm.StateReady() ctx, span := b.vm.Tracer().Start( ctx, "StatelessBlock.Verify", trace.WithAttributes( - attribute.Int("txs", len(b.Txs)), - attribute.Int64("height", int64(b.Hght)), - attribute.Bool("stateReady", stateReady), - attribute.Bool("built", b.Processed()), + attribute.Int64("height", int64(b.StatefulBlock.Height)), ), ) defer span.End() - log := b.vm.Logger() - switch { - case !stateReady: - // If the state of the accepted tip has not been fully fetched, it is not safe to - // verify any block. - log.Info( - "skipping verification, state not ready", - zap.Uint64("height", b.Hght), - zap.Stringer("blkID", b.ID()), + // Skip verification if we built this block + if b.built { + b.vm.Logger().Info( + "skipping verify because built block", + zap.Stringer("blockID", b.ID()), + zap.Uint64("height", b.StatefulBlock.Height), + zap.Stringer("parentID", b.Parent()), ) - case b.Processed(): - // If we built the block, the state will already be populated and we don't - // need to compute it (we assume that we built a correct block and it isn't - // necessary to re-verify anything). - log.Info( - "skipping verification, already processed", - zap.Uint64("height", b.Hght), - zap.Stringer("blkID", b.ID()), - ) - default: - // Get the [VerifyContext] needed to process this block. - // - // If the parent block's height is less than or equal to the last accepted height (and - // the last accepted height is processed), the accepted state will be used as the execution - // context. Otherwise, the parent block will be used as the execution context. - vctx, err := b.vm.GetVerifyContext(ctx, b.Hght, b.Prnt) - if err != nil { - b.vm.Logger().Warn("unable to get verify context", - zap.Uint64("height", b.Hght), - zap.Stringer("blkID", b.ID()), - zap.Error(err), - ) - return fmt.Errorf("%w: unable to load verify context", err) - } - - // Parent block may not be processed when we verify this block, so [innerVerify] may - // recursively verify ancestry. - if err := b.innerVerify(ctx, vctx); err != nil { - b.vm.Logger().Warn("verification failed", - zap.Uint64("height", b.Hght), - zap.Stringer("blkID", b.ID()), - zap.Error(err), - ) - return err - } + b.vm.Verified(ctx, b) + return nil } - // At any point after this, we may attempt to verify the block. We should be - // sure we are prepared to do so. - // - // NOTE: mempool is modified by VM handler - b.vm.Verified(ctx, b) - return nil -} - -// innerVerify executes the block on top of the provided [VerifyContext]. -// -// Invariants: -// Accepted / Rejected blocks should never have Verify called on them. -// Blocks that were verified (and returned nil) with Verify will not have verify called again. -// Blocks that were verified with VerifyWithContext may have verify called multiple times. -// -// When this may be called: -// 1. [Verify|VerifyWithContext] -// 2. If the parent view is missing when verifying (dynamic state sync) -// 3. If the view of a block we are accepting is missing (finishing dynamic -// state sync) -func (b *StatelessBlock) innerVerify(ctx context.Context, vctx VerifyContext) error { + // Ensure p-chain height referenced is valid var ( - log = b.vm.Logger() - r = b.vm.Rules(b.Tmstmp) + log = b.vm.Logger() + r = b.vm.Rules(b.StatefulBlock.Timestamp) + epoch = utils.Epoch(b.StatefulBlock.Timestamp, r.GetEpochDuration()) ) + if !b.verifiedCerts { + validHeight, err := b.vm.IsValidHeight(ctx, b.PHeight) + if err != nil { + return fmt.Errorf("%w: can't determine if valid height", err) + } + if !validHeight { + return fmt.Errorf("invalid p-chain height: %d", b.PHeight) + } - // Perform basic correctness checks before doing any expensive work - if b.Timestamp().UnixMilli() > time.Now().Add(FutureBound).UnixMilli() { - return ErrTimestampTooLate - } + // Ensure no certificates if P-Chain height is 0 + // + // Even if there is an epoch, this height is used to verify warp + // messages. + if b.PHeight == 0 && len(b.AvailableChunks) > 0 { + return errors.New("no certificates should exist in a block without context") + } - // Fetch view where we will apply block state transitions - // - // This call may result in our ancestry being verified. - parentView, err := vctx.View(ctx, true) - if err != nil { - return fmt.Errorf("%w: unable to load parent view", err) - } + // TODO: skip verification if state does not exist yet (state sync) - // Fetch parent height key and ensure block height is valid - heightKey := HeightKey(b.vm.StateManager().HeightKey()) - parentHeightRaw, err := parentView.GetValue(ctx, heightKey) - if err != nil { - return err - } - parentHeight := binary.BigEndian.Uint64(parentHeightRaw) - if b.Hght != parentHeight+1 { - return ErrInvalidBlockHeight - } + // Fetch P-Chain height for epoch from executed state + // + // If missing and state read has timestamp updated in same or previous slot, we know that epoch + // cannot be set. + timestamp, heights, err := b.vm.Engine().GetEpochHeights(ctx, []uint64{epoch, epoch + 1}) + if err != nil { + return fmt.Errorf("%w: can't get epoch heights", err) + } + executedEpoch := utils.Epoch(timestamp, r.GetEpochDuration()) + if executedEpoch+1 < epoch && len(b.AvailableChunks) > 0 { // if execution in epoch 2 while trying to verify 4 and 5, we need to wait (should be rare) + log.Error( + "executed tip is too far behind to verify block with certs", + zap.Uint64("executedEpoch", executedEpoch), + zap.Uint64("epoch", epoch), + zap.Stringer("blockID", b.ID()), + ) + return errors.New("executed tip is too far behind to verify block with certs") + } + // We allow verfication to proceed if no available chunks and no epochs stored so that epochs could be set. - // Fetch parent timestamp and confirm block timestamp is valid - // - // Parent may not be available (if we preformed state sync), so we - // can't rely on being able to fetch it during verification. - timestampKey := TimestampKey(b.vm.StateManager().TimestampKey()) - parentTimestampRaw, err := parentView.GetValue(ctx, timestampKey) - if err != nil { - return err - } - parentTimestamp := int64(binary.BigEndian.Uint64(parentTimestampRaw)) - if b.Tmstmp < parentTimestamp+r.GetMinBlockGap() { - return ErrTimestampTooEarly - } - if len(b.Txs) == 0 && b.Tmstmp < parentTimestamp+r.GetMinEmptyBlockGap() { - return ErrTimestampTooEarly - } + // Perform basic correctness checks before doing any expensive work + if b.Timestamp().UnixMilli() > time.Now().Add(FutureBound).UnixMilli() { + return ErrTimestampTooEarly + } - // Ensure tx cannot be replayed - // - // Before node is considered ready (emap is fully populated), this may return - // false when other validators think it is true. - // - // If a block is already accepted, its transactions have already been added - // to the VM's seen emap and calling [IsRepeat] will return a non-zero value. - if b.st != choices.Accepted { - oldestAllowed := b.Tmstmp - r.GetValidityWindow() - if oldestAllowed < 0 { - // Can occur if verifying genesis - oldestAllowed = 0 + // Check that gap between parent is at least minimum + // + // We do not have access to state here, so we must use the parent block. + parent, err := b.vm.GetStatelessBlock(ctx, b.StatefulBlock.Parent) + if err != nil { + log.Error("block verification failed, missing parent", zap.Stringer("parentID", b.StatefulBlock.Parent), zap.Error(err)) + return fmt.Errorf("%w: can't get parent block %s", err, b.StatefulBlock.Parent) } - dup, err := vctx.IsRepeat(ctx, oldestAllowed, b.Txs, set.NewBits(), true) + if b.StatefulBlock.Height != parent.StatefulBlock.Height+1 { + return ErrInvalidBlockHeight + } + b.parent = parent + parentTimestamp := parent.StatefulBlock.Timestamp + if b.StatefulBlock.Timestamp < parentTimestamp+r.GetMinBlockGap() { + return ErrTimestampTooEarly + } + + // Check duplicate certificates + repeats, err := parent.IsRepeatChunk(ctx, b.AvailableChunks, set.NewBits()) if err != nil { - return err + return fmt.Errorf("%w: can't check if chunk is repeat", err) } - if dup.Len() > 0 { - return fmt.Errorf("%w: duplicate in ancestry", ErrDuplicateTx) + if repeats.Len() > 0 { + log.Error("block contains duplicate chunk") + return errors.New("duplicate chunk issuance") } - } - // Compute next unit prices to use - feeKey := FeeKey(b.vm.StateManager().FeeKey()) - feeRaw, err := parentView.GetValue(ctx, feeKey) - if err != nil { - return err - } - parentFeeManager := fees.NewManager(feeRaw) - feeManager, err := parentFeeManager.ComputeNext(parentTimestamp, b.Tmstmp, r) - if err != nil { - return err - } + // Verify certificates + // + // TODO: make parallel + // TODO: cache verifications (may be verified multiple times at the same p-chain height while + // waiting for execution to complete). + for i, cert := range b.AvailableChunks { + // Ensure chunk is not expired + if cert.Slot < b.StatefulBlock.Timestamp { + return ErrTimestampTooLate + } - // Process transactions - results, ts, err := b.Execute(ctx, b.vm.Tracer(), parentView, feeManager, r) - if err != nil { - log.Error("failed to execute block", zap.Error(err)) - return err - } - b.results = results - b.feeManager = feeManager + // Ensure chunk is not too early + // + // TODO: ensure slot is in the block epoch + if cert.Slot > b.StatefulBlock.Timestamp+r.GetValidityWindow() { + return ErrTimestampTooEarly + } - // NMT root verification - txsDataToProve, nmtNSs, NMTNamespaceToTxIndexes, err := ExtractTxsDataToApproveFromTxs(b.Txs, b.results) - if err != nil { - b.vm.Logger().Error("unable to extract data from txs", zap.Error(err)) - return err - } - b.vm.Logger().Info("nmt info", zap.ByteStrings("txsDataToProve", txsDataToProve), zap.ByteStrings("nmtNSs", nmtNSs), zap.Error(err)) + // Ensure chunk expiry is aligned to a tenth of a second + // + // Signatures will only be given for a configurable number of chunks per + // second. + // + // TODO: consider moving to unmarshal + if cert.Slot%consts.MillisecondsPerDecisecond != 0 { + return ErrMisalignedTime + } - _, nmtRoot, NMTProofs, err := BuildNMTTree(txsDataToProve, nmtNSs) - if err != nil { - b.vm.Logger().Error("unable to build NMT tree", zap.Error(err)) - return err - } + // Get validator set for the epoch + certEpoch := utils.Epoch(cert.Slot, r.GetEpochDuration()) + heightIndex := certEpoch - epoch + if heights[heightIndex] == nil { + log.Error( + "certificate is from missing epoch", + zap.Uint64("epoch", certEpoch), + zap.Stringer("chunkID", cert.Chunk), + ) + return errors.New("missing epoch") + } - if !bytes.Equal(nmtRoot, b.NMTRoot) { - b.vm.Logger().Warn("nmt root not equal", zap.ByteString("expect", nmtRoot), zap.ByteString("actual", b.NMTRoot)) - return ErrNMTRootNotEqual - } + // Get the public key for the signers + aggrPubKey, err := b.vm.GetAggregatePublicKey(ctx, *heights[heightIndex], cert.Signers, 67, 100) // TODO: add consts + if err != nil { + return fmt.Errorf("%w: can't generate aggregate public key", err) + } + if !cert.VerifySignature(r.NetworkID(), r.ChainID(), aggrPubKey) { + log.Error( + "certificate has invalid signature", + zap.Stringer("blockID", b.ID()), + zap.Stringer("chunkID", cert.Chunk), + ) + return fmt.Errorf("%w: pk=%s signature=%s pHeight=%d cert=%d certID=%s signers=%s", errors.New("certificate invalid"), hex.EncodeToString(bls.PublicKeyToCompressedBytes(aggrPubKey)), hex.EncodeToString(bls.SignatureToBytes(cert.Signature)), b.PHeight, i, cert.Chunk, cert.Signers.String()) + } + } - // Update chain metadata - heightKeyStr := string(heightKey) - timestampKeyStr := string(timestampKey) - feeKeyStr := string(feeKey) - - keys := make(state.Keys) - keys.Add(heightKeyStr, state.Write) - keys.Add(timestampKeyStr, state.Write) - keys.Add(feeKeyStr, state.Write) - tsv := ts.NewView(keys, map[string][]byte{ - heightKeyStr: parentHeightRaw, - timestampKeyStr: parentTimestampRaw, - feeKeyStr: parentFeeManager.Bytes(), - }) - if err := tsv.Insert(ctx, heightKey, binary.BigEndian.AppendUint64(nil, b.Hght)); err != nil { - return err - } - if err := tsv.Insert(ctx, timestampKey, binary.BigEndian.AppendUint64(nil, uint64(b.Tmstmp))); err != nil { - return err + // If we get this far, record we've already verified the certificates in this block so we don't do it again if + // block results aren't ready yet. We don't just check results first because we want to give as much time as + // possible for root generation to complete (and can do meaningful work here). + b.verifiedCerts = true } - if err := tsv.Insert(ctx, feeKey, feeManager.Bytes()); err != nil { - return err - } - tsv.Commit() - // Compare state root - // - // Because fee bytes are not recorded in state, it is sufficient to check the state root - // to verify all fee calculations were correct. - _, rspan := b.vm.Tracer().Start(ctx, "StatelessBlock.Verify.WaitRoot") - start := time.Now() - computedRoot, err := parentView.GetMerkleRoot(ctx) - rspan.End() - if err != nil { - return err - } - b.vm.RecordWaitRoot(time.Since(start)) - if b.StateRoot != computedRoot { - return fmt.Errorf( - "%w: expected=%s found=%s", - ErrStateRootMismatch, - computedRoot, - b.StateRoot, + // Verify execution results + depth := r.GetBlockExecutionDepth() + if b.StatefulBlock.Height <= depth { + if len(b.ExecutedChunks) > 0 { + return errors.New("no execution result should exist") + } + } else { + var ( + execHeight = b.StatefulBlock.Height - depth + executed []ids.ID + checksum ids.ID + err error ) + for { + executed, checksum, err = b.vm.Engine().Results(execHeight) + if err != nil { + // TODO: handle case where we state synced and don't have results + if b.vm.IsBootstrapped() { + log.Debug("could not get results for block", zap.Uint64("height", execHeight)) + return fmt.Errorf("%w: no results for execHeight", err) + } + // If we haven't finished bootstrapping, we can't fail. + // + // TODO: we should actually not verify any of this (long p-chain lookbacks) + time.Sleep(100 * time.Millisecond) + continue + } + break + } + if len(b.ExecutedChunks) != len(executed) { + log.Error("block has invalid executed chunks count", zap.Stringer("blockID", b.ID()), zap.Int("expectedCount", len(executed)), zap.Int("actualCount", len(b.ExecutedChunks))) + panic("executed chunk count mismatch") // could help us debug + } + for i, id := range b.ExecutedChunks { + if id != executed[i] { + log.Error("block has invalid executed chunks", zap.Stringer("blockID", b.ID()), zap.Int("index", i), zap.Stringer("expectedID", executed[i]), zap.Stringer("actualID", id)) + panic("executed chunk mismatch") // could help us debug + } + } + if b.Checksum != checksum { + log.Error("block has invalid checksum", zap.Stringer("blockID", b.ID()), zap.Stringer("expectedChecksum", checksum), zap.Stringer("actualChecksum", b.Checksum)) + panic("checksum mismatch") // could help us debug + } + b.execHeight = &execHeight } - // Ensure signatures are verified - _, sspan := b.vm.Tracer().Start(ctx, "StatelessBlock.Verify.WaitSignatures") - start = time.Now() - err = b.sigJob.Wait() - sspan.End() - if err != nil { - return err - } - b.vm.RecordWaitSignatures(time.Since(start)) - - // Get view from [tstate] after processing all state transitions - b.vm.RecordStateChanges(ts.PendingChanges()) - b.vm.RecordStateOperations(ts.OpIndex()) - view, err := ts.ExportMerkleDBView(ctx, b.vm.Tracer(), parentView) - if err != nil { - return err - } - b.view = view - // values are regenerated by NMTTree, safe to use as root is verified - b.NMTNamespaceToTxIndexes = NMTNamespaceToTxIndexes - b.NMTProofs = NMTProofs - - // Kickoff root generation - go func() { - start := time.Now() - root, err := view.GetMerkleRoot(ctx) - if err != nil { - log.Error("merkle root generation failed", zap.Error(err)) - return - } - log.Info("merkle root generated", - zap.Uint64("height", b.Hght), - zap.Stringer("blkID", b.ID()), - zap.Stringer("root", root), - ) - b.vm.RecordRootCalculated(time.Since(start)) - }() + b.vm.Verified(ctx, b) + log.Info( + "verified block", + zap.Stringer("blockID", b.ID()), + zap.Uint64("height", b.StatefulBlock.Height), + zap.Any("execHeight", b.execHeight), + zap.Stringer("parentID", b.Parent()), + zap.Int("available chunks", len(b.AvailableChunks)), + zap.Int("executed chunks", len(b.ExecutedChunks)), + zap.Stringer("checksum", b.Checksum), + ) + success = true return nil } @@ -578,70 +598,53 @@ func (b *StatelessBlock) Accept(ctx context.Context) error { ctx, span := b.vm.Tracer().Start(ctx, "StatelessBlock.Accept") defer span.End() - // Consider verifying the a block if it is not processed and we are no longer - // syncing. - if !b.Processed() { - // The state of this block was not calculated during the call to - // [StatelessBlock.Verify]. This is because the VM was state syncing - // and did not have the state necessary to verify the block. - updated, err := b.vm.UpdateSyncTarget(b) - if err != nil { - return err - } - if updated { - b.vm.Logger().Info("updated state sync target", - zap.Stringer("id", b.ID()), - zap.Stringer("root", b.StateRoot), - ) - return nil // the sync is still ongoing - } + // Mark block as accepted + b.st = choices.Accepted - // This code handles the case where this block was not - // verified during state sync (stopped syncing with a - // processing block). - // - // If state sync completes before accept is called - // then we need to process it here. - b.vm.Logger().Info("verifying unprocessed block in accept", - zap.Stringer("id", b.ID()), - zap.Stringer("root", b.StateRoot), - ) - vctx, err := b.vm.GetVerifyContext(ctx, b.Hght, b.Prnt) + // Prune async results (if any) + var filteredChunks []*FilteredChunk + if b.execHeight != nil { + _, fc, err := b.vm.Engine().PruneResults(ctx, *b.execHeight) if err != nil { - return fmt.Errorf("%w: unable to get verify context", err) - } - if err := b.innerVerify(ctx, vctx); err != nil { - return fmt.Errorf("%w: unable to verify block", err) - } - } - - if b.vm.GetStoreBlockResultsOnDisk() { - if err := b.vm.StoreBlockResultsOnDisk(b); err != nil { - return fmt.Errorf("%w: unable to store block results on disk", err) + return fmt.Errorf("%w: cannot prune results", err) } + filteredChunks = fc } - // Commit view if we don't return before here (would happen if we are still - // syncing) - if err := b.view.CommitToDB(ctx); err != nil { - return fmt.Errorf("%w: unable to commit block", err) - } - - // Mark block as accepted and update last accepted in storage - b.MarkAccepted(ctx) + // Notify the VM that the block has been accepted + // + // It is assumed that Accept is invoked atomically such that the slightly + // misaligned caches (we mark the block state as accepted before we update the + // repeat protection emaps for chunks/blocks) will not be queried. + b.vm.Accepted(ctx, b, filteredChunks) + + // Start async execution of chunks (and fetch if missing) + if b.StatefulBlock.Height > 0 { // nothing to execute in genesis + b.vm.Engine().Execute(b) + } + + r := b.vm.Rules(b.StatefulBlock.Timestamp) + epoch := utils.Epoch(b.StatefulBlock.Timestamp, r.GetEpochDuration()) + b.vm.RecordAcceptedEpoch(epoch) + b.vm.Logger().Info( + "accepted block", + zap.Stringer("blockID", b.ID()), + zap.Uint64("height", b.StatefulBlock.Height), + zap.Uint64("epoch", epoch), + ) return nil } func (b *StatelessBlock) MarkAccepted(ctx context.Context) { // Accept block and free unnecessary memory b.st = choices.Accepted - b.txsSet = nil // only used for replay protection when processing // [Accepted] will persist the block to disk and set in-memory variables // needed to ensure we don't resync all blocks when state sync finishes. // // Note: We will not call [b.vm.Verified] before accepting during state sync - b.vm.Accepted(ctx, b) + // TODO: this is definitely wrong, we should fix it + b.vm.Accepted(ctx, b, nil) } // implements "snowman.Block.choices.Decidable" @@ -658,331 +661,36 @@ func (b *StatelessBlock) Reject(ctx context.Context) error { func (b *StatelessBlock) Status() choices.Status { return b.st } // implements "snowman.Block" -func (b *StatelessBlock) Parent() ids.ID { return b.StatefulBlock.Prnt } +func (b *StatelessBlock) Parent() ids.ID { return b.StatefulBlock.Parent } // implements "snowman.Block" func (b *StatelessBlock) Bytes() []byte { return b.bytes } // implements "snowman.Block" -func (b *StatelessBlock) Height() uint64 { return b.StatefulBlock.Hght } +func (b *StatelessBlock) Height() uint64 { return b.StatefulBlock.Height } // implements "snowman.Block" func (b *StatelessBlock) Timestamp() time.Time { return b.t } -// Used to determine if should notify listeners and/or pass to controller -func (b *StatelessBlock) Processed() bool { - return b.view != nil -} - -// View returns the [merkledb.TrieView] of the block (representing the state -// post-execution) or returns the accepted state if the block is accepted or -// is height 0 (genesis). -// -// If [b.view] is nil (not processed), this function will either return an error or will -// run verification (depending on whether the height is in [acceptedState]). -// -// We still need to handle returning the accepted state here because -// the [VM] will call [View] on the preferred tip of the chain (whether or -// not it is accepted). -// -// Invariant: [View] with [verify] == true should not be called concurrently, otherwise, -// it will result in undefined behavior. -func (b *StatelessBlock) View(ctx context.Context, verify bool) (state.View, error) { - ctx, span := b.vm.Tracer().Start(ctx, "StatelessBlock.View", - trace.WithAttributes( - attribute.Bool("processed", b.Processed()), - attribute.Bool("verify", verify), - ), - ) - defer span.End() - - // If this is the genesis block, return the base state. - if b.Hght == 0 { - return b.vm.State() - } - - // If block is processed, we can return either the accepted state - // or its pending view. - if b.Processed() { - if b.st == choices.Accepted { - // We assume that base state was properly updated if this - // block was accepted (this is not obvious because - // the accepted state may be that of the parent of the last - // accepted block right after state sync finishes). - return b.vm.State() - } - return b.view, nil - } - - // If the block is not processed but [acceptedState] equals the height - // of the block, we should return the accepted state. - // - // This can happen when we are building a child block immediately after - // restart (latest block will not be considered [Processed] because there - // will be no attached view from execution). - // - // We cannot use the merkle root to check against the accepted state - // because the block only contains the root of the parent block's post-execution. - if b.st == choices.Accepted { - acceptedState, err := b.vm.State() - if err != nil { - return nil, err - } - acceptedHeightRaw, err := acceptedState.Get(HeightKey(b.vm.StateManager().HeightKey())) - if err != nil { - return nil, err - } - acceptedHeight := binary.BigEndian.Uint64(acceptedHeightRaw) - if acceptedHeight == b.Hght { - b.vm.Logger().Info("accepted block not processed but found post-execution state on-disk", - zap.Uint64("height", b.Hght), - zap.Stringer("blkID", b.ID()), - zap.Bool("verify", verify), - ) - return acceptedState, nil - } - b.vm.Logger().Info("accepted block not processed and does not match state on-disk", - zap.Uint64("height", b.Hght), - zap.Stringer("blkID", b.ID()), - zap.Bool("verify", verify), - ) - } else { - b.vm.Logger().Info("block not processed", - zap.Uint64("height", b.Hght), - zap.Stringer("blkID", b.ID()), - zap.Bool("verify", verify), - ) - } - if !verify { - return nil, ErrBlockNotProcessed - } - - // If there are no processing blocks when state sync finishes, - // the first block we attempt to verify will reach this execution - // path. - // - // In this scenario, the last accepted block will not be processed - // and [acceptedState] will correspond to the post-execution state - // of the new block's grandparent (our parent). To remedy this, - // we need to process this block to return a valid view. - b.vm.Logger().Info("verifying block when view requested", - zap.Uint64("height", b.Hght), - zap.Stringer("blkID", b.ID()), - zap.Bool("accepted", b.st == choices.Accepted), - ) - vctx, err := b.vm.GetVerifyContext(ctx, b.Hght, b.Prnt) - if err != nil { - b.vm.Logger().Error("unable to get verify context", zap.Error(err)) - return nil, err - } - if err := b.innerVerify(ctx, vctx); err != nil { - b.vm.Logger().Error("unable to verify block", zap.Error(err)) - return nil, err - } - if b.st != choices.Accepted { - return b.view, nil - } - - // If the block is already accepted, we should update - // the accepted state to ensure future calls to [View] - // return the correct state (now that the block is considered - // processed). - // - // It is not possible to reach this function if this block - // is not the child of the block whose post-execution state - // is currently stored on disk, so it is safe to call [CommitToDB]. - if err := b.view.CommitToDB(ctx); err != nil { - b.vm.Logger().Error("unable to commit to DB", zap.Error(err)) - return nil, err - } - return b.vm.State() -} - -// IsRepeat returns a bitset of all transactions that are considered repeats in -// the range that spans back to [oldestAllowed]. -// -// If [stop] is set to true, IsRepeat will return as soon as the first repeat -// is found (useful for block verification). -func (b *StatelessBlock) IsRepeat( - ctx context.Context, - oldestAllowed int64, - txs []*Transaction, - marker set.Bits, - stop bool, -) (set.Bits, error) { - ctx, span := b.vm.Tracer().Start(ctx, "StatelessBlock.IsRepeat") - defer span.End() - - // Early exit if we are already back at least [ValidityWindow] - // - // It is critical to ensure this logic is equivalent to [emap] to avoid - // non-deterministic verification. - if b.Tmstmp < oldestAllowed { - return marker, nil - } +// func (b *StatelessBlock) FeeManager() *fees.Manager { +// return b.feeManager +// } - // If we are at an accepted block or genesis, we can use the emap on the VM - // instead of checking each block - if b.st == choices.Accepted || b.Hght == 0 /* genesis */ { - return b.vm.IsRepeat(ctx, txs, marker, stop), nil +func (b *StatelessBlock) IsRepeatChunk(ctx context.Context, certs []*ChunkCertificate, marker set.Bits) (set.Bits, error) { + if b.st == choices.Accepted || b.StatefulBlock.Height == 0 /* genesis */ { + return b.vm.IsRepeatChunk(ctx, certs, marker), nil } - - // Check if block contains any overlapping txs - for i, tx := range txs { + for i, cert := range certs { if marker.Contains(i) { continue } - if b.txsSet.Contains(tx.ID()) { + if b.chunks.Contains(cert.Chunk) { marker.Add(i) - if stop { - return marker, nil - } } } - prnt, err := b.vm.GetStatelessBlock(ctx, b.Prnt) + parent, err := b.vm.GetStatelessBlock(ctx, b.StatefulBlock.Parent) if err != nil { return marker, err } - return prnt.IsRepeat(ctx, oldestAllowed, txs, marker, stop) -} - -func (b *StatelessBlock) GetTxs() []*Transaction { - return b.Txs -} - -func (b *StatelessBlock) GetTimestamp() int64 { - return b.Tmstmp -} - -func (b *StatelessBlock) Results() []*Result { - return b.results -} - -func (b *StatelessBlock) FeeManager() *fees.Manager { - return b.feeManager -} - -func (b *StatefulBlock) Marshal() ([]byte, error) { - size := ids.IDLen + consts.Uint64Len + consts.Uint64Len + - consts.Uint64Len + window.WindowSliceSize + - consts.IntLen + codec.CummSize(b.Txs) + ids.IDLen + - ids.IDLen + consts.Uint64Len + consts.Uint64Len - - p := codec.NewWriter(size, consts.NetworkSizeLimit) - - p.PackID(b.Prnt) - p.PackInt64(b.Tmstmp) - p.PackUint64(b.Hght) - - p.PackInt(len(b.Txs)) - b.authCounts = map[uint8]int{} - for _, tx := range b.Txs { - if err := tx.Marshal(p); err != nil { - return nil, err - } - b.authCounts[tx.Auth.GetTypeID()]++ - } - - p.PackInt64(b.L1Head) - - p.PackID(b.StateRoot) - - p.PackBytes(b.NMTRoot) - - proofsJson, err := json.Marshal(b.NMTProofs) - if err != nil { - return nil, err - } - p.PackBytes(proofsJson) - nmtNSTxMapping, err := json.Marshal(b.NMTNamespaceToTxIndexes) - if err != nil { - return nil, err - } - p.PackBytes(nmtNSTxMapping) - - bytes := p.Bytes() - if err := p.Err(); err != nil { - return nil, err - } - b.size = len(bytes) - return bytes, nil -} - -func UnmarshalBlock(raw []byte, parser Parser) (*StatefulBlock, error) { - var ( - p = codec.NewReader(raw, consts.NetworkSizeLimit) - b StatefulBlock - ) - b.size = len(raw) - - p.UnpackID(false, &b.Prnt) - b.Tmstmp = p.UnpackInt64(false) - b.Hght = p.UnpackUint64(false) - - // Parse transactions - txCount := p.UnpackInt(false) // can produce empty blocks - actionRegistry, authRegistry := parser.Registry() - b.Txs = []*Transaction{} // don't preallocate all to avoid DoS - b.authCounts = map[uint8]int{} - for i := 0; i < txCount; i++ { - tx, err := UnmarshalTx(p, actionRegistry, authRegistry) - if err != nil { - return nil, err - } - b.Txs = append(b.Txs, tx) - b.authCounts[tx.Auth.GetTypeID()]++ - } - - b.L1Head = p.UnpackInt64(false) - - p.UnpackID(false, &b.StateRoot) - - p.UnpackBytes(consts.NMTRootLen, false, &b.NMTRoot) - proofs := make(map[string]nmt.Proof) - // unknown how much to allocate in advance - proofsBytes := make([]byte, 0, 1024) - p.UnpackBytes(consts.MaxNMTProofBytes, false, &proofsBytes) - err := json.Unmarshal(proofsBytes, &proofs) - if err != nil { - fmt.Println("unable to json.unmarshal proofs") - return nil, err - } - txNSMappingBytes := make([]byte, 0, 256) - txNsMapping := make(map[string][][2]int) - p.UnpackBytes(consts.MaxNSTxMappingBytes, false, &txNSMappingBytes) - err = json.Unmarshal(txNSMappingBytes, &txNsMapping) - if err != nil { - fmt.Println("unable to json.unmarshal tx to ns mapping") - return nil, err - } - - b.NMTProofs = proofs - b.NMTNamespaceToTxIndexes = txNsMapping - - // Ensure no leftover bytes - if !p.Empty() { - return nil, fmt.Errorf("%w: remaining=%d", ErrInvalidObject, len(raw)-p.Offset()) - } - return &b, p.Err() -} - -type SyncableBlock struct { - *StatelessBlock -} - -func (sb *SyncableBlock) Accept(ctx context.Context) (block.StateSyncMode, error) { - return sb.vm.AcceptedSyncableBlock(ctx, sb) -} - -func NewSyncableBlock(sb *StatelessBlock) *SyncableBlock { - return &SyncableBlock{sb} -} - -func (sb *SyncableBlock) String() string { - return fmt.Sprintf("%d:%s root=%s", sb.Height(), sb.ID(), sb.StateRoot) -} - -// Testing -func (b *StatelessBlock) MarkUnprocessed() { - b.view = nil + return parent.IsRepeatChunk(ctx, certs, marker) } diff --git a/chain/chunk.go b/chain/chunk.go new file mode 100644 index 0000000000..5b5e9ea001 --- /dev/null +++ b/chain/chunk.go @@ -0,0 +1,626 @@ +package chain + +import ( + "context" + "encoding/hex" + "fmt" + "time" + + "github.com/AnomalyFi/hypersdk/codec" + "github.com/AnomalyFi/hypersdk/consts" + "github.com/AnomalyFi/hypersdk/fees" + "github.com/AnomalyFi/hypersdk/utils" + "github.com/ava-labs/avalanchego/ids" + "github.com/ava-labs/avalanchego/utils/crypto/bls" + "github.com/ava-labs/avalanchego/utils/set" + "github.com/ava-labs/avalanchego/vms/platformvm/warp" + "go.uber.org/zap" +) + +const chunkPrealloc = 16_384 + +type Chunk struct { + Slot int64 `json:"slot"` // rounded to nearest 100ms + Txs []*Transaction `json:"txs"` + + Producer ids.NodeID `json:"producer"` + Beneficiary codec.Address `json:"beneficiary"` // used for fees + + Signer *bls.PublicKey `json:"signer"` + Signature *bls.Signature `json:"signature"` + + id ids.ID + units *fees.Dimensions + bytes []byte + authCounts map[uint8]int +} + +// @todo integrate build chunk with anchor. +func BuildChunk(ctx context.Context, vm VM) (*Chunk, error) { + start := time.Now() + now := time.Now().UnixMilli() - consts.ClockSkewAllowance + sm := vm.StateManager() + r := vm.Rules(now) + c := &Chunk{ + Slot: utils.UnixRDeci(now, r.GetValidityWindow()), + Txs: make([]*Transaction, 0, chunkPrealloc), + } + epoch := utils.Epoch(now, r.GetEpochDuration()) + + // Don't build chunk if no P-Chain height for epoch + timestamp, heights, err := vm.Engine().GetEpochHeights(ctx, []uint64{epoch}) + if err != nil { + return nil, err + } + executedEpoch := utils.Epoch(timestamp, r.GetEpochDuration()) + if executedEpoch+2 < epoch { // only require + 2 because we don't care about epoch + 1 like in verification. + return nil, fmt.Errorf("executed epoch (%d) is too far behind (%d) to verify chunk", executedEpoch, epoch) + } + if heights[0] == nil { + return nil, fmt.Errorf("no P-Chain height for epoch %d", epoch) + } + + // Check if validator + // + // If not a validator in this epoch height, don't build. + amValidator, err := vm.IsValidator(ctx, *heights[0], vm.NodeID()) + if err != nil { + return nil, err + } + if !amValidator { + return nil, ErrNotAValidator + } + + // Pack chunk for build duration + // + // TODO: sort mempool by priority and fit (only fetch items that can be included) + var ( + maxChunkUnits = r.GetMaxChunkUnits() + chunkUnits = fees.Dimensions{} + full bool + mempool = vm.Mempool() + authCounts = make(map[uint8]int) + + restorableTxs = make([]*Transaction, 0, chunkPrealloc) + ) + mempool.StartStreaming(ctx) + for time.Since(start) < vm.GetTargetChunkBuildDuration() { + tx := mempool.Stream(ctx) + if !ok { + break + } + // Ensure we haven't included this transaction in a chunk yet + // + // Should protect us from issuing repeat txs (if others get duplicates, + // there will be duplicate inclusion but this is fixed with partitions) + if vm.IsIssuedTx(ctx, tx) { + vm.RecordChunkBuildTxDropped() + continue + } + + // TODO: count outstanding for an account and ensure less than epoch bond + // if too many, just put back into mempool and try again later + + // TODO: ensure tx can still be processed (bond not frozen) + + // TODO: skip if transaction will pay < max fee over validity window (this fee period or a future one based on limit + // of activity). + + // TODO: check if chunk units greater than limit + + // TODO: verify transactions + if tx.Base.Timestamp > c.Slot { + vm.RecordChunkBuildTxDropped() + continue + } + + // Check if tx can fit in chunk + txUnits, err := tx.Units(sm, r) + if err != nil { + vm.RecordChunkBuildTxDropped() + vm.Logger().Warn("failed to get units for transaction", zap.Error(err)) + continue + } + nextUnits, err := fees.Add(chunkUnits, txUnits) + if err != nil || !maxChunkUnits.Greater(nextUnits) { + full = true + // TODO: update mempool to only provide txs that can fit in chunk + // Want to maximize how "full" chunk is, so need to be a little complex + // if there are transactions with uneven usage of resources. + restorableTxs = append(restorableTxs, tx) + vm.RecordRemainingMempool(vm.Mempool().Len(ctx)) + break + } + chunkUnits = nextUnits + + // Add transaction to chunk + vm.IssueTx(ctx, tx) // prevents duplicate from being re-added to mempool + c.Txs = append(c.Txs, tx) + authCounts[tx.Auth.GetTypeID()]++ + } + + // Always close stream + defer func() { + if c.id == ids.Empty { // only happens if there is an error + mempool.FinishStreaming(ctx, append(c.Txs, restorableTxs...)) + return + } + mempool.FinishStreaming(ctx, restorableTxs) + }() + + // Discard chunk if nothing produced + if len(c.Txs) == 0 { + return nil, ErrNoTxs + } + + // Setup chunk + c.Producer = vm.NodeID() + c.Beneficiary = vm.Beneficiary() + c.Signer = vm.Signer() + c.units = &chunkUnits + c.authCounts = authCounts + + // Sign chunk + digest, err := c.Digest() + if err != nil { + return nil, err + } + wm, err := warp.NewUnsignedMessage(r.NetworkID(), r.ChainID(), digest) + if err != nil { + return nil, err + } + sig, err := vm.Sign(wm) + if err != nil { + return nil, err + } + c.Signature, err = bls.SignatureFromBytes(sig) + if err != nil { + return nil, err + } + bytes, err := c.Marshal() + if err != nil { + return nil, err + } + c.id = utils.ToID(bytes) + + vm.Logger().Info( + "built chunk with signature", + zap.Stringer("nodeID", vm.NodeID()), + zap.Uint32("networkID", r.NetworkID()), + zap.Stringer("chainID", r.ChainID()), + zap.Int64("slot", c.Slot), + zap.Uint64("epoch", epoch), + zap.Bool("full", full), + zap.Int("txs", len(c.Txs)), + zap.Any("units", chunkUnits), + zap.String("signer", hex.EncodeToString(bls.PublicKeyToCompressedBytes(c.Signer))), + zap.String("signature", hex.EncodeToString(bls.SignatureToBytes(c.Signature))), + zap.Duration("t", time.Since(start)), + ) + return c, nil +} + +func (c *Chunk) Digest() ([]byte, error) { + size := consts.Int64Len + consts.IntLen + codec.CummSize(c.Txs) + ids.NodeIDLen + bls.PublicKeyLen + p := codec.NewWriter(size, consts.NetworkSizeLimit) + + // Marshal transactions + p.PackInt64(c.Slot) + p.PackInt(len(c.Txs)) + for _, tx := range c.Txs { + if err := tx.Marshal(p); err != nil { + return nil, err + } + } + + // Marshal signer + p.PackNodeID(c.Producer) + p.PackAddress(c.Beneficiary) + p.PackFixedBytes(bls.PublicKeyToCompressedBytes(c.Signer)) + + return p.Bytes(), p.Err() +} + +func (c *Chunk) ID() ids.ID { + return c.id +} + +func (c *Chunk) Size() int { + return consts.Int64Len + consts.IntLen + codec.CummSize(c.Txs) + ids.NodeIDLen + codec.AddressLen + bls.PublicKeyLen + bls.SignatureLen +} + +func (c *Chunk) Units(sm StateManager, r Rules) (fees.Dimensions, error) { + if c.units != nil { + return *c.units, nil + } + units := fees.Dimensions{} + for _, tx := range c.Txs { + txUnits, err := tx.Units(sm, r) + if err != nil { + return fees.Dimensions{}, err + } + nextUnits, err := fees.Add(units, txUnits) + if err != nil { + return fees.Dimensions{}, err + } + units = nextUnits + } + c.units = &units + return units, nil +} + +func (c *Chunk) Marshal() ([]byte, error) { + if c.bytes != nil { + return c.bytes, nil + } + + p := codec.NewWriter(c.Size(), consts.NetworkSizeLimit) + + // Marshal transactions + p.PackInt64(c.Slot) + p.PackInt(len(c.Txs)) + for _, tx := range c.Txs { + if err := tx.Marshal(p); err != nil { + return nil, err + } + } + + // Marshal signer + p.PackNodeID(c.Producer) + p.PackAddress(c.Beneficiary) + p.PackFixedBytes(bls.PublicKeyToCompressedBytes(c.Signer)) + p.PackFixedBytes(bls.SignatureToBytes(c.Signature)) + bytes, err := p.Bytes(), p.Err() + if err != nil { + return nil, err + } + c.bytes = bytes + return bytes, nil +} + +func (c *Chunk) VerifySignature(networkID uint32, chainID ids.ID) bool { + digest, err := c.Digest() + if err != nil { + return false + } + // TODO: don't use warp message for this (nice to have chainID protection)? + msg, err := warp.NewUnsignedMessage(networkID, chainID, digest) + if err != nil { + return false + } + return bls.Verify(c.Signer, c.Signature, msg.Bytes()) +} + +func (c *Chunk) AuthCounts() map[uint8]int { + return c.authCounts +} + +func UnmarshalChunk(raw []byte, parser Parser) (*Chunk, error) { + var ( + actionRegistry, authRegistry = parser.Registry() + p = codec.NewReader(raw, consts.NetworkSizeLimit) + c Chunk + authCounts = make(map[uint8]int) + ) + c.id = utils.ToID(raw) + + // Parse transactions + c.Slot = p.UnpackInt64(false) + txCount := p.UnpackInt(true) // can't produce empty blocks + c.Txs = []*Transaction{} // don't preallocate all to avoid DoS + for i := 0; i < txCount; i++ { + tx, err := UnmarshalTx(p, actionRegistry, authRegistry) + if err != nil { + return nil, err + } + c.Txs = append(c.Txs, tx) + authCounts[tx.Auth.GetTypeID()]++ + } + c.authCounts = authCounts + + // Parse signer + p.UnpackNodeID(true, &c.Producer) + p.UnpackAddress(&c.Beneficiary) + pk := make([]byte, bls.PublicKeyLen) + p.UnpackFixedBytes(bls.PublicKeyLen, &pk) + signer, err := bls.PublicKeyFromCompressedBytes(pk) + if err != nil { + return nil, fmt.Errorf("%w: unable to decompress pk=%x packerErr=%w", err, pk, p.Err()) + } + c.Signer = signer + sig := make([]byte, bls.SignatureLen) + p.UnpackFixedBytes(bls.SignatureLen, &sig) + signature, err := bls.SignatureFromBytes(sig) + if err != nil { + return nil, err + } + c.Signature = signature + + // Ensure no leftover bytes + if !p.Empty() { + return nil, fmt.Errorf("%w: remaining=%d", ErrInvalidObject, len(raw)-p.Offset()) + } + return &c, p.Err() +} + +type ChunkSignature struct { + Chunk ids.ID `json:"chunk"` + Slot int64 `json:"slot"` // used for builders that don't yet have the chunk being sequenced to verify not included before expiry + + Signer *bls.PublicKey `json:"signer"` + Signature *bls.Signature `json:"signature"` +} + +func (c *ChunkSignature) Size() int { + return ids.IDLen + consts.Int64Len + bls.PublicKeyLen + bls.SignatureLen +} + +func (c *ChunkSignature) Marshal() ([]byte, error) { + size := c.Size() + p := codec.NewWriter(size, consts.NetworkSizeLimit) + + p.PackID(c.Chunk) + p.PackInt64(c.Slot) + + p.PackFixedBytes(bls.PublicKeyToCompressedBytes(c.Signer)) + p.PackFixedBytes(bls.SignatureToBytes(c.Signature)) + + return p.Bytes(), p.Err() +} + +func (c *ChunkSignature) Digest() ([]byte, error) { + size := ids.IDLen + consts.Int64Len + p := codec.NewWriter(size, consts.NetworkSizeLimit) + + p.PackID(c.Chunk) + p.PackInt64(c.Slot) + + return p.Bytes(), p.Err() +} + +func (c *ChunkSignature) VerifySignature(networkID uint32, chainID ids.ID) bool { + digest, err := c.Digest() + if err != nil { + return false + } + // TODO: don't use warp message for this (nice to have chainID protection)? + msg, err := warp.NewUnsignedMessage(networkID, chainID, digest) + if err != nil { + return false + } + return bls.Verify(c.Signer, c.Signature, msg.Bytes()) +} + +func UnmarshalChunkSignature(raw []byte) (*ChunkSignature, error) { + var ( + p = codec.NewReader(raw, consts.NetworkSizeLimit) + c ChunkSignature + ) + + p.UnpackID(true, &c.Chunk) + c.Slot = p.UnpackInt64(false) + pk := make([]byte, bls.PublicKeyLen) + p.UnpackFixedBytes(bls.PublicKeyLen, &pk) + signer, err := bls.PublicKeyFromCompressedBytes(pk) + if err != nil { + return nil, err + } + c.Signer = signer + sig := make([]byte, bls.SignatureLen) + p.UnpackFixedBytes(bls.SignatureLen, &sig) + signature, err := bls.SignatureFromBytes(sig) + if err != nil { + return nil, err + } + c.Signature = signature + + // Ensure no leftover bytes + if !p.Empty() { + return nil, fmt.Errorf("%w: remaining=%d", ErrInvalidObject, len(raw)-p.Offset()) + } + return &c, p.Err() +} + +// TODO: which height to use to verify this signature? +// If we use the block context, validator set might change a bit too frequently? +type ChunkCertificate struct { + Chunk ids.ID `json:"chunk"` + Slot int64 `json:"slot"` + + Signers set.Bits `json:"signers"` + Signature *bls.Signature `json:"signature"` +} + +// implements "emap.Item" +func (c *ChunkCertificate) ID() ids.ID { + return c.Chunk +} + +// implements "emap.Item" +func (c *ChunkCertificate) Expiry() int64 { + return c.Slot +} + +func (c *ChunkCertificate) Size() int { + signers := c.Signers.Bytes() + return ids.IDLen + consts.Int64Len + codec.BytesLen(signers) + bls.SignatureLen +} + +func (c *ChunkCertificate) Marshal() ([]byte, error) { + p := codec.NewWriter(c.Size(), consts.NetworkSizeLimit) + + p.PackID(c.Chunk) + p.PackInt64(c.Slot) + p.PackBytes(c.Signers.Bytes()) + p.PackFixedBytes(bls.SignatureToBytes(c.Signature)) + + return p.Bytes(), p.Err() +} + +func (c *ChunkCertificate) MarshalPacker(p *codec.Packer) error { + p.PackID(c.Chunk) + p.PackInt64(c.Slot) + p.PackBytes(c.Signers.Bytes()) + p.PackFixedBytes(bls.SignatureToBytes(c.Signature)) + return p.Err() +} + +// TODO: unify with ChunkSignature +func (c *ChunkCertificate) Digest() ([]byte, error) { + size := ids.IDLen + consts.Int64Len + p := codec.NewWriter(size, consts.NetworkSizeLimit) + + p.PackID(c.Chunk) + p.PackInt64(c.Slot) + + return p.Bytes(), p.Err() +} + +func (c *ChunkCertificate) VerifySignature(networkID uint32, chainID ids.ID, aggrPubKey *bls.PublicKey) bool { + digest, err := c.Digest() + if err != nil { + return false + } + // TODO: don't use warp message for this (nice to have chainID protection)? + msg, err := warp.NewUnsignedMessage(networkID, chainID, digest) + if err != nil { + return false + } + return bls.Verify(aggrPubKey, c.Signature, msg.Bytes()) +} + +func UnmarshalChunkCertificate(raw []byte) (*ChunkCertificate, error) { + var ( + p = codec.NewReader(raw, consts.NetworkSizeLimit) + c ChunkCertificate + ) + + p.UnpackID(true, &c.Chunk) + c.Slot = p.UnpackInt64(false) + var signerBytes []byte + p.UnpackBytes(32 /* TODO: make const */, true, &signerBytes) + c.Signers = set.BitsFromBytes(signerBytes) + if len(signerBytes) != len(c.Signers.Bytes()) { + return nil, fmt.Errorf("%w: signers not minimal", ErrInvalidObject) + } + sig := make([]byte, bls.SignatureLen) + p.UnpackFixedBytes(bls.SignatureLen, &sig) + signature, err := bls.SignatureFromBytes(sig) + if err != nil { + return nil, err + } + c.Signature = signature + + // Ensure no leftover bytes + if !p.Empty() { + return nil, fmt.Errorf("%w: remaining=%d", ErrInvalidObject, len(raw)-p.Offset()) + } + return &c, p.Err() +} + +func UnmarshalChunkCertificatePacker(p *codec.Packer) (*ChunkCertificate, error) { + var c ChunkCertificate + + p.UnpackID(true, &c.Chunk) + c.Slot = p.UnpackInt64(false) + var signerBytes []byte + p.UnpackBytes(32 /* TODO: make const */, true, &signerBytes) + c.Signers = set.BitsFromBytes(signerBytes) + if len(signerBytes) != len(c.Signers.Bytes()) { + return nil, fmt.Errorf("%w: signers not minimal", ErrInvalidObject) + } + sig := make([]byte, bls.SignatureLen) + p.UnpackFixedBytes(bls.SignatureLen, &sig) + signature, err := bls.SignatureFromBytes(sig) + if err != nil { + return nil, err + } + c.Signature = signature + + return &c, nil +} + +// TODO: consider evaluating what other fields should be here (tx results bit array? so no need to sync for simple transfers) +type FilteredChunk struct { + Chunk ids.ID `json:"chunk"` + Slot int64 `json:"slot"` + + Producer ids.NodeID `json:"producer"` + Beneficiary codec.Address `json:"beneficiary"` // used for fees + + Txs []*Transaction `json:"txs"` + WarpResults set.Bits64 `json:"warpResults"` + + id ids.ID +} + +func (c *FilteredChunk) ID() (ids.ID, error) { + if c.id != ids.Empty { + return c.id, nil + } + + bytes, err := c.Marshal() + if err != nil { + return ids.ID{}, err + } + c.id = utils.ToID(bytes) + return c.id, nil +} + +func (c *FilteredChunk) Size() int { + return ids.IDLen + consts.Int64Len + ids.NodeIDLen + codec.AddressLen + consts.IntLen + codec.CummSize(c.Txs) + consts.Uint64Len +} + +func (c *FilteredChunk) Marshal() ([]byte, error) { + p := codec.NewWriter(c.Size(), consts.NetworkSizeLimit) + + // Marshal header + p.PackID(c.Chunk) + p.PackInt64(c.Slot) + p.PackNodeID(c.Producer) + p.PackAddress(c.Beneficiary) + + // Marshal transactions + p.PackInt(len(c.Txs)) + for _, tx := range c.Txs { + if err := tx.Marshal(p); err != nil { + return nil, err + } + } + p.PackUint64(uint64(c.WarpResults)) + + return p.Bytes(), p.Err() +} + +func UnmarshalFilteredChunk(raw []byte, parser Parser) (*FilteredChunk, error) { + var ( + actionRegistry, authRegistry = parser.Registry() + p = codec.NewReader(raw, consts.NetworkSizeLimit) + c FilteredChunk + ) + c.id = utils.ToID(raw) + + // Parse header + p.UnpackID(true, &c.Chunk) + c.Slot = p.UnpackInt64(false) + p.UnpackNodeID(true, &c.Producer) + p.UnpackAddress(&c.Beneficiary) + + // Parse transactions + txCount := p.UnpackInt(false) // can produce empty filtered chunks + c.Txs = []*Transaction{} // don't preallocate all to avoid DoS + for i := 0; i < txCount; i++ { + tx, err := UnmarshalTx(p, actionRegistry, authRegistry) + if err != nil { + return nil, err + } + c.Txs = append(c.Txs, tx) + } + c.WarpResults = set.Bits64(p.UnpackUint64(false)) + + // Ensure no leftover bytes + if !p.Empty() { + return nil, fmt.Errorf("%w: remaining=%d extra=%x err=%w", ErrInvalidObject, len(raw)-p.Offset(), raw[p.Offset():], p.Err()) + } + return &c, p.Err() +} diff --git a/chain/dependencies.go b/chain/dependencies.go index 96aa98ae27..87357d60f3 100644 --- a/chain/dependencies.go +++ b/chain/dependencies.go @@ -8,18 +8,18 @@ import ( "time" "github.com/ava-labs/avalanchego/ids" - "github.com/ava-labs/avalanchego/snow/engine/snowman/block" "github.com/ava-labs/avalanchego/snow/validators" "github.com/ava-labs/avalanchego/trace" "github.com/ava-labs/avalanchego/utils/logging" "github.com/ava-labs/avalanchego/utils/set" - "github.com/ava-labs/avalanchego/x/merkledb" + "github.com/ava-labs/avalanchego/vms/platformvm/warp" "github.com/AnomalyFi/hypersdk/codec" + "github.com/AnomalyFi/hypersdk/crypto/bls" "github.com/AnomalyFi/hypersdk/executor" "github.com/AnomalyFi/hypersdk/fees" "github.com/AnomalyFi/hypersdk/state" - "github.com/AnomalyFi/hypersdk/workers" + "github.com/AnomalyFi/hypersdk/vilmo" ) type ( @@ -33,19 +33,44 @@ type Parser interface { } type Metrics interface { - RecordRootCalculated(time.Duration) // only called in Verify - RecordWaitRoot(time.Duration) // only called in Verify - RecordWaitSignatures(time.Duration) // only called in Verify + RecordRPCAuthorizedTx() + RecordExecutedChunks(int) + RecordWaitRepeat(time.Duration) + RecordWaitQueue(time.Duration) + RecordWaitAuth(time.Duration) + RecordWaitPrecheck(time.Duration) + RecordWaitExec(time.Duration) + RecordWaitCommit(time.Duration) + + RecordRemainingMempool(int) + + RecordBlockVerifyFail() RecordBlockVerify(time.Duration) RecordBlockAccept(time.Duration) + RecordAcceptedEpoch(uint64) + RecordExecutedEpoch(uint64) + + GetExecutorRecorder() executor.Metrics + RecordBlockExecute(time.Duration) + RecordTxsIncluded(int) + RecordChunkBuildTxDropped() + RecordBlockBuildCertDropped() + RecordTxsInvalid(int) + RecordEngineBacklog(int) + RecordStateChanges(int) - RecordStateOperations(int) - RecordBuildCapped() - RecordEmptyBlockBuilt() - RecordClearedMempool() - GetExecutorBuildRecorder() executor.Metrics - GetExecutorVerifyRecorder() executor.Metrics + + // TODO: make each name a string and then + // allow dynamic registering of metrics + // as needed rather than this approach (just + // have gauge, counter, averager). + RecordVilmoBatchInit(time.Duration) + RecordVilmoBatchInitBytes(int64) + RecordVilmoBatchesRewritten() + RecordVilmoBatchPrepare(time.Duration) + RecordTStateIterate(time.Duration) + RecordVilmoBatchWrite(time.Duration) } type Monitoring interface { @@ -60,8 +85,10 @@ type VM interface { // We don't include this in registry because it would never be used // by any client of the hypersdk. - AuthVerifiers() workers.Workers - GetAuthBatchVerifier(authTypeID uint8, cores int, count int) (AuthBatchVerifier, bool) + Engine() *Engine + RequestChunks(uint64, []*ChunkCertificate, chan *Chunk) + SubnetID() ids.ID + // GetAuthBatchVerifier(authTypeID uint8, cores int, count int) (AuthBatchVerifier, bool) GetVerifyAuth() bool IsBootstrapped() bool @@ -69,14 +96,18 @@ type VM interface { LastL1Head() int64 GetStatelessBlock(context.Context, ids.ID) (*StatelessBlock, error) - GetVerifyContext(ctx context.Context, blockHeight uint64, parent ids.ID) (VerifyContext, error) - - State() (merkledb.MerkleDB, error) + State() (*vilmo.Vilmo, error) StateManager() StateManager ValidatorState() validators.State + IsIssuedTx(context.Context, *Transaction) bool + IssueTx(context.Context, *Transaction) + + GetAuthResult(ids.ID) bool + IsRepeatTx(context.Context, []*Transaction, set.Bits) set.Bits + IsRepeatChunk(context.Context, []*ChunkCertificate, set.Bits) set.Bits + Mempool() Mempool - IsRepeat(context.Context, []*Transaction, set.Bits, bool) set.Bits GetTargetBuildDuration() time.Duration GetTransactionExecutionCores() int GetStateFetchConcurrency() int @@ -87,18 +118,29 @@ type VM interface { Verified(context.Context, *StatelessBlock) Rejected(context.Context, *StatelessBlock) Accepted(context.Context, *StatelessBlock) - AcceptedSyncableBlock(context.Context, *SyncableBlock) (block.StateSyncMode, error) - - // UpdateSyncTarget returns a bool that is true if the root - // was updated and the sync is continuing with the new specified root - // and false if the sync completed with the previous root. - UpdateSyncTarget(*StatelessBlock) (bool, error) - StateReady() bool -} - -type VerifyContext interface { - View(ctx context.Context, verify bool) (state.View, error) - IsRepeat(ctx context.Context, oldestAllowed int64, txs []*Transaction, marker set.Bits, stop bool) (set.Bits, error) + ExecutedChunk(context.Context, *StatefulBlock, *FilteredChunk, []*Result, []ids.ID) + ExecutedBlock(context.Context, *StatefulBlock) + + NodeID() ids.NodeID + Signer() *bls.PublicKey + Beneficiary() codec.Address + + Sign(*warp.UnsignedMessage) ([]byte, error) + StopChan() chan struct{} + + StartCertStream(context.Context) + StreamCert(context.Context) (*ChunkCertificate, bool) + FinishCertStream(context.Context, []*ChunkCertificate) + HasChunk(ctx context.Context, slot int64, id ids.ID) bool + RestoreChunkCertificates(context.Context, []*ChunkCertificate) + IsSeenChunk(context.Context, ids.ID) bool + GetChunk(int64, ids.ID) (*Chunk, error) + + IsValidHeight(ctx context.Context, height uint64) (bool, error) + CacheValidators(ctx context.Context, height uint64) + IsValidator(ctx context.Context, height uint64, nodeID ids.NodeID) (bool, error) // TODO: filter based on being part of whole epoch + GetAggregatePublicKey(ctx context.Context, height uint64, signers set.Bits, num, denom uint64) (*bls.PublicKey, error) // cached + AddressPartition(ctx context.Context, epoch uint64, height uint64, addr codec.Address, partition uint8) (ids.NodeID, error) } type Mempool interface { @@ -114,7 +156,7 @@ type Mempool interface { StartStreaming(context.Context) PrepareStream(context.Context, int) - Stream(context.Context, int) []*Transaction + Stream(context.Context) (*Transaction, bool) FinishStreaming(context.Context, []*Transaction) int } @@ -125,9 +167,13 @@ type Rules interface { NetworkID() uint32 ChainID() ids.ID - GetMinBlockGap() int64 // in milliseconds - GetMinEmptyBlockGap() int64 // in milliseconds - GetValidityWindow() int64 // in milliseconds + // TODO: make immutable rules (that don't expect to be changed) + GetPartitions() uint8 + GetBlockExecutionDepth() uint64 + GetEpochDuration() int64 + + GetMinBlockGap() int64 // in milliseconds + GetValidityWindow() int64 // in milliseconds GetMaxActionsPerTx() uint8 GetMaxOutputsPerAction() uint8 @@ -137,6 +183,9 @@ type Rules interface { GetWindowTargetUnits() fees.Dimensions GetMaxBlockUnits() fees.Dimensions + GetUnitPrices() fees.Dimensions // TODO: make this dynamic if we want to burn fees? + GetMaxChunkUnits() fees.Dimensions + GetBaseComputeUnits() uint64 // Invariants: @@ -178,6 +227,19 @@ type FeeHandler interface { Deduct(ctx context.Context, addr codec.Address, mu state.Mutable, amount uint64) error } +type EpochManager interface { + // EpochKey is the key that corresponds to the height of the P-Chain to use for + // validation of a given epoch and the fees to use for verifying transactions. + EpochKey(epoch uint64) string +} + +type RewardHandler interface { + // Reward sends [amount] to [addr] after block execution if any fees or bonds were collected. + // + // Reward is only invoked if [amount] > 0. + // Reward(ctx context.Context, addr codec.Address, mu state.Mutable, amount uint64) error +} + // StateManager allows [Chain] to safely store certain types of items in state // in a structured manner. If we did not use [StateManager], we may overwrite // state written by actions or auth. @@ -187,6 +249,8 @@ type FeeHandler interface { type StateManager interface { FeeHandler MetadataManager + EpochManager + RewardHandler } type Object interface { diff --git a/chain/engine.go b/chain/engine.go new file mode 100644 index 0000000000..65420a4635 --- /dev/null +++ b/chain/engine.go @@ -0,0 +1,451 @@ +package chain + +import ( + "context" + "encoding/binary" + "errors" + "fmt" + "sync" + "time" + + "github.com/ava-labs/avalanchego/database" + "github.com/ava-labs/avalanchego/ids" + "github.com/ava-labs/avalanchego/utils/math" + "github.com/ava-labs/avalanchego/utils/maybe" + "github.com/ava-labs/avalanchego/utils/set" + "github.com/ava-labs/hypersdk/vilmo" + "github.com/ava-labs/hypersdk/consts" + "github.com/ava-labs/hypersdk/utils" + "go.uber.org/zap" +) + +type engineJob struct { + parentTimestamp int64 + blk *StatelessBlock + chunks chan *Chunk +} + +type output struct { + txs map[ids.ID]*blockLoc + chunkResults [][]*Result + + chunks []*FilteredChunk + checksum ids.ID +} + +// Engine is in charge of orchestrating the execution of +// accepted chunks. +// +// TODO: put in VM? +type Engine struct { + vm VM + done chan struct{} + + backlog chan *engineJob + + db *vilmo.Vilmo + + outputsLock sync.RWMutex + outputs map[uint64]*output + largestOutput *uint64 +} + +func NewEngine(vm VM, maxBacklog int) *Engine { + // TODO: strategically use old timestamps on blocks when catching up after processing + // surge to maximize chunk inclusion + return &Engine{ + vm: vm, + done: make(chan struct{}), + + backlog: make(chan *engineJob, maxBacklog), + + outputs: make(map[uint64]*output), + } +} + +func (e *Engine) processJob(batch *vilmo.Batch, job *engineJob) error { + log := e.vm.Logger() + e.vm.RecordEngineBacklog(-1) + + estart := time.Now() + ctx := context.Background() // TODO: cleanup + + // Setup state + r := e.vm.Rules(job.blk.StatefulBlock.Timestamp) + + // Fetch parent height key and ensure block height is valid + heightKey := HeightKey(e.vm.StateManager().HeightKey()) + parentHeightRaw, err := e.db.Get(ctx, heightKey) + if err != nil { + return err + } + parentHeight := binary.BigEndian.Uint64(parentHeightRaw) + if job.blk.Height() != parentHeight+1 { + // TODO: re-execute previous blocks to get to required state + return ErrInvalidBlockHeight + } + + // Fetch latest block context (used for reliable and recent warp verification) + var ( + pHeight *uint64 + shouldUpdatePHeight bool + ) + pHeightKey := PHeightKey(e.vm.StateManager().PHeightKey()) + pHeightRaw, err := e.db.Get(ctx, pHeightKey) + if err == nil { + h := binary.BigEndian.Uint64(pHeightRaw) + pHeight = &h + } + if pHeight == nil || *pHeight < job.blk.PHeight { // use latest P-Chain height during verification + shouldUpdatePHeight = true + pHeight = &job.blk.PHeight + } + + // Fetch PChainHeight for this epoch + // + // We don't need to check timestamps here becuase we already handled that in block verification. + epoch := utils.Epoch(job.blk.StatefulBlock.Timestamp, r.GetEpochDuration()) + _, epochHeights, err := e.GetEpochHeights(ctx, []uint64{epoch, epoch + 1}) + if err != nil { + return err + } + + // Process chunks + // + // We know that if any new available chunks are added that block context must be non-nil (so warp messages will be processed). + p := NewProcessor(e.vm, e, pHeight, epochHeights, len(job.blk.AvailableChunks), job.blk.StatefulBlock.Timestamp, e.db, r) + chunks := make([]*Chunk, 0, len(job.blk.AvailableChunks)) + for chunk := range job.chunks { + // Handle fetched chunk + p.Add(ctx, len(chunks), chunk) + chunks = append(chunks, chunk) + } + txSet, ts, chunkResults, err := p.Wait() + if err != nil { + e.vm.Logger().Fatal("chunk processing failed", zap.Error(err)) // does not actually panic + return err + } + if len(chunks) != len(job.blk.AvailableChunks) { + // This can happen on the shutdown path. If this is because of an error, the chunk manager will FATAL. + e.vm.Logger().Warn("did not receive all chunks from engine, exiting execution") + return ErrMissingChunks + } + e.vm.RecordExecutedChunks(len(chunks)) + + // TODO: pay beneficiary all tips for processing chunk + // + // TODO: add configuration for how much of base fee to pay (could be 100% in a payment network, however, + // this allows miner to drive up fees without consequence as they get their fees back) + + // Create FilteredChunks + // + // TODO: send chunk material here as soon as processed to speed up confirmation latency + txCount := 0 + filteredChunks := make([]*FilteredChunk, len(chunkResults)) + for i, chunkResult := range chunkResults { + var ( + validResults = make([]*Result, 0, len(chunkResult)) + chunk = chunks[i] + cert = job.blk.AvailableChunks[i] + txs = make([]*Transaction, 0, len(chunkResult)) + invalidTxs = make([]ids.ID, 0, 4) // TODO: make a const + reward uint64 + + warpResults set.Bits64 + warpCount uint + ) + for j, txResult := range chunkResult { + txCount++ + tx := chunk.Txs[j] + if !txResult.Valid { + txID := tx.ID() + invalidTxs = append(invalidTxs, txID) + // Remove txID from txSet if it was invalid and + // it was the first txID of its kind seen in the block. + if bl, ok := txSet[txID]; ok { + if bl.chunk == i && bl.index == j { + delete(txSet, txID) + } + } + + // TODO: handle case where Freezable (claim bond from user + freeze user) + // TODO: need to ensure that mark tx in set to prevent freezable replay? + continue + } + validResults = append(validResults, txResult) + txs = append(txs, tx) + if tx.WarpMessage != nil { + if txResult.WarpVerified { + warpResults.Add(warpCount) + } + warpCount++ + } + + // Add fee to reward + newReward, err := math.Add64(reward, txResult.Fee) + if err != nil { + panic(err) + } + reward = newReward + } + + // TODO: Pay beneficiary proportion of reward + // TODO: scale reward based on % of stake that signed cert + p.vm.Logger().Debug("rewarding beneficiary", zap.Uint64("reward", reward)) + + // Create filtered chunk + filteredChunks[i] = &FilteredChunk{ + Chunk: cert.Chunk, + + Producer: chunk.Producer, + Beneficiary: chunk.Beneficiary, + + Txs: txs, + WarpResults: warpResults, + } + + // As soon as execution of transactions is finished, let the VM know so that it + // can notify subscribers. + e.vm.ExecutedChunk(ctx, job.blk.StatefulBlock, filteredChunks[i], validResults, invalidTxs) // handled async by the vm + e.vm.RecordTxsInvalid(len(invalidTxs)) + } + + // Update tracked p-chain height as long as it is increasing + metaChunk := len(chunks) + ts.PrepareChunk(metaChunk, 1) + tsv := ts.NewWriteView(metaChunk, 0) + if job.blk.PHeight > 0 { // if context is not set, don't update P-Chain height in state or populate epochs + if shouldUpdatePHeight { + if err := tsv.Put(ctx, pHeightKey, binary.BigEndian.AppendUint64(nil, job.blk.PHeight)); err != nil { + panic(err) + } + e.vm.Logger().Info("setting current p-chain height", zap.Uint64("height", job.blk.PHeight)) + } else { + e.vm.Logger().Debug("ignoring p-chain height update", zap.Uint64("height", job.blk.PHeight)) + } + + // Ensure we are never stuck waiting for height information near the end of an epoch + // + // When validating data in a given epoch e, we need to know the p-chain height for epoch e and e+1. If either + // is not populated, we need to know by e that e+1 cannot be populated. If we only set e+2 below, we may + // not know whether e+1 is populated unitl we wait for e-1 execution to finish (as any block in e-1 could set + // the epoch height). This could cause verification to stutter across the boundary when it is taking longer than + // expected to set to the p-chain hegiht for an epoch. + nextEpoch := epoch + 3 + nextEpochKey := EpochKey(e.vm.StateManager().EpochKey(nextEpoch)) + epochValueRaw, err := e.db.Get(ctx, nextEpochKey) // | + switch { + case err == nil: + e.vm.Logger().Debug( + "height already set for epoch", + zap.Uint64("epoch", nextEpoch), + zap.Uint64("height", binary.BigEndian.Uint64(epochValueRaw[:consts.Uint64Len])), + ) + case err != nil && errors.Is(err, database.ErrNotFound): + value := make([]byte, consts.Uint64Len) + binary.BigEndian.PutUint64(value, job.blk.PHeight) + if err := tsv.Put(ctx, nextEpochKey, value); err != nil { + panic(err) + } + e.vm.CacheValidators(ctx, job.blk.PHeight) // optimistically fetch validators to prevent lockbacks + e.vm.Logger().Info( + "setting epoch height", + zap.Uint64("epoch", nextEpoch), + zap.Uint64("height", job.blk.PHeight), + ) + default: + e.vm.Logger().Warn( + "unable to determine if should set epoch height", + zap.Uint64("epoch", nextEpoch), + zap.Error(err), + ) + } + } + + // Update chain metadata + if err := tsv.Put(ctx, heightKey, binary.BigEndian.AppendUint64(nil, job.blk.StatefulBlock.Height)); err != nil { + return err + } + if err := tsv.Put(ctx, HeightKey(e.vm.StateManager().TimestampKey()), binary.BigEndian.AppendUint64(nil, uint64(job.blk.StatefulBlock.Timestamp))); err != nil { + return err + } + tsv.Commit() + + // Persist changes to state + commitStart := time.Now() + tstart := time.Now() + openBytes, rewrite := batch.Prepare() + e.vm.RecordVilmoBatchPrepare(time.Since(tstart)) + tstart = time.Now() + changes := 0 + if err := ts.Iterate(func(key string, value maybe.Maybe[[]byte]) error { + changes++ + if value.IsNothing() { + return batch.Delete(context.TODO(), key) + } else { + return batch.Put(context.TODO(), key, value.Value()) + } + }); err != nil { + return err + } + e.vm.RecordTStateIterate(time.Since(tstart)) + tstart = time.Now() + checksum, err := batch.Write() + if err != nil { + return err + } + e.vm.RecordVilmoBatchWrite(time.Since(tstart)) + e.vm.RecordStateChanges(changes) + e.vm.RecordVilmoBatchInitBytes(openBytes) + if rewrite { + e.vm.RecordVilmoBatchesRewritten() + } + e.vm.RecordWaitCommit(time.Since(commitStart)) + + // Store and update parent view + validTxs := len(txSet) + e.outputsLock.Lock() + e.outputs[job.blk.StatefulBlock.Height] = &output{ + txs: txSet, + chunkResults: chunkResults, + + chunks: filteredChunks, + checksum: checksum, + } + e.largestOutput = &job.blk.StatefulBlock.Height + e.outputsLock.Unlock() + + log.Info( + "executed block", + zap.Stringer("blkID", job.blk.ID()), + zap.Uint64("height", job.blk.StatefulBlock.Height), + zap.Int("valid txs", validTxs), + zap.Int("total txs", txCount), + zap.Int("chunks", len(filteredChunks)), + zap.Uint64("epoch", epoch), + zap.Duration("t", time.Since(estart)), + ) + e.vm.RecordBlockExecute(time.Since(estart)) + e.vm.RecordTxsIncluded(txCount) + e.vm.RecordExecutedEpoch(epoch) + e.vm.ExecutedBlock(ctx, job.blk.StatefulBlock) + return nil +} + +func (e *Engine) Run() { + defer close(e.done) + + // Get last accepted state + e.db = e.vm.State() + batchStart := time.Now() + batch, err := e.db.NewBatch() + if err != nil { + panic(err) + } + e.vm.RecordVilmoBatchInit(time.Since(batchStart)) + + for { + select { + case job := <-e.backlog: + err := e.processJob(batch, job) + switch { + case err == nil: + // Start preparation for new batch + batchStart := time.Now() + batch, err = e.db.NewBatch() + if err != nil { + panic(err) + } + e.vm.RecordVilmoBatchInit(time.Since(batchStart)) + continue + case errors.Is(ErrMissingChunks, err): + // Should only happen on shutdown + e.vm.Logger().Warn("engine shutting down", zap.Error(err)) + return + default: + panic(err) // unrecoverable error + } + case <-e.vm.StopChan(): + // Give up latest batch + batch.Abort() + return + } + } +} + +func (e *Engine) Execute(blk *StatelessBlock) { + // Request chunks for processing when ready + chunks := make(chan *Chunk, len(blk.AvailableChunks)) + e.vm.RequestChunks(blk.Height(), blk.AvailableChunks, chunks) // spawns a goroutine + + // Enqueue job + e.vm.RecordEngineBacklog(1) + e.backlog <- &engineJob{ + parentTimestamp: blk.parent.StatefulBlock.Timestamp, + blk: blk, + chunks: chunks, + } +} + +func (e *Engine) Results(height uint64) ([]ids.ID /* Executed Chunks */, ids.ID /* Checksum */, error) { + // TODO: handle case where never started execution (state sync) + e.outputsLock.RLock() + defer e.outputsLock.RUnlock() + + if output, ok := e.outputs[height]; ok { + filteredIDs := make([]ids.ID, len(output.chunks)) + for i, chunk := range output.chunks { + id, err := chunk.ID() + if err != nil { + return nil, ids.Empty, err + } + filteredIDs[i] = id + } + return filteredIDs, output.checksum, nil + } + return nil, ids.Empty, fmt.Errorf("%w: results not found for %d", errors.New("not found"), height) +} + +func (e *Engine) PruneResults(ctx context.Context, height uint64) ([][]*Result, []*FilteredChunk, error) { + e.outputsLock.Lock() + defer e.outputsLock.Unlock() + + output, ok := e.outputs[height] + if !ok { + return nil, nil, fmt.Errorf("%w: %d", errors.New("not outputs found at height"), height) + } + delete(e.outputs, height) + if e.largestOutput != nil && *e.largestOutput == height { + e.largestOutput = nil + } + return output.chunkResults, output.chunks, nil +} + +func (e *Engine) ReadLatestState(ctx context.Context, keys []string) ([][]byte, []error) { + return e.db.Gets(ctx, keys) +} + +func (e *Engine) Done() { + <-e.done +} + +func (e *Engine) GetEpochHeights(ctx context.Context, epochs []uint64) (int64, []*uint64, error) { + keys := []string{HeightKey(e.vm.StateManager().TimestampKey())} + for _, epoch := range epochs { + keys = append(keys, EpochKey(e.vm.StateManager().EpochKey(epoch))) + } + values, errs := e.ReadLatestState(ctx, keys) + if errs[0] != nil { + return -1, nil, fmt.Errorf("%w: can't read timestamp key", errs[0]) + } + heights := make([]*uint64, len(epochs)) + for i := 0; i < len(epochs); i++ { + if errs[i+1] != nil { + continue + } + value := values[i+1] + height := binary.BigEndian.Uint64(value[:consts.Uint64Len]) + heights[i] = &height + } + return int64(binary.BigEndian.Uint64(values[0])), heights, nil +} diff --git a/chain/errors.go b/chain/errors.go index 3bc5101703..418c1eb301 100644 --- a/chain/errors.go +++ b/chain/errors.go @@ -66,6 +66,10 @@ var ( ErrTxNSMappingNotReceived = errors.New("NMT tx to namespace mapping not received") ErrConvertingNamespace = errors.New("unable to convert encoded namespace id back to namespace id") + // Chunk building + ErrInvalidPartition = errors.New("invalid partition") + ErrNotAValidator = errors.New("not a validator during this epoch, so no one will sign my chunk") + // Misc ErrNotImplemented = errors.New("not implemented") ErrBlockNotProcessed = errors.New("block is not processed") diff --git a/codec/packer.go b/codec/packer.go index 566cdfec4e..7587b58262 100644 --- a/codec/packer.go +++ b/codec/packer.go @@ -57,6 +57,19 @@ func (p *Packer) UnpackID(required bool, dest *ids.ID) { } } +func (p *Packer) PackNodeID(src ids.NodeID) { + p.p.PackFixedBytes(src[:]) +} + +// UnpackNodeID unpacks an avalanchego NodeID into [dest]. If [required] is true, +// and the unpacked bytes are empty, Packer will add an ErrFieldNotPopulated error. +func (p *Packer) UnpackNodeID(required bool, dest *ids.NodeID) { + copy((*dest)[:], p.p.UnpackFixedBytes(ids.NodeIDLen)) + if required && *dest == ids.EmptyNodeID { + p.addErr(fmt.Errorf("%w: NodeID field is not populated", ErrFieldNotPopulated)) + } +} + func (p *Packer) PackByte(b byte) { p.p.PackByte(b) } diff --git a/config/config.go b/config/config.go index cafd2f8de3..7ac105aafe 100644 --- a/config/config.go +++ b/config/config.go @@ -15,40 +15,39 @@ import ( "github.com/AnomalyFi/hypersdk/trace" ) +// TODO: Add configs for vilmo and file db type Config struct{} func (c *Config) GetLogLevel() logging.Level { return logging.Info } -func (c *Config) GetAuthVerificationCores() int { return 1 } -func (c *Config) GetRootGenerationCores() int { return 1 } -func (c *Config) GetTransactionExecutionCores() int { return 1 } -func (c *Config) GetStateFetchConcurrency() int { return 1 } -func (c *Config) GetMempoolSize() int { return 2_048 } +func (c *Config) GetAuthExecutionCores() int { return 1 } +func (c *Config) GetAuthRPCCores() int { return 1 } +func (c *Config) GetAuthRPCBacklog() int { return 1_024 } +func (c *Config) GetAuthGossipCores() int { return 1 } +func (c *Config) GetAuthGossipBacklog() int { return 1_024 } +func (c *Config) GetPrecheckCores() int { return 1 } +func (c *Config) GetActionExecutionCores() int { return 1 } +func (c *Config) GetChunkStorageCores() int { return 1 } +func (c *Config) GetChunkStorageBacklog() int { return 1_024 } +func (c *Config) GetMissingChunkFetchers() int { return 4 } +func (c *Config) GetMempoolSize() int { return 2 * units.GiB } func (c *Config) GetMempoolSponsorSize() int { return 32 } func (c *Config) GetMempoolExemptSponsors() []codec.Address { return nil } -func (c *Config) GetStreamingBacklogSize() int { return 1024 } -func (c *Config) GetIntermediateNodeCacheSize() int { return 4 * units.GiB } -func (c *Config) GetStateIntermediateWriteBufferSize() int { return 32 * units.MiB } -func (c *Config) GetStateIntermediateWriteBatchSize() int { return 4 * units.MiB } -func (c *Config) GetValueNodeCacheSize() int { return 2 * units.GiB } +func (c *Config) GetStreamingBacklogSize() int { return 1_024 } func (c *Config) GetTraceConfig() *trace.Config { return &trace.Config{Enabled: false} } -func (c *Config) GetStateSyncParallelism() int { return 4 } -func (c *Config) GetStateSyncServerDelay() time.Duration { return 0 } // used for testing - -func (c *Config) GetParsedBlockCacheSize() int { return 128 } -func (c *Config) GetStateHistoryLength() int { return 256 } -func (c *Config) GetAcceptedBlockWindowCache() int { return 128 } // 256MB at 2MB blocks -func (c *Config) GetAcceptedBlockWindow() int { return 50_000 } // ~3.5hr with 250ms block time (100GB at 2MB) -func (c *Config) GetStateSyncMinBlocks() uint64 { return 768 } // set to max int for archive nodes to ensure no skips -func (c *Config) GetAcceptorSize() int { return 64 } -func (c *Config) GetStoreBlockResultsOnDisk() bool { return true } +func (c *Config) GetParsedBlockCacheSize() int { return 128 } +func (c *Config) GetAcceptedBlockWindowCache() int { return 128 } // 256MB at 2MB blocks +func (c *Config) GetAcceptedBlockWindow() int { return 512 } // TODO: make this longer for prod +func (c *Config) GetAcceptorSize() int { return 64 } func (c *Config) GetContinuousProfilerConfig() *profiler.Config { return &profiler.Config{Enabled: false} } -func (c *Config) GetVerifyAuth() bool { return true } -func (c *Config) GetTargetBuildDuration() time.Duration { return 100 * time.Millisecond } -func (c *Config) GetProcessingBuildSkip() int { return 16 } -func (c *Config) GetTargetGossipDuration() time.Duration { return 20 * time.Millisecond } -func (c *Config) GetBlockCompactionFrequency() int { return 32 } // 64 MB of deletion if 2 MB blocks -func (c *Config) GetETHL1RPC() string { return "http://localhost:8545" } -func (c *Config) GetETHL1WS() string { return "ws://localhost:8546" } +func (c *Config) GetVerifyAuth() bool { return true } +func (c *Config) GetTargetChunkBuildDuration() time.Duration { return 100 * time.Millisecond } +func (c *Config) GetChunkBuildFrequency() time.Duration { return 250 * time.Millisecond } +func (c *Config) GetBlockBuildFrequency() time.Duration { return time.Second } +func (c *Config) GetProcessingBuildSkip() int { return 16 } +func (c *Config) GetMinimumCertificateBroadcastNumerator() uint64 { return 85 } // out of 100 (more weight == more fees) +func (c *Config) GetBeneficiary() codec.Address { return codec.EmptyAddress } +func (c *Config) GetETHL1RPC() string { return "http://localhost:8545" } +func (c *Config) GetETHL1WS() string { return "ws://localhost:8546" } diff --git a/consts/consts.go b/consts/consts.go index f57534efe6..0bcf02c2fe 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -19,15 +19,15 @@ const ( // no more than 50 KiB of overhead but is likely much less) NetworkSizeLimit = 2_044_723 // 1.95 MiB - MaxUint8 = ^uint8(0) - MaxUint16 = ^uint16(0) - MaxUint8Offset = 7 - MaxUint = ^uint(0) - MaxInt = int(MaxUint >> 1) - MaxUint64Offset = 63 - MaxUint64 = ^uint64(0) - MillisecondsPerSecond = 1000 - + MaxUint8 = ^uint8(0) + MaxUint16 = ^uint16(0) + MaxUint8Offset = 7 + MaxUint = ^uint(0) + MaxInt = int(MaxUint >> 1) + MaxUint64Offset = 63 + MaxUint64 = ^uint64(0) + MillisecondsPerSecond = 1000 + MillisecondsPerDecisecond = 100 // NMT // See https://github.com/celestiaorg/nmt/blob/master/nmt.go#L477 // 8 + 8 + 32 diff --git a/emap/lemap.go b/emap/lemap.go new file mode 100644 index 0000000000..97d2c30a12 --- /dev/null +++ b/emap/lemap.go @@ -0,0 +1,162 @@ +// Copyright (C) 2023, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package emap + +import ( + "sync" + + "github.com/AnomalyFi/hypersdk/heap" + "github.com/ava-labs/avalanchego/ids" + "github.com/ava-labs/avalanchego/utils/set" +) + +// A LEmap implements en eviction map that stores the status +// of txs and their linked timestamps. The type [T] must implement the +// Item interface. +// +// A LEMap only allows the caller to check for the inclusion of some Item +// but not actually fetch that item. +// +// TODO: rename this/unify with emap impl +type LEMap[T Item] struct { + mu sync.RWMutex + + bh *heap.Heap[*bucket, int64] + seen map[ids.ID]struct{} // Stores a set of unique tx ids + times map[int64]*bucket // Uses timestamp as keys to map to buckets of ids. +} + +// NewLEMap returns a pointer to a instance of an empty LEMap struct. +func NewLEMap[T Item]() *LEMap[T] { + return &LEMap[T]{ + seen: map[ids.ID]struct{}{}, + times: make(map[int64]*bucket), + bh: heap.New[*bucket, int64](120, true), + } +} + +// Add adds a list of txs to the LEMap. +func (e *LEMap[T]) Add(items []T) { + e.mu.Lock() + defer e.mu.Unlock() + + for _, item := range items { + e.add(item) + } +} + +// Add adds an id with a timestampt [t] to the LEMap. If the timestamp +// is genesis(0) or the id has been seen already, add returns. The id is +// added to a bucket with timestamp [t]. If no bucket exists, add creates a +// new bucket and pushes it to the binaryHeap. +func (e *LEMap[T]) add(item T) { + id := item.ID() + t := item.Expiry() + + // Assume genesis txs can't be placed in seen tracker + if t == 0 { + return + } + + // Check if already exists + _, ok := e.seen[id] + if ok { + return + } + e.seen[id] = struct{}{} + + // Check if bucket with time already exists + if b, ok := e.times[t]; ok { + b.items = append(b.items, id) + return + } + + // Create new bucket + b := &bucket{ + t: t, + items: []ids.ID{id}, + } + e.times[t] = b + e.bh.Push(&heap.Entry[*bucket, int64]{ + ID: id, + Val: t, + Item: b, + Index: e.bh.Len(), + }) +} + +// SetMin removes all buckets with a lower +// timestamp than [t] from e's bucketHeap. +func (e *LEMap[T]) SetMin(t int64) []ids.ID { + e.mu.Lock() + defer e.mu.Unlock() + + evicted := []ids.ID{} + for { + b := e.bh.First() + if b == nil || b.Val >= t { + break + } + e.bh.Pop() + for _, id := range b.Item.items { + delete(e.seen, id) + evicted = append(evicted, id) + } + // Delete from times map + delete(e.times, b.Val) + } + return evicted +} + +// Any returns true if any items have been seen by LEMap. +func (e *LEMap[T]) Any(items []T) bool { + e.mu.RLock() + defer e.mu.RUnlock() + + for _, item := range items { + _, ok := e.seen[item.ID()] + if ok { + return true + } + } + return false +} + +func (e *LEMap[T]) Contains(items []T, marker set.Bits, stop bool) set.Bits { + e.mu.RLock() + defer e.mu.RUnlock() + + for i, item := range items { + if marker.Contains(i) { + continue + } + _, ok := e.seen[item.ID()] + if ok { + marker.Add(i) + if stop { + return marker + } + } + } + return marker +} + +func (e *LEMap[T]) Has(item T) bool { + return e.HasID(item.ID()) +} + +func (e *LEMap[T]) HasID(id ids.ID) bool { + e.mu.RLock() + defer e.mu.RUnlock() + + _, ok := e.seen[id] + return ok +} + +func (e *LEMap[T]) Size() int { + e.mu.RLock() + defer e.mu.RUnlock() + + return len(e.seen) +} diff --git a/filedb/errors.go b/filedb/errors.go new file mode 100644 index 0000000000..e30776d556 --- /dev/null +++ b/filedb/errors.go @@ -0,0 +1,5 @@ +package filedb + +import "errors" + +var ErrCorrupt = errors.New("corrupt file") diff --git a/filedb/filedb.go b/filedb/filedb.go new file mode 100644 index 0000000000..98d0e77b5c --- /dev/null +++ b/filedb/filedb.go @@ -0,0 +1,167 @@ +package filedb + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + + "github.com/AnomalyFi/hypersdk/lockmap" + "github.com/AnomalyFi/hypersdk/utils" + "github.com/ava-labs/avalanchego/cache" + "github.com/ava-labs/avalanchego/database" + "github.com/ava-labs/avalanchego/ids" + "github.com/ava-labs/avalanchego/utils/units" +) + +type FileDB struct { + baseDir string + sync bool + + lm *lockmap.Lockmap + + fileCache cache.Cacher[string, []byte] +} + +type Config struct { + sync bool + directoryCache int + dataCache int +} + +func NewDefaultConfig() *Config { + return &Config{ + sync: true, + directoryCache: 1024, + dataCache: 1 * units.GiB, + } +} + +// TODO: return metrics too, while initiating new filedb. +func New(baseDir string, cfg *Config) *FileDB { + return &FileDB{ + baseDir: baseDir, + sync: cfg.sync, + lm: lockmap.New(16), // concurrent locks + fileCache: cache.NewSizedLRU[string, []byte](cfg.dataCache, func(key string, value []byte) int { return len(key) + len(value) }), + } +} + +func (f *FileDB) Put(key string, value []byte, cache bool) error { + filePath := filepath.Join(f.baseDir, key) + + // Don't do anything if already in cache and bytes equal + if cachedValue, exists := f.fileCache.Get(filePath); exists { + if bytes.Equal(cachedValue, value) { + return nil + } + } + + // Put in cache before writing to disk so readers can still access if there + // is a write backlog. + if cache { + f.fileCache.Put(filePath, value) + } + + // Store the value on disk + f.lm.Lock(filePath) + defer f.lm.Unlock(filePath) + file, err := os.Create(filePath) + if err != nil { + return fmt.Errorf("%w: unable to create file", err) + } + defer file.Close() + + vid := utils.ToID(value) + _, err = file.Write(vid[:]) + if err != nil { + return fmt.Errorf("%w: unable to write to file", err) + } + _, err = file.Write(value) + if err != nil { + return fmt.Errorf("%w: unable to write to file", err) + } + if f.sync { + if err := file.Sync(); err != nil { + return fmt.Errorf("%w: unable to sync file", err) + } + } + return nil +} + +func (f *FileDB) Get(key string, cache bool) ([]byte, error) { + filePath := filepath.Join(f.baseDir, key) + + // Attempt to read from cache + if value, exists := f.fileCache.Get(filePath); exists { + return value, nil + } + + // Attempt to read from disk + f.lm.RLock(filePath) + defer f.lm.RUnlock(filePath) + file, err := os.Open(filePath) + if err != nil { + return nil, database.ErrNotFound + } + defer file.Close() + + stat, err := file.Stat() + if err != nil { + return nil, fmt.Errorf("%w: unable to stat file", err) + } + + diskValue := make([]byte, stat.Size()) + if _, err = file.Read(diskValue); err != nil { + return nil, fmt.Errorf("%w: unable to read from file", err) + } + if len(diskValue) < ids.IDLen { + return nil, fmt.Errorf("%w: less than IDLen found=%d", ErrCorrupt, len(diskValue)) + } + value := diskValue[ids.IDLen:] + vid := utils.ToID(value) + did := ids.ID(diskValue[:ids.IDLen]) + if vid != did { + return nil, fmt.Errorf("%w: found=%s expected=%s", ErrCorrupt, vid, did) + } + if cache { + f.fileCache.Put(filePath, value) + } + return value, nil +} + +func (f *FileDB) Has(key string) (bool, error) { + filePath := filepath.Join(f.baseDir, key) + + // Attempt to reach from cache + if _, exists := f.fileCache.Get(filePath); exists { + return true, nil + } + + // Attempt to read from disk + f.lm.RLock(filePath) + defer f.lm.RUnlock(filePath) + _, err := os.Stat(filePath) + if os.IsNotExist(err) { + return false, nil + } + if err != nil { + return false, err + } + return true, nil +} + +func (f *FileDB) Remove(key string) error { + filePath := filepath.Join(f.baseDir, key) + f.lm.Lock(filePath) + defer f.lm.Unlock(filePath) + + if err := os.Remove(filePath); err != nil { + return err + } + f.fileCache.Evict(filePath) + return nil +} + +// Close doesn't do anything but is canonical for a database to provide. +func (f *FileDB) Close() error { return nil } diff --git a/filedb/filedb_test.go b/filedb/filedb_test.go new file mode 100644 index 0000000000..412a09533f --- /dev/null +++ b/filedb/filedb_test.go @@ -0,0 +1,213 @@ +package filedb + +import ( + "context" + "crypto/rand" + "fmt" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/AnomalyFi/hypersdk/pebble" + "github.com/ava-labs/avalanchego/database" + "github.com/ava-labs/avalanchego/utils/units" + "github.com/stretchr/testify/require" + "golang.org/x/sync/errgroup" +) + +func TestFileDB(t *testing.T) { + require := require.New(t) + cfg := NewDefaultConfig() + cfg.dataCache = 2 * units.MiB + db := New(t.TempDir(), cfg) + + v, err := db.Get("1", true) + require.ErrorIs(err, database.ErrNotFound) + require.Empty(v) + + require.NoError(db.Put("1", []byte("2"), true)) + + v, err = db.Get("1", true) + require.NoError(err) + require.Equal([]byte("2"), v) + + require.NoError(db.Put("2", []byte("3"), true)) + + v, err = db.Get("2", true) + require.Nil(err) + require.Equal([]byte("3"), v) + + require.NoError(db.Remove("1")) + require.NoError(db.Remove("2")) + + v, err = db.Get("1", true) + require.ErrorIs(err, database.ErrNotFound) + require.Empty(v) + v, err = db.Get("2", true) + require.ErrorIs(err, database.ErrNotFound) + require.Empty(v) + + require.Zero(db.lm.Locks()) +} + +func TestFileDBCorruption(t *testing.T) { + require := require.New(t) + dbDir := t.TempDir() + cfg := NewDefaultConfig() + cfg.dataCache = 2 * units.MiB + db := New(t.TempDir(), cfg) + + v, err := db.Get("1", true) + require.ErrorIs(err, database.ErrNotFound) + require.Empty(v) + + require.NoError(db.Put("1", []byte("2"), true)) + + v, err = db.Get("1", true) + require.NoError(err) + require.Equal([]byte("2"), v) + + // Corrupt file with invalid length + f, err := os.Create(filepath.Join(dbDir, "1")) + require.NoError(err) + _, err = f.Write([]byte{}) + require.NoError(err) + require.NoError(f.Close()) + + db.fileCache.Flush() + v, err = db.Get("1", true) + require.ErrorIs(err, ErrCorrupt) + require.Empty(v) + + // Corrupt file with invalid data + f, err = os.Create(filepath.Join(dbDir, "1")) + require.NoError(err) + msg := make([]byte, 1.5*units.MiB) + _, err = rand.Read(msg) + require.NoError(err) + _, err = f.Write(msg) + require.NoError(err) + require.NoError(f.Close()) + + v, err = db.Get("1", true) + require.ErrorIs(err, ErrCorrupt) + require.Empty(v) +} + +func TestFileDBCache(t *testing.T) { + require := require.New(t) + dbDir := t.TempDir() + cfg := NewDefaultConfig() + cfg.dataCache = 2 * units.MiB + db := New(t.TempDir(), cfg) + + require.NoError(db.Put("1", []byte("2"), true)) + require.NoError(db.Put("2", []byte("3"), true)) + + db.fileCache.Flush() + v, err := db.Get("1", true) + require.NoError(err) + require.Equal([]byte("2"), v) + + require.NoError(db.Put("1", []byte("4"), true)) + v, cache := db.fileCache.Get(filepath.Join(dbDir, "1")) + require.True(cache) + require.Equal([]byte("4"), v) +} + +func BenchmarkFileDB(b *testing.B) { + for _, sync := range []bool{true, false} { + b.Run(fmt.Sprintf("sync=%v", sync), func(b *testing.B) { + b.StopTimer() + cfg := NewDefaultConfig() + cfg.dataCache = 2 * units.MiB + db := New(b.TempDir(), cfg) + msg := make([]byte, 1.5*units.MiB) + _, err := rand.Read(msg) + if err != nil { + b.Fatal(err) + } + b.StartTimer() + for i := 0; i < b.N; i++ { + if err := db.Put(fmt.Sprintf("%d", i), msg, true); err != nil { + b.Fatal(err) + } + } + }) + } +} + +func BenchmarkFileDBConcurrent(b *testing.B) { + for _, sync := range []bool{true, false} { + b.Run(fmt.Sprintf("sync=%v", sync), func(b *testing.B) { + b.StopTimer() + cfg := NewDefaultConfig() + cfg.dataCache = 2 * units.MiB + db := New(b.TempDir(), cfg) + msg := make([]byte, 1.5*units.MiB) + _, err := rand.Read(msg) + if err != nil { + b.Fatal(err) + } + b.StartTimer() + g, _ := errgroup.WithContext(context.TODO()) + g.SetLimit(runtime.NumCPU()) + for i := 0; i < b.N; i++ { + ti := i + g.Go(func() error { + return db.Put(fmt.Sprintf("%d", ti), msg, true) + }) + } + if err := g.Wait(); err != nil { + b.Fatal(err) + } + }) + } +} + +func BenchmarkPebbleDB(b *testing.B) { + b.StopTimer() + db, _, err := pebble.New(b.TempDir(), pebble.NewDefaultConfig()) + if err != nil { + b.Fatal(err) + } + defer db.Close() + + msg := make([]byte, 1.5*units.MiB) + if _, err := rand.Read(msg); err != nil { + b.Fatal(err) + } + b.StartTimer() + for i := 0; i < b.N; i++ { + if err := db.Put([]byte(fmt.Sprintf("%d", i)), msg); err != nil { + b.Fatal(err) + } + } +} + +func BenchmarkPebbleDBConcurrent(b *testing.B) { + b.StopTimer() + db, _, err := pebble.New(b.TempDir(), pebble.NewDefaultConfig()) + if err != nil { + b.Fatal(err) + } + defer db.Close() + + msg := make([]byte, 1.5*units.MiB) + if _, err := rand.Read(msg); err != nil { + b.Fatal(err) + } + b.StartTimer() + g, _ := errgroup.WithContext(context.TODO()) + g.SetLimit(runtime.NumCPU()) + for i := 0; i < b.N; i++ { + ti := i + g.Go(func() error { + return db.Put([]byte(fmt.Sprintf("%d", ti)), msg) + }) + } + if err := g.Wait(); err != nil { + b.Fatal(err) + } +} diff --git a/go.mod b/go.mod index 5d7628daf1..bb93d09af6 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/bytecodealliance/wasmtime-go/v14 v14.0.0 github.com/celestiaorg/nmt v0.20.0 github.com/cockroachdb/pebble v0.0.0-20230928194634-aa077af62593 + github.com/dustin/go-humanize v1.0.0 github.com/ethereum/go-ethereum v1.13.8 github.com/gorilla/mux v1.8.0 github.com/gorilla/rpc v1.2.0 @@ -68,12 +69,12 @@ require ( github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/golang/snappy v0.0.5-0.20220116011046-fa5810519dcb // indirect - github.com/google/btree v1.1.2 // indirect github.com/google/pprof v0.0.0-20230406165453-00490a63f317 // indirect github.com/google/renameio/v2 v2.0.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 // indirect github.com/holiman/uint256 v1.2.4 // indirect github.com/klauspost/compress v1.15.15 // indirect + github.com/klauspost/cpuid/v2 v2.0.9 // indirect github.com/kr/pretty v0.3.1 // indirect github.com/kr/text v0.2.0 // indirect github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect @@ -92,6 +93,7 @@ require ( github.com/tklauser/go-sysconf v0.3.12 // indirect github.com/tklauser/numcpus v0.6.1 // indirect github.com/yusufpapurcu/wmi v1.2.2 // indirect + github.com/zeebo/xxh3 v1.0.2 go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.22.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.22.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.22.0 // indirect diff --git a/go.sum b/go.sum index e718b25c21..1c94b6ea61 100644 --- a/go.sum +++ b/go.sum @@ -120,6 +120,7 @@ github.com/decred/dcrd/dcrec/secp256k1/v4 v4.1.0/go.mod h1:DZGJHZMqrU4JJqFAWUS2U github.com/decred/dcrd/lru v1.0.0/go.mod h1:mxKOwFd7lFjN2GZYsiz/ecgqR6kkYAl+0pz0tEMk218= github.com/dgraph-io/badger v1.6.0/go.mod h1:zwt7syl517jmP8s94KqSxTlM6IMsdhYy6psNgSztDR4= github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= +github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo= github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/eknkc/amber v0.0.0-20171010120322-cdade1c07385/go.mod h1:0vRUJqYpeSZifjYj7uP3BG/gKcuzL9xWVV/Y+cK33KM= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= @@ -201,8 +202,6 @@ github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEW github.com/golang/snappy v0.0.5-0.20220116011046-fa5810519dcb h1:PBC98N2aIaM3XXiurYmW7fx4GZkL8feAMVq7nEjURHk= github.com/golang/snappy v0.0.5-0.20220116011046-fa5810519dcb/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/gomodule/redigo v1.7.1-0.20190724094224-574c33c3df38/go.mod h1:B4C85qUVwatsJoIUNIfCRsp7qO0iAmpGFZ4EELWSbC4= -github.com/google/btree v1.1.2 h1:xf4v41cLI2Z6FxbKm+8Bu+m8ifhj15JuZ9sa0jZCMUU= -github.com/google/btree v1.1.2/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= @@ -278,6 +277,8 @@ github.com/klauspost/compress v1.9.7/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0 github.com/klauspost/compress v1.15.15 h1:EF27CXIuDsYJ6mmvtBRlEuB2UVOqHG1tAXgZ7yIO+lw= github.com/klauspost/compress v1.15.15/go.mod h1:ZcK2JAFqKOpnBlxcLsJzYfrS9X1akm9fHZNnD9+Vo/4= github.com/klauspost/cpuid v1.2.1/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek= +github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -467,6 +468,10 @@ github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9dec github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yusufpapurcu/wmi v1.2.2 h1:KBNDSne4vP5mbSWnJbO+51IMOXJB67QiYCSBrubbPRg= github.com/yusufpapurcu/wmi v1.2.2/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= +github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= +github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= +github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= go.opentelemetry.io/otel v1.22.0 h1:xS7Ku+7yTFvDfDraDIJVpw7XPyuHlB9MCiqqX5mcJ6Y= go.opentelemetry.io/otel v1.22.0/go.mod h1:eoV4iAi3Ea8LkAEI9+GFT44O6T/D0GWAVFyZVCC6pMI= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.22.0 h1:9M3+rhx7kZCIQQhQRYaZCdNu1V73tm4TvXs2ntl98C4= diff --git a/lockmap/lockmap.go b/lockmap/lockmap.go new file mode 100644 index 0000000000..2ca27afbf5 --- /dev/null +++ b/lockmap/lockmap.go @@ -0,0 +1,83 @@ +package lockmap + +import "sync" + +type holderLock struct { + holders int + mu sync.RWMutex +} + +type Lockmap struct { + l sync.Mutex + m map[string]*holderLock +} + +func New(initSize int) *Lockmap { + return &Lockmap{ + m: make(map[string]*holderLock, initSize), + } +} + +func (l *Lockmap) Lock(key string) { + l.lock(key, true) +} + +func (l *Lockmap) Unlock(key string) { + l.unlock(key, true) +} + +func (l *Lockmap) RLock(key string) { + l.lock(key, false) +} + +func (l *Lockmap) RUnlock(key string) { + l.unlock(key, false) +} + +func (l *Lockmap) lock(key string, write bool) { + l.l.Lock() + hl, ok := l.m[key] + if !ok { + hl = &holderLock{} + l.m[key] = hl + } + hl.holders++ + l.l.Unlock() + + // Another caller may grab this lock before we do, however, + // that's fine. + if write { + hl.mu.Lock() + } else { + hl.mu.RLock() + } +} + +func (l *Lockmap) unlock(key string, write bool) { + l.l.Lock() + defer l.l.Unlock() + + hl := l.m[key] + + // While unlocking these mutexes isn't required + // if we are the last holder, we still do it as + // a best practice. + if write { + hl.mu.Unlock() + } else { + hl.mu.RUnlock() + } + + if hl.holders == 1 { + delete(l.m, key) + } else { + hl.holders-- + } +} + +func (l *Lockmap) Locks() int { + l.l.Lock() + defer l.l.Unlock() + + return len(l.m) +} diff --git a/oexpirer/oexpirer.go b/oexpirer/oexpirer.go new file mode 100644 index 0000000000..db634360b1 --- /dev/null +++ b/oexpirer/oexpirer.go @@ -0,0 +1,106 @@ +package oexpirer + +import ( + "sync" + + "github.com/AnomalyFi/hypersdk/list" + + "github.com/ava-labs/avalanchego/ids" +) + +type Item interface { + ID() ids.ID + Expiry() int64 +} + +type OExpirer[T Item] struct { + l sync.RWMutex + q *list.List[T] + m map[ids.ID]*list.Element[T] +} + +func New[T Item](init int) *OExpirer[T] { + return &OExpirer[T]{ + q: &list.List[T]{}, + m: make(map[ids.ID]*list.Element[T], init), + } +} + +func (o *OExpirer[T]) Add(i T, front bool) { + o.l.Lock() + defer o.l.Unlock() + + if _, ok := o.m[i.ID()]; ok { + return + } + var e *list.Element[T] + if front { + e = o.q.PushFront(i) + } else { + e = o.q.PushBack(i) + } + o.m[i.ID()] = e +} + +func (o *OExpirer[T]) Remove(id ids.ID) (T, bool) { + o.l.Lock() + defer o.l.Unlock() + + e, ok := o.m[id] + if !ok { + return *new(T), false + } + + o.q.Remove(e) + delete(o.m, id) + return e.Value(), true +} + +func (o *OExpirer[T]) RemoveNext() (T, bool) { + o.l.Lock() + defer o.l.Unlock() + + e := o.q.First() + if e == nil { + return *new(T), false + } + + o.q.Remove(e) + delete(o.m, e.ID()) + return e.Value(), true +} + +func (o *OExpirer[T]) Has(id ids.ID) bool { + o.l.RLock() + defer o.l.RUnlock() + + _, ok := o.m[id] + return ok +} + +func (o *OExpirer[T]) SetMin(t int64) []T { + o.l.Lock() + defer o.l.Unlock() + + expired := []T{} + for { + e := o.q.First() + if e == nil { + break + } + if e.Value().Expiry() >= t { + break + } + o.q.Remove(e) + delete(o.m, e.Value().ID()) + expired = append(expired, e.Value()) + } + return expired +} + +func (o *OExpirer[T]) Len() int { + o.l.RLock() + defer o.l.RUnlock() + + return len(o.m) +} diff --git a/opool/opool.go b/opool/opool.go new file mode 100644 index 0000000000..ff22b81dd6 --- /dev/null +++ b/opool/opool.go @@ -0,0 +1,132 @@ +package opool + +import ( + "sync" + + "go.uber.org/atomic" +) + +// OPool is a pool of goroutines that can execute functions in parallel +// but invoke callbacks (if provided) in the order they are enqueued. +type OPool struct { + added int + + work chan *task + workClose sync.Once + + outstandingWorkers sync.WaitGroup + + processedL sync.Mutex + processedM map[int]func() + toProcess int + + err atomic.Error +} + +// New returns an instance of [OPool] that +// has [worker] goroutines. +func New(workers, backlog int) *OPool { + p := &OPool{ + work: make(chan *task, backlog), + processedM: make(map[int]func()), + } + for i := 0; i < workers; i++ { + // We assume all workers will be required and start + // them immediately. + p.startWorker() + } + return p +} + +func (p *OPool) startWorker() { + p.outstandingWorkers.Add(1) + + go func() { + defer p.outstandingWorkers.Done() + + for t := range p.work { + p.run(t) + } + }() +} + +type task struct { + i int + f func() (func(), error) +} + +func (p *OPool) run(t *task) { + if p.err.Load() != nil { + return + } + + f, err := t.f() + if err != nil { + p.err.CompareAndSwap(nil, err) + return + } + + // Run available functions + p.processedL.Lock() + defer p.processedL.Unlock() + funcs := []func(){} + if t.i != p.toProcess { + p.processedM[t.i] = f + } else { + funcs = append(funcs, f) + p.toProcess++ + for { + if f, ok := p.processedM[p.toProcess]; ok { + funcs = append(funcs, f) + delete(p.processedM, p.toProcess) + p.toProcess++ + } else { + break + } + } + } + // We must execute these functions with the lock held + // to ensure they are executed in the correct order. + for _, f := range funcs { + if f == nil { + continue + } + f() + } +} + +// Go executes the given function on an existing goroutine waiting for more work. If +// [f] returns a function, it will be executed in the order it was enqueued. Returned +// functions are executed by the worker directly (blocking processing of other callbacks +// and other tasks by the worker), so it is important to minimize the returned function complexity. +// +// Go must not be called after Wait, otherwise it might panic. +// +// Go should not be called concurrently from multiple goroutines. +// +// If the pool has errored, Go will not execute the function and will return immediately. +// This means that enqueued functions may never be executed. +func (p *OPool) Go(f func() (func(), error)) { + if p.err.Load() != nil { + return + } + + t := &task{i: p.added, f: f} + p.added++ + p.work <- t +} + +// Wait returns after all enqueued work finishes and all goroutines to exit. +// Wait returns the number of workers that were spawned during the run. +// +// Wait can only be called after ALL calls to [Execute] have returned. +// +// It is safe to call Wait multiple times but not safe to call [Execute] +// after [Wait] has been called. +func (p *OPool) Wait() error { + p.workClose.Do(func() { + close(p.work) + }) + p.outstandingWorkers.Wait() + return p.err.Load() +} diff --git a/opool/opool_test.go b/opool/opool_test.go new file mode 100644 index 0000000000..4efd7f2d8a --- /dev/null +++ b/opool/opool_test.go @@ -0,0 +1,154 @@ +package opool + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "fmt" + "sync" + "testing" + + "golang.org/x/sync/errgroup" +) + +var ( + signatures = []int{1024, 4096, 16384, 32768, 65536, 131072} + workers = []int{2, 4, 8, 16, 24, 32} + batches = []int{1, 2, 4, 8, 16, 32, 64, 128} +) + +func generateSignature(b *testing.B) ([]byte, ed25519.PublicKey, []byte) { + msg := make([]byte, 128) + _, err := rand.Read(msg) + if err != nil { + b.Fatal(err) + } + pub, priv, err := ed25519.GenerateKey(nil) + if err != nil { + b.Fatal(err) + } + sig := ed25519.Sign(priv, msg) + return msg, pub, sig +} + +func BenchmarkBaseline(b *testing.B) { + for _, sigs := range signatures { + b.Run(fmt.Sprintf("sigs=%d", sigs), func(b *testing.B) { + b.StopTimer() + msg, pub, sig := generateSignature(b) + b.StartTimer() + b.ReportAllocs() + for i := 0; i < b.N; i++ { + for j := 0; j < sigs; j++ { + ed25519.Verify(pub, msg, sig) + } + } + }) + } +} + +func BenchmarkNewGoroutines(b *testing.B) { + for _, sigs := range signatures { + for _, batch := range batches { + if batch > sigs { + continue + } + b.Run(fmt.Sprintf("sigs=%d batch=%d", sigs, batch), func(b *testing.B) { + b.StopTimer() + msg, pub, sig := generateSignature(b) + b.StartTimer() + b.ReportAllocs() + for i := 0; i < b.N; i++ { + wg := sync.WaitGroup{} + started := 0 + for started < sigs { + run := min(batch, sigs-started) + started += run + wg.Add(1) + go func() { + defer wg.Done() + + for j := 0; j < run; j++ { + ed25519.Verify(pub, msg, sig) + } + }() + } + wg.Wait() + } + }) + } + } +} + +func BenchmarkErrgroup(b *testing.B) { + for _, sigs := range signatures { + for _, w := range workers { + for _, batch := range batches { + if batch > sigs { + continue + } + if w > sigs { + continue + } + b.Run(fmt.Sprintf("sigs=%d w=%d batch=%d", sigs, w, batch), func(b *testing.B) { + b.StopTimer() + msg, pub, sig := generateSignature(b) + b.StartTimer() + b.ReportAllocs() + for i := 0; i < b.N; i++ { + g, _ := errgroup.WithContext(context.TODO()) + g.SetLimit(w) + started := 0 + for started < sigs { + run := min(batch, sigs-started) + started += run + g.Go(func() error { + for j := 0; j < run; j++ { + ed25519.Verify(pub, msg, sig) + } + return nil + }) + } + g.Wait() + } + }) + } + } + } +} + +func BenchmarkOPool(b *testing.B) { + for _, sigs := range signatures { + for _, w := range workers { + for _, batch := range batches { + if batch > sigs { + continue + } + if w > sigs { + continue + } + b.Run(fmt.Sprintf("sigs=%d w=%d batch=%d", sigs, w, batch), func(b *testing.B) { + b.StopTimer() + msg, pub, sig := generateSignature(b) + b.StartTimer() + b.ReportAllocs() + for i := 0; i < b.N; i++ { + op := New(w, sigs) + started := 0 + for started < sigs { + run := min(batch, sigs-started) + started += run + op.Go(func() (func(), error) { + for j := 0; j < run; j++ { + ed25519.Verify(pub, msg, sig) + } + return nil, nil + }) + } + op.Wait() + } + }) + } + } + } +} diff --git a/rchannel/rchannel.go b/rchannel/rchannel.go new file mode 100644 index 0000000000..81b259f84a --- /dev/null +++ b/rchannel/rchannel.go @@ -0,0 +1,67 @@ +package rchannel + +import ( + "context" + "sync/atomic" + + "github.com/AnomalyFi/hypersdk/smap" +) + +type RChannel[V any] struct { + keys *smap.SMap[V] + pending chan string + done chan struct{} + err error + + skips int + maxBacklog int32 + backlog atomic.Int32 +} + +func New[V any](backlog int) *RChannel[V] { + return &RChannel[V]{ + pending: make(chan string, backlog), + keys: smap.New[V](backlog), + done: make(chan struct{}), + } +} + +func (r *RChannel[V]) SetCallback(c func(context.Context, string, V) error) { + go func() { + defer close(r.done) + + for k := range r.pending { + r.backlog.Add(-1) + v, ok := r.keys.GetAndDelete(k) + if !ok { + // Already handled + r.skips++ + continue + } + + // Skip if we already errored (keep dequeing to prevent stall) + if r.err != nil { + continue + } + + // Record error if was unsuccessful + if err := c(context.TODO(), k, v); err != nil { + r.err = err + } + } + }() +} + +func (r *RChannel[V]) Add(key string, val V) { + if l := r.backlog.Add(1); l > r.maxBacklog { + r.maxBacklog = l + } + r.keys.Put(key, val) + r.pending <- key +} + +func (r *RChannel[V]) Wait() (int, int, error) { + close(r.pending) + <-r.done + return r.skips, int(r.maxBacklog), r.err +} diff --git a/rpc/dependencies.go b/rpc/dependencies.go index 19602b3cd1..02bd61386c 100644 --- a/rpc/dependencies.go +++ b/rpc/dependencies.go @@ -30,6 +30,7 @@ type VM interface { LastAcceptedBlock() *chain.StatelessBlock LastL1Head() int64 UnitPrices(context.Context) (fees.Dimensions, error) + IterateCurrentValidators(context.Context, func(ids.NodeID, *validators.GetValidatorOutput)) error CurrentValidators( context.Context, ) (map[ids.NodeID]*validators.GetValidatorOutput, map[string]struct{}) @@ -38,4 +39,13 @@ type VM interface { GetDiskBlockResults(ctx context.Context, height uint64) ([]*chain.Result, error) GetDiskFeeManager(ctx context.Context, height uint64) ([]byte, error) GetVerifyAuth() bool + + GetAuthRPCCores() int + GetAuthRPCBacklog() int + RecordRPCTxBacklog(int64) + AddRPCAuthorized(tx *chain.Transaction) + StopChan() chan struct{} + + RecordWebsocketConnection(int) + RecordRPCTxInvalid() } diff --git a/smap/smap.go b/smap/smap.go new file mode 100644 index 0000000000..2bbda34770 --- /dev/null +++ b/smap/smap.go @@ -0,0 +1,86 @@ +package smap + +import ( + "runtime" + "sync" + + "github.com/zeebo/xxh3" +) + +var shardCount = runtime.NumCPU() * 16 + +type SMap[V any] struct { + count uint64 // less coversions with [xxh3.HashString] + shards []*shard[V] +} + +func New[V any](initial int) *SMap[V] { + m := &SMap[V]{ + count: uint64(shardCount), + shards: make([]*shard[V], shardCount), + } + for i := 0; i < shardCount; i++ { + m.shards[i] = &shard[V]{data: make(map[string]V, max(16, initial/shardCount))} + } + return m +} + +type shard[V any] struct { + l sync.RWMutex + data map[string]V +} + +func (m *SMap[V]) Put(key string, value V) { + h := xxh3.HashString(key) + shard := m.shards[h%m.count] + + shard.l.Lock() + defer shard.l.Unlock() + shard.data[key] = value +} + +func (m *SMap[V]) Get(key string) (V, bool) { + h := xxh3.HashString(key) + shard := m.shards[h%m.count] + + shard.l.RLock() + defer shard.l.RUnlock() + value, ok := shard.data[key] + return value, ok +} + +func (m *SMap[V]) GetAndDelete(key string) (V, bool) { + h := xxh3.HashString(key) + shard := m.shards[h%m.count] + + shard.l.Lock() + defer shard.l.Unlock() + value, ok := shard.data[key] + if ok { + delete(shard.data, key) + } + return value, ok +} + +func (m *SMap[V]) Delete(key string) { + h := xxh3.HashString(key) + shard := m.shards[h%m.count] + + shard.l.Lock() + defer shard.l.Unlock() + delete(shard.data, key) +} + +func (m *SMap[V]) Iterate(f func(k string, v V) bool) { + for i := 0; i < int(m.count); i++ { + shard := m.shards[i] + shard.l.RLock() + for k, v := range shard.data { + if !f(k, v) { + shard.l.RUnlock() + return + } + } + shard.l.RUnlock() + } +} diff --git a/state/simple.go b/state/simple.go deleted file mode 100644 index aeb9ea49e3..0000000000 --- a/state/simple.go +++ /dev/null @@ -1,52 +0,0 @@ -// Copyright (C) 2023, Ava Labs, Inc. All rights reserved. -// See the file LICENSE for licensing terms. - -package state - -import ( - "context" - - "github.com/ava-labs/avalanchego/database" - "github.com/ava-labs/avalanchego/utils/maybe" - "github.com/ava-labs/avalanchego/x/merkledb" -) - -var _ Mutable = (*SimpleMutable)(nil) - -type SimpleMutable struct { - v View - - changes map[string]maybe.Maybe[[]byte] -} - -func NewSimpleMutable(v View) *SimpleMutable { - return &SimpleMutable{v, make(map[string]maybe.Maybe[[]byte])} -} - -func (s *SimpleMutable) GetValue(ctx context.Context, k []byte) ([]byte, error) { - if v, ok := s.changes[string(k)]; ok { - if v.IsNothing() { - return nil, database.ErrNotFound - } - return v.Value(), nil - } - return s.v.GetValue(ctx, k) -} - -func (s *SimpleMutable) Insert(_ context.Context, k []byte, v []byte) error { - s.changes[string(k)] = maybe.Some(v) - return nil -} - -func (s *SimpleMutable) Remove(_ context.Context, k []byte) error { - s.changes[string(k)] = maybe.Nothing[[]byte]() - return nil -} - -func (s *SimpleMutable) Commit(ctx context.Context) error { - view, err := s.v.NewView(ctx, merkledb.ViewChanges{MapOps: s.changes}) - if err != nil { - return err - } - return view.CommitToDB(ctx) -} diff --git a/state/state.go b/state/state.go index ec87cb691c..2b1066dcee 100644 --- a/state/state.go +++ b/state/state.go @@ -5,9 +5,6 @@ package state import ( "context" - - "github.com/ava-labs/avalanchego/ids" - "github.com/ava-labs/avalanchego/x/merkledb" ) type Immutable interface { @@ -20,10 +17,3 @@ type Mutable interface { Insert(ctx context.Context, key []byte, value []byte) error Remove(ctx context.Context, key []byte) error } - -type View interface { - Immutable - - NewView(ctx context.Context, changes merkledb.ViewChanges) (merkledb.View, error) - GetMerkleRoot(ctx context.Context) (ids.ID, error) -} diff --git a/storage/consts.go b/storage/consts.go index ccd4d594ff..d72ff0213f 100644 --- a/storage/consts.go +++ b/storage/consts.go @@ -4,7 +4,8 @@ package storage const ( - block = "blockdb" + vm = "vmdb" + blob = "blobdb" state = "statedb" metadata = "metadatadb" ) diff --git a/storage/storage.go b/storage/storage.go index 331e388e10..de8f4d5fdf 100644 --- a/storage/storage.go +++ b/storage/storage.go @@ -6,54 +6,64 @@ package storage import ( "github.com/ava-labs/avalanchego/api/metrics" "github.com/ava-labs/avalanchego/database" - "github.com/ava-labs/avalanchego/database/corruptabledb" + "github.com/ava-labs/avalanchego/utils/logging" + "github.com/AnomalyFi/hypersdk/filedb" "github.com/AnomalyFi/hypersdk/pebble" "github.com/AnomalyFi/hypersdk/utils" + "github.com/AnomalyFi/hypersdk/vilmo" ) // TODO: add option to use a single DB with prefixes to allow for atomic writes -func New(chainDataDir string, gatherer metrics.MultiGatherer) (database.Database, database.Database, database.Database, error) { +// TODO: should filedb and blobdb wrapped with database.Database interface? +// compaction is not necessary in blobdb and filebd, handles compaction in its own way. +func New(chainDataDir string, logger logging.Logger, gatherer metrics.MultiGatherer) (database.Database, *filedb.FileDB, *vilmo.Vilmo, database.Database, error) { // TODO: tune Pebble config based on each sub-db focus cfg := pebble.NewDefaultConfig() - blockPath, err := utils.InitSubDirectory(chainDataDir, block) + vmPath, err := utils.InitSubDirectory(chainDataDir, vm) if err != nil { - return nil, nil, nil, err + return nil, nil, nil, nil, err } - blockDB, blockDBRegistry, err := pebble.New(blockPath, cfg) + vmDB, vmDBRegistry, err := pebble.New(vmPath, cfg) if err != nil { - return nil, nil, nil, err + return nil, nil, nil, nil, err } + if gatherer != nil { - if err := gatherer.Register(block, blockDBRegistry); err != nil { - return nil, nil, nil, err + if err := gatherer.Register(vm, vmDBRegistry); err != nil { + return nil, nil, nil, nil, err } } - statePath, err := utils.InitSubDirectory(chainDataDir, state) + + blobPath, err := utils.InitSubDirectory(chainDataDir, blob) if err != nil { - return nil, nil, nil, err + return nil, nil, nil, nil, err } - stateDB, stateDBRegistry, err := pebble.New(statePath, cfg) + blobDBCfg := filedb.NewDefaultConfig() + blobDB := filedb.New(blobPath, blobDBCfg) + + statePath, err := utils.InitSubDirectory(chainDataDir, state) if err != nil { - return nil, nil, nil, err + return nil, nil, nil, nil, err } - if gatherer != nil { - if err := gatherer.Register(state, stateDBRegistry); err != nil { - return nil, nil, nil, err - } + stateCfg := vilmo.NewDefaultConfig() + stateDB, _, err := vilmo.New(logger, statePath, stateCfg) + if err != nil { + return nil, nil, nil, nil, err } + metaPath, err := utils.InitSubDirectory(chainDataDir, metadata) if err != nil { - return nil, nil, nil, err + return nil, nil, nil, nil, err } metaDB, metaDBRegistry, err := pebble.New(metaPath, cfg) if err != nil { - return nil, nil, nil, err + return nil, nil, nil, nil, err } if gatherer != nil { if err := gatherer.Register(metadata, metaDBRegistry); err != nil { - return nil, nil, nil, err + return nil, nil, nil, nil, err } } - return corruptabledb.New(blockDB), corruptabledb.New(stateDB), corruptabledb.New(metaDB), nil + return vmDB, blobDB, stateDB, metaDB, nil } diff --git a/tstate/tstate.go b/tstate/tstate.go index e13f287527..3109008ec3 100644 --- a/tstate/tstate.go +++ b/tstate/tstate.go @@ -5,78 +5,95 @@ package tstate import ( "context" - "sync" - - "github.com/ava-labs/avalanchego/trace" - "github.com/ava-labs/avalanchego/utils/maybe" - "github.com/ava-labs/avalanchego/x/merkledb" - "go.opentelemetry.io/otel/attribute" + "github.com/AnomalyFi/hypersdk/smap" "github.com/AnomalyFi/hypersdk/state" - oteltrace "go.opentelemetry.io/otel/trace" + "github.com/ava-labs/avalanchego/utils/maybe" ) +type change struct { + chunkIdx int + txIdx int + v maybe.Maybe[[]byte] +} + // TState defines a struct for storing temporary state. type TState struct { - l sync.RWMutex - ops int - changedKeys map[string]maybe.Maybe[[]byte] + viewKeys [][][]string + changedKeys *smap.SMap[*change] } // New returns a new instance of TState. Initializes the storage and changedKeys // maps to have an initial size of [storageSize] and [changedSize] respectively. func New(changedSize int) *TState { return &TState{ - changedKeys: make(map[string]maybe.Maybe[[]byte], changedSize), + viewKeys: make([][][]string, 1024), // set to max chunks that could ever be in a single block + changedKeys: smap.New[*change](changedSize), } } -func (ts *TState) getChangedValue(_ context.Context, key string) ([]byte, bool, bool) { - ts.l.RLock() - defer ts.l.RUnlock() - - if v, ok := ts.changedKeys[key]; ok { - if v.IsNothing() { +func (ts *TState) getChangedValue(_ context.Context, key []byte) ([]byte, bool, bool) { + if v, ok := ts.changedKeys.Get(string(key)); ok { + if v.v.IsNothing() { return nil, true, false } - return v.Value(), true, true + return v.v.Value(), true, true } return nil, false, false } -func (ts *TState) PendingChanges() int { - ts.l.RLock() - defer ts.l.RUnlock() - - return len(ts.changedKeys) +func (ts *TState) PrepareChunk(idx, size int) { + ts.viewKeys[idx] = make([][]string, size) } -// OpIndex returns the number of operations done on ts. -func (ts *TState) OpIndex() int { - ts.l.RLock() - defer ts.l.RUnlock() - - return ts.ops +// Iterate over changes in deterministic order +// +// Iterate should only be called once tstate is done being modified. +func (ts *TState) Iterate(f func([]byte, maybe.Maybe[[]byte]) error) error { + // TODO: make naming more generic + for chunkIdx, txs := range ts.viewKeys { + if txs == nil { + // Once we run out of views, exit + break + } + for txIdx, keys := range txs { + // Skip invalid txs + if keys == nil { + continue + } + + // Ensure we iterate deterministically + for _, key := range keys { + v, ok := ts.changedKeys.Get(key) + if !ok { + continue + } + if v.chunkIdx != chunkIdx || v.txIdx != txIdx { + // If we weren't the latest modification, skip + continue + } + if err := f([]byte(key), v.v); err != nil { + return err + } + } + } + } + return nil } -// ExportMerkleDBView creates a slice of [database.BatchOp] of all -// changes in [TState] that can be used to commit to [merkledb]. -func (ts *TState) ExportMerkleDBView( - ctx context.Context, - t trace.Tracer, //nolint:interfacer - view state.View, -) (merkledb.View, error) { - ts.l.RLock() - defer ts.l.RUnlock() +// Persist changes to the provided batch from the Tstate. +// call prepare on the batch, before calling this function. +// This function will iterate over all the changes in the Tstate and apply them to the batch. +// After persisiting changes, call write on the batch to write the changes to the underlying storage. +func (ts *TState) PersistChanges(ctx context.Context, batch state.Mutable) error { + return ts.Iterate(func(key []byte, value maybe.Maybe[[]byte]) error { + if value.IsNothing() { - ctx, span := t.Start( - ctx, "TState.ExportMerkleDBView", - oteltrace.WithAttributes( - attribute.Int("items", len(ts.changedKeys)), - ), - ) - defer span.End() + return batch.Remove(ctx, key) + } else { - return view.NewView(ctx, merkledb.ViewChanges{MapOps: ts.changedKeys, ConsumeBytes: true}) + return batch.Insert(ctx, key, value.Value()) + } + }) } diff --git a/tstate/tstate_test.go b/tstate/tstate_test.go index 6d9e8298e6..e7349be230 100644 --- a/tstate/tstate_test.go +++ b/tstate/tstate_test.go @@ -9,15 +9,15 @@ import ( "testing" "github.com/ava-labs/avalanchego/database" - "github.com/ava-labs/avalanchego/database/memdb" + "github.com/ava-labs/avalanchego/ids" + "github.com/ava-labs/avalanchego/utils/logging" "github.com/ava-labs/avalanchego/utils/maybe" "github.com/ava-labs/avalanchego/utils/units" - "github.com/ava-labs/avalanchego/x/merkledb" "github.com/stretchr/testify/require" "github.com/AnomalyFi/hypersdk/keys" "github.com/AnomalyFi/hypersdk/state" - "github.com/AnomalyFi/hypersdk/trace" + "github.com/AnomalyFi/hypersdk/vilmo" ) var ( @@ -64,9 +64,9 @@ func TestScope(t *testing.T) { require := require.New(t) ctx := context.TODO() ts := New(10) - + db := NewTestDB() // No Scope - tsv := ts.NewView(state.Keys{}, map[string][]byte{}) + tsv := ts.NewView(1, 1, db, state.Keys{}) val, err := tsv.GetValue(ctx, testKey) require.ErrorIs(ErrInvalidKeyOrPermission, err) require.Nil(val) @@ -78,9 +78,11 @@ func TestGetValue(t *testing.T) { require := require.New(t) ctx := context.TODO() ts := New(10) - + db := NewTestDB() + db.Insert(ctx, testKey, testVal) + sk := state.Keys{string(testKey): state.Read | state.Write} // Set Scope - tsv := ts.NewView(state.Keys{string(testKey): state.Read | state.Write}, map[string][]byte{string(testKey): testVal}) + tsv := ts.NewView(1, 1, db, sk) val, err := tsv.GetValue(ctx, testKey) require.NoError(err, "unable to get value") require.Equal(testVal, val, "value was not saved correctly") @@ -90,14 +92,16 @@ func TestDeleteCommitGet(t *testing.T) { require := require.New(t) ctx := context.TODO() ts := New(10) - + db := NewTestDB() + db.Insert(ctx, testKey, testVal) + ts.PrepareChunk(1, 2) // Delete value - tsv := ts.NewView(state.Keys{string(testKey): state.Read | state.Write}, map[string][]byte{string(testKey): testVal}) + tsv := ts.NewView(1, 1, db, state.Keys{string(testKey): state.Read | state.Write}) require.NoError(tsv.Remove(ctx, testKey)) tsv.Commit() // Check deleted - tsv = ts.NewView(state.Keys{string(testKey): state.Read | state.Write}, map[string][]byte{string(testKey): testVal}) + tsv = ts.NewView(1, 1, db, state.Keys{string(testKey): state.Read | state.Write}) val, err := tsv.GetValue(ctx, testKey) require.ErrorIs(err, database.ErrNotFound) require.Nil(val) @@ -107,9 +111,10 @@ func TestGetValueNoStorage(t *testing.T) { require := require.New(t) ctx := context.TODO() ts := New(10) - + db := NewTestDB() + ts.PrepareChunk(1, 2) // SetScope but dont add to storage - tsv := ts.NewView(state.Keys{string(testKey): state.Read | state.Write}, map[string][]byte{}) + tsv := ts.NewView(1, 1, db, state.Keys{string(testKey): state.Read | state.Write}) _, err := tsv.GetValue(ctx, testKey) require.ErrorIs(database.ErrNotFound, err, "data should not exist") } @@ -118,9 +123,10 @@ func TestInsertNew(t *testing.T) { require := require.New(t) ctx := context.TODO() ts := New(10) - + db := NewTestDB() + ts.PrepareChunk(1, 2) // SetScope - tsv := ts.NewView(state.Keys{string(testKey): state.All}, map[string][]byte{}) + tsv := ts.NewView(1, 1, db, state.Keys{string(testKey): state.All}) // Insert key require.NoError(tsv.Insert(ctx, testKey, testVal)) @@ -128,20 +134,20 @@ func TestInsertNew(t *testing.T) { require.NoError(err) require.Equal(1, tsv.OpIndex(), "insert was not added as an operation") require.Equal(testVal, val, "value was not set correctly") - // Check commit tsv.Commit() - require.Equal(1, ts.OpIndex(), "insert was not added as an operation") + require.Equal(1, tsv.OpIndex(), "insert was not added as an operation") } func TestInsertInvalid(t *testing.T) { require := require.New(t) ctx := context.TODO() ts := New(10) - + db := NewTestDB() + ts.PrepareChunk(1, 2) // SetScope key := binary.BigEndian.AppendUint16([]byte("hello"), 0) - tsv := ts.NewView(state.Keys{string(key): state.Read | state.Write}, map[string][]byte{}) + tsv := ts.NewView(1, 1, db, state.Keys{string(key): state.Read | state.Write}) // Insert key require.ErrorIs(tsv.Insert(ctx, key, []byte("cool")), ErrInvalidKeyValue) @@ -155,10 +161,12 @@ func TestInsertUpdate(t *testing.T) { require := require.New(t) ctx := context.TODO() ts := New(10) - + db := NewTestDB() + db.Insert(ctx, testKey, testVal) + ts.PrepareChunk(1, 2) // SetScope and add - tsv := ts.NewView(state.Keys{string(testKey): state.Read | state.Write}, map[string][]byte{string(testKey): testVal}) - require.Equal(0, ts.OpIndex()) + tsv := ts.NewView(1, 1, db, state.Keys{string(testKey): state.Read | state.Write}) + // require.Equal(0, ts.OpIndex()) // Insert key newVal := []byte("newVal") @@ -173,7 +181,7 @@ func TestInsertUpdate(t *testing.T) { // Check value after commit tsv.Commit() - tsv = ts.NewView(state.Keys{string(testKey): state.Read | state.Write}, map[string][]byte{string(testKey): testVal}) + tsv = ts.NewView(1, 1, db, state.Keys{string(testKey): state.Read | state.Write}) val, err = tsv.GetValue(ctx, testKey) require.NoError(err) require.Equal(newVal, val, "value was not committed correctly") @@ -183,10 +191,11 @@ func TestInsertRemoveInsert(t *testing.T) { require := require.New(t) ctx := context.TODO() ts := New(10) - + db := NewTestDB() + ts.PrepareChunk(1, 2) // SetScope and add - tsv := ts.NewView(state.Keys{key2str: state.All}, map[string][]byte{}) - require.Equal(0, ts.OpIndex()) + tsv := ts.NewView(1, 1, db, state.Keys{key2str: state.All}) + // require.Equal(0, ts.OpIndex()) // Insert key for first time require.NoError(tsv.Insert(ctx, key2, testVal)) @@ -255,10 +264,12 @@ func TestModifyRemoveInsert(t *testing.T) { require := require.New(t) ctx := context.TODO() ts := New(10) - + db := NewTestDB() + db.Insert(ctx, key2, testVal) + ts.PrepareChunk(1, 2) // SetScope and add - tsv := ts.NewView(state.Keys{key2str: state.All}, map[string][]byte{key2str: testVal}) - require.Equal(0, ts.OpIndex()) + tsv := ts.NewView(1, 1, db, state.Keys{key2str: state.All}) + // require.Equal(0, ts.OpIndex()) // Modify existing key testVal2 := []byte("blah") @@ -309,10 +320,12 @@ func TestModifyRevert(t *testing.T) { require := require.New(t) ctx := context.TODO() ts := New(10) - + db := NewTestDB() + db.Insert(ctx, key2, testVal) + ts.PrepareChunk(1, 2) // SetScope and add - tsv := ts.NewView(state.Keys{key2str: state.Read | state.Write}, map[string][]byte{key2str: testVal}) - require.Equal(0, ts.OpIndex()) + tsv := ts.NewView(1, 1, db, state.Keys{key2str: state.Read | state.Write}) + // require.Equal(0, ts.OpIndex()) // Modify existing key testVal2 := []byte("blah") @@ -349,10 +362,12 @@ func TestModifyModify(t *testing.T) { require := require.New(t) ctx := context.TODO() ts := New(10) - + db := NewTestDB() + db.Insert(ctx, key2, testVal) + ts.PrepareChunk(1, 2) // SetScope and add - tsv := ts.NewView(state.Keys{key2str: state.Read | state.Write}, map[string][]byte{key2str: testVal}) - require.Equal(0, ts.OpIndex()) + tsv := ts.NewView(1, 1, db, state.Keys{key2str: state.Read | state.Write}) + // require.Equal(0, ts.OpIndex()) // Modify existing key testVal2 := []byte("blah") @@ -396,9 +411,10 @@ func TestRemoveInsertRollback(t *testing.T) { require := require.New(t) ts := New(10) ctx := context.TODO() - + db := NewTestDB() + ts.PrepareChunk(1, 2) // Insert - tsv := ts.NewView(state.Keys{string(testKey): state.All}, map[string][]byte{}) + tsv := ts.NewView(1, 1, db, state.Keys{string(testKey): state.All}) require.NoError(tsv.Insert(ctx, testKey, testVal)) v, err := tsv.GetValue(ctx, testKey) require.NoError(err) @@ -435,6 +451,8 @@ func TestRestoreInsert(t *testing.T) { require := require.New(t) ts := New(10) ctx := context.TODO() + db := NewTestDB() + ts.PrepareChunk(1, 2) keys := [][]byte{key1, key2, key3} keySet := state.Keys{ key1str: state.All, @@ -444,7 +462,7 @@ func TestRestoreInsert(t *testing.T) { vals := [][]byte{[]byte("val1"), []byte("val2"), []byte("val3")} // Store keys - tsv := ts.NewView(keySet, map[string][]byte{}) + tsv := ts.NewView(1, 1, db, keySet) for i, key := range keys { require.NoError(tsv.Insert(ctx, key, vals[i])) } @@ -492,6 +510,8 @@ func TestRestoreDelete(t *testing.T) { require := require.New(t) ts := New(10) ctx := context.TODO() + db := NewTestDB() + ts.PrepareChunk(1, 2) keys := [][]byte{key1, key2, key3} keySet := state.Keys{ key1str: state.Read | state.Write, @@ -499,11 +519,10 @@ func TestRestoreDelete(t *testing.T) { key3str: state.Read | state.Write, } vals := [][]byte{[]byte("val1"), []byte("val2"), []byte("val3")} - tsv := ts.NewView(keySet, map[string][]byte{ - string(keys[0]): vals[0], - string(keys[1]): vals[1], - string(keys[2]): vals[2], - }) + for i, key := range keys { + require.NoError(db.Insert(ctx, key, vals[i])) + } + tsv := ts.NewView(1, 1, db, keySet) // Check scope for i, key := range keys { @@ -523,8 +542,8 @@ func TestRestoreDelete(t *testing.T) { // Roll back all removes tsv.Rollback(ctx, 0) - require.Equal(0, ts.OpIndex(), "operations not rolled back properly") - require.Equal(0, ts.PendingChanges()) + // require.Equal(0, ts.OpIndex(), "operations not rolled back properly") + // require.Equal(0, ts.changedKeys) for i, key := range keys { val, err := tsv.GetValue(ctx, key) require.NoError(err, "error getting value") @@ -534,21 +553,20 @@ func TestRestoreDelete(t *testing.T) { func TestCreateView(t *testing.T) { require := require.New(t) - + const ( + defaultInitialSize = 10_000_000 + defaultBufferSize = 64 * units.KiB + defaultHistoryLen = 100 + ) ctx := context.TODO() ts := New(10) - tracer, err := trace.New(&trace.Config{Enabled: false}) + ts.PrepareChunk(0, 1) + + baseDir := t.TempDir() + db, last, err := vilmo.New(logging.NoLog{}, baseDir, defaultInitialSize, 100_000, defaultBufferSize, 15) require.NoError(err) - db, err := merkledb.New(ctx, memdb.New(), merkledb.Config{ - BranchFactor: merkledb.BranchFactor16, - RootGenConcurrency: 1, - HistoryLength: 100, - ValueNodeCacheSize: units.MiB, - IntermediateNodeCacheSize: units.MiB, - IntermediateWriteBufferSize: units.KiB, - IntermediateWriteBatchSize: units.KiB, - Tracer: tracer, - }) + require.Equal(ids.Empty, last) + batch, err := db.NewBatch() require.NoError(err) keys := [][]byte{key1, key2, key3} keySet := state.Keys{ @@ -559,7 +577,7 @@ func TestCreateView(t *testing.T) { vals := [][]byte{[]byte("val1"), []byte("val2"), []byte("val3")} // Add - tsv := ts.NewView(keySet, map[string][]byte{}) + tsv := ts.NewView(0, 0, db, keySet) for i, key := range keys { require.NoError(tsv.Insert(ctx, key, vals[i]), "error inserting value") val, err := tsv.GetValue(ctx, key) @@ -576,30 +594,31 @@ func TestCreateView(t *testing.T) { require.Equal(writeMap, writes) // Test warm modification - tsvM := ts.NewView(keySet, map[string][]byte{}) + tsvM := ts.NewView(0, 0, db, keySet) require.NoError(tsvM.Insert(ctx, keys[0], vals[2])) allocates, writes = tsvM.KeyOperations() require.Empty(allocates) require.Equal(map[string]uint16{key1str: 1}, writes) - - // Create merkle view - view, err := ts.ExportMerkleDBView(ctx, tracer, db) - require.NoError(err, "error writing changes") - require.NoError(view.CommitToDB(ctx)) - + batch.Prepare() + require.NoError(ts.PersistChanges(ctx, batch)) + _, err = batch.Write() + require.NoError(err) + db, _, err = vilmo.New(logging.NoLog{}, baseDir, defaultInitialSize, 100_000, defaultBufferSize, 15) + require.NoError(err) // Check if db was updated correctly for i, key := range keys { val, _ := db.GetValue(ctx, key) require.Equal(vals[i], val, "value not updated in db") } - // Remove ts = New(10) - tsv = ts.NewView(keySet, map[string][]byte{ - string(keys[0]): vals[0], - string(keys[1]): vals[1], - string(keys[2]): vals[2], - }) + ts.PrepareChunk(0, 1) + db, _, err = vilmo.New(logging.NoLog{}, baseDir, defaultInitialSize, 100_000, defaultBufferSize, 15) + require.NoError(err) + + batch, err = db.NewBatch() + require.NoError(err) + tsv = ts.NewView(0, 0, db, keySet) for _, key := range keys { err := tsv.Remove(ctx, key) require.NoError(err, "error removing from ts") @@ -607,17 +626,16 @@ func TestCreateView(t *testing.T) { require.ErrorIs(err, database.ErrNotFound, "key not removed") } tsv.Commit() - - // Create merkle view - view, err = tsv.ts.ExportMerkleDBView(ctx, tracer, db) - require.NoError(err, "error writing changes") - require.NoError(view.CommitToDB(ctx)) - + batch.Prepare() + require.NoError(ts.PersistChanges(ctx, batch)) + _, err = batch.Write() + require.NoError(err) // Check if db was updated correctly for _, key := range keys { _, err := db.GetValue(ctx, key) require.ErrorIs(err, database.ErrNotFound, "value not removed from db") } + } func TestGetValuePermissions(t *testing.T) { @@ -658,7 +676,10 @@ func TestGetValuePermissions(t *testing.T) { require := require.New(t) ctx := context.TODO() ts := New(10) - tsv := ts.NewView(state.Keys{tt.key: tt.permission}, map[string][]byte{tt.key: testVal}) + db := NewTestDB() + db.Insert(ctx, []byte(tt.key), testVal) + ts.PrepareChunk(1, 2) + tsv := ts.NewView(1, 1, db, state.Keys{tt.key: tt.permission}) _, err := tsv.GetValue(ctx, []byte(tt.key)) require.ErrorIs(err, tt.expectedErr) }) @@ -703,7 +724,10 @@ func TestInsertPermissions(t *testing.T) { require := require.New(t) ctx := context.TODO() ts := New(10) - tsv := ts.NewView(state.Keys{tt.key: tt.permission}, map[string][]byte{tt.key: testVal}) + db := NewTestDB() + db.Insert(ctx, []byte(tt.key), testVal) + ts.PrepareChunk(1, 2) + tsv := ts.NewView(1, 1, db, state.Keys{tt.key: tt.permission}) err := tsv.Insert(ctx, []byte(tt.key), []byte("val")) require.ErrorIs(err, tt.expectedErr) }) @@ -748,7 +772,10 @@ func TestDeletePermissions(t *testing.T) { require := require.New(t) ctx := context.TODO() ts := New(10) - tsv := ts.NewView(state.Keys{tt.key: tt.permission}, map[string][]byte{tt.key: testVal}) + db := NewTestDB() + db.Insert(ctx, []byte(tt.key), testVal) + ts.PrepareChunk(1, 2) + tsv := ts.NewView(1, 1, db, state.Keys{tt.key: tt.permission}) err := tsv.Remove(ctx, []byte(tt.key)) require.ErrorIs(err, tt.expectedErr) }) @@ -803,9 +830,11 @@ func TestUpdatingKeyPermission(t *testing.T) { require := require.New(t) ctx := context.TODO() ts := New(10) - + db := NewTestDB() + db.Insert(ctx, []byte(tt.key), testVal) + ts.PrepareChunk(1, 2) keys := state.Keys{tt.key: tt.permission1} - tsv := ts.NewView(keys, map[string][]byte{tt.key: testVal}) + tsv := ts.NewView(1, 1, db, keys) // Check permissions perm := keys[tt.key] @@ -893,13 +922,15 @@ func TestInsertAllocate(t *testing.T) { require := require.New(t) ctx := context.TODO() ts := New(10) - + db := NewTestDB() + ts.PrepareChunk(1, 2) keys := state.Keys{tt.key: tt.permission} var tsv *TStateView if tt.keyExists { - tsv = ts.NewView(keys, map[string][]byte{tt.key: testVal}) + db.Insert(ctx, []byte(tt.key), testVal) + tsv = ts.NewView(1, 1, db, keys) } else { - tsv = ts.NewView(keys, map[string][]byte{}) + tsv = ts.NewView(1, 1, db, keys) } // Try to update key diff --git a/tstate/tstate_view.go b/tstate/tstate_view.go index 360533d8a4..f9edc1b3ba 100644 --- a/tstate/tstate_view.go +++ b/tstate/tstate_view.go @@ -6,12 +6,12 @@ package tstate import ( "bytes" "context" - - "github.com/ava-labs/avalanchego/database" - "github.com/ava-labs/avalanchego/utils/maybe" + "slices" "github.com/AnomalyFi/hypersdk/keys" "github.com/AnomalyFi/hypersdk/state" + "github.com/ava-labs/avalanchego/database" + "github.com/ava-labs/avalanchego/utils/maybe" ) const defaultOps = 4 @@ -33,7 +33,11 @@ type op struct { } type TStateView struct { - ts *TState + ts *TState + + chunkIdx int + txIdx int + pendingChangedKeys map[string]maybe.Maybe[[]byte] // Ops is a record of all operations performed on [TState]. Tracking @@ -44,23 +48,27 @@ type TStateView struct { // TODO: Need to handle read-only/write-only keys differently (won't prefetch a write // key, see issue below) // https://github.com/AnomalyFi/hypersdk/issues/709 - scope state.Keys - scopeStorage map[string][]byte + scope state.Keys + im state.Immutable // Store which keys are modified and how large their values were. allocates map[string]uint16 writes map[string]uint16 } -func (ts *TState) NewView(scope state.Keys, storage map[string][]byte) *TStateView { +func (ts *TState) NewView(chunkIdx, txIdx int, im state.Immutable, scope state.Keys) *TStateView { return &TStateView{ - ts: ts, + ts: ts, + + chunkIdx: chunkIdx, + txIdx: txIdx, + pendingChangedKeys: make(map[string]maybe.Maybe[[]byte], len(scope)), ops: make([]*op, 0, defaultOps), - scope: scope, - scopeStorage: storage, + scope: scope, + im: im, allocates: make(map[string]uint16, len(scope)), writes: make(map[string]uint16, len(scope)), @@ -138,7 +146,8 @@ func (ts *TStateView) OpIndex() int { // operation will be returned here (if 1 chunk then 2 chunks are written to a key, // this function will return 2 chunks). // -// TODO: this function is no longer used but could be a useful metric +// TODO: add a metric for tracking the difference between requested allocate/write +// vs used allocate/write. func (ts *TStateView) KeyOperations() (map[string]uint16, map[string]uint16) { return ts.allocates, ts.writes } @@ -148,7 +157,7 @@ func (ts *TStateView) checkScope(_ context.Context, k []byte, perm state.Permiss return ts.scope[string(k)].Has(perm) } -// GetValue returns the value associated from tempStorage with the +// Get returns the value associated from tempStorage with the // associated [key]. If [key] does not exist in scope, or is not read/rw, or if it is not found // in storage an error is returned. func (ts *TStateView) GetValue(ctx context.Context, key []byte) ([]byte, error) { @@ -156,16 +165,15 @@ func (ts *TStateView) GetValue(ctx context.Context, key []byte) ([]byte, error) if !ts.checkScope(ctx, key, state.Read) { return nil, ErrInvalidKeyOrPermission } - k := string(key) - v, exists := ts.getValue(ctx, k) + v, exists := ts.getValue(ctx, key) if !exists { return nil, database.ErrNotFound } return v, nil } -func (ts *TStateView) getValue(ctx context.Context, key string) ([]byte, bool) { - if v, ok := ts.pendingChangedKeys[key]; ok { +func (ts *TStateView) getValue(ctx context.Context, key []byte) ([]byte, bool) { + if v, ok := ts.pendingChangedKeys[string(key)]; ok { if v.IsNothing() { return nil, false } @@ -174,7 +182,7 @@ func (ts *TStateView) getValue(ctx context.Context, key string) ([]byte, bool) { if v, changed, exists := ts.ts.getChangedValue(ctx, key); changed { return v, exists } - if v, ok := ts.scopeStorage[key]; ok { + if v, err := ts.im.GetValue(ctx, key); err == nil { return v, true } return nil, false @@ -182,20 +190,20 @@ func (ts *TStateView) getValue(ctx context.Context, key string) ([]byte, bool) { // isUnchanged determines if a [key] is unchanged from the parent view (or // scope if the parent is unchanged). -func (ts *TStateView) isUnchanged(ctx context.Context, key string, nval []byte, nexists bool) bool { +func (ts *TStateView) isUnchanged(ctx context.Context, key []byte, nval []byte, nexists bool) bool { if v, changed, exists := ts.ts.getChangedValue(ctx, key); changed { return !exists && !nexists || exists && nexists && bytes.Equal(v, nval) } - if v, ok := ts.scopeStorage[key]; ok { + if v, err := ts.im.GetValue(ctx, key); err == nil { return nexists && bytes.Equal(v, nval) } return !nexists } -// Insert allocates and writes (or just writes) a new key to [tstate]. If this +// Put allocates and writes (or just writes) a new key to [tstate]. If this // action returns the value of [key] to the parent view, it reverts any pending changes. func (ts *TStateView) Insert(ctx context.Context, key []byte, value []byte) error { - // Inserting requires a Write Permissions, so we pass state.Write + // Puting requires a Write Permissions, so we pass state.Write if !ts.checkScope(ctx, key, state.Write) { return ErrInvalidKeyOrPermission } @@ -203,10 +211,8 @@ func (ts *TStateView) Insert(ctx context.Context, key []byte, value []byte) erro return ErrInvalidKeyValue } valueChunks, _ := keys.NumChunks(value) // not possible to fail + past, exists := ts.getValue(ctx, key) k := string(key) - // Invariant: [getValue] is safe to call here because with [state.Write], it - // will provide Read and Write access to the state - past, exists := ts.getValue(ctx, k) op := &op{ k: k, pastV: past, @@ -221,10 +227,6 @@ func (ts *TStateView) Insert(ctx context.Context, key []byte, value []byte) erro op.t = insertOp ts.writes[k] = valueChunks // set to latest value } else { - // New entry requires Allocate - // TODO: we assume any allocate is a write too, but we should - // make this invariant more clear. Do we require Write, - // Allocate|Write, and never Allocate alone? if !ts.checkScope(ctx, key, state.Allocate) { return ErrInvalidKeyOrPermission } @@ -235,7 +237,7 @@ func (ts *TStateView) Insert(ctx context.Context, key []byte, value []byte) erro } ts.ops = append(ts.ops, op) ts.pendingChangedKeys[k] = maybe.Some(value) - if ts.isUnchanged(ctx, k, value, true) { + if ts.isUnchanged(ctx, key, value, true) { delete(ts.allocates, k) delete(ts.writes, k) delete(ts.pendingChangedKeys, k) @@ -243,7 +245,7 @@ func (ts *TStateView) Insert(ctx context.Context, key []byte, value []byte) erro return nil } -// Remove deletes a key from [tstate]. If this action returns the +// Delete deletes a key from [tstate]. If this action returns the // value of [key] to the parent view, it reverts any pending changes. func (ts *TStateView) Remove(ctx context.Context, key []byte) error { // Removing requires writing & deleting that key, so we pass state.Write @@ -251,7 +253,7 @@ func (ts *TStateView) Remove(ctx context.Context, key []byte) error { return ErrInvalidKeyOrPermission } k := string(key) - past, exists := ts.getValue(ctx, k) + past, exists := ts.getValue(ctx, key) if !exists { // We do not update writes if the key does not exist. return nil @@ -275,7 +277,7 @@ func (ts *TStateView) Remove(ctx context.Context, key []byte) error { ts.writes[k] = 0 ts.pendingChangedKeys[k] = maybe.Nothing[[]byte]() } - if ts.isUnchanged(ctx, k, nil, false) { + if ts.isUnchanged(ctx, key, nil, false) { delete(ts.allocates, k) delete(ts.writes, k) delete(ts.pendingChangedKeys, k) @@ -290,13 +292,13 @@ func (ts *TStateView) PendingChanges() int { // Commit adds all pending changes to the parent view. func (ts *TStateView) Commit() { - ts.ts.l.Lock() - defer ts.ts.l.Unlock() - + changedKeys := make([]string, 0, len(ts.pendingChangedKeys)) for k, v := range ts.pendingChangedKeys { - ts.ts.changedKeys[k] = v + ts.ts.changedKeys.Put(k, &change{ts.chunkIdx, ts.txIdx, v}) + changedKeys = append(changedKeys, k) } - ts.ts.ops += len(ts.ops) + slices.Sort(changedKeys) + ts.ts.viewKeys[ts.chunkIdx][ts.txIdx] = changedKeys } // chunks gets the number of chunks for a key in [m] @@ -308,3 +310,41 @@ func chunks(m map[string]uint16, key string) *uint16 { } return &chunks } + +type TStateWriteView struct { + ts *TState + + chunkIdx int + txIdx int + + pendingChangedKeys map[string]maybe.Maybe[[]byte] +} + +func (ts *TState) NewWriteView(chunkIdx, txIdx int) *TStateWriteView { + return &TStateWriteView{ + ts: ts, + + chunkIdx: chunkIdx, + txIdx: txIdx, + + pendingChangedKeys: make(map[string]maybe.Maybe[[]byte]), + } +} + +func (ts *TStateWriteView) Put(ctx context.Context, key []byte, value []byte) error { + if !keys.VerifyValue(key, value) { + return ErrInvalidKeyValue + } + ts.pendingChangedKeys[string(key)] = maybe.Some(value) + return nil +} + +func (tsv *TStateWriteView) Commit() { + changedKeys := make([]string, 0, len(tsv.pendingChangedKeys)) + for k, v := range tsv.pendingChangedKeys { + tsv.ts.changedKeys.Put(k, &change{tsv.chunkIdx, tsv.txIdx, v}) + changedKeys = append(changedKeys, k) + } + slices.Sort(changedKeys) + tsv.ts.viewKeys[tsv.chunkIdx][tsv.txIdx] = changedKeys +} diff --git a/utils/utils.go b/utils/utils.go index ac6d9fdbf1..d8179c4214 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -5,18 +5,26 @@ package utils import ( "fmt" + "io/fs" "math" "net" "net/url" "os" "path" + "path/filepath" "strconv" "time" "github.com/ava-labs/avalanchego/ids" + "github.com/ava-labs/avalanchego/snow/validators" + "github.com/ava-labs/avalanchego/utils" + "github.com/ava-labs/avalanchego/utils/crypto/bls" "github.com/ava-labs/avalanchego/utils/hashing" + safemath "github.com/ava-labs/avalanchego/utils/math" "github.com/ava-labs/avalanchego/utils/perms" + "github.com/ava-labs/avalanchego/vms/platformvm/warp" "github.com/onsi/ginkgo/v2/formatter" + "golang.org/x/exp/maps" "github.com/AnomalyFi/hypersdk/consts" ) @@ -85,6 +93,24 @@ func Repeat[T any](v T, n int) []T { return arr } +// SaveBytes writes [b] to a file [filename]. If filename does +// not exist, it creates a new file with read/write permissions (0o600). +func SaveBytes(filename string, b []byte) error { + return os.WriteFile(filename, b, 0o600) +} + +// LoadBytes returns bytes stored at a file [filename]. +func LoadBytes(filename string, expectedSize int) ([]byte, error) { + bytes, err := os.ReadFile(filename) + if err != nil { + return nil, err + } + if expectedSize != -1 && len(bytes) != expectedSize { + return nil, ErrInvalidSize + } + return bytes, nil +} + // UnixRMilli returns the current unix time in milliseconds, rounded // down to the nearsest second. // @@ -100,20 +126,86 @@ func UnixRMilli(now, add int64) int64 { return t - t%consts.MillisecondsPerSecond } -// SaveBytes writes [b] to a file [filename]. If filename does -// not exist, it creates a new file with read/write permissions (0o600). -func SaveBytes(filename string, b []byte) error { - return os.WriteFile(filename, b, 0o600) +func RoundUint64(x, r uint64) uint64 { + return x + -x%r } -// LoadBytes returns bytes stored at a file [filename]. -func LoadBytes(filename string, expectedSize int) ([]byte, error) { - bytes, err := os.ReadFile(filename) - if err != nil { - return nil, err +// UnixRDeci returns the current unix time in milliseconds, rounded +// down to the nearsest decisecond. +// +// [now] is used as the current unix time in milliseconds if >= 0. +// +// [add] (in ms) is added to the unix time before it is rounded (typically +// used when generating an expiry time with a validity window). +func UnixRDeci(now, add int64) int64 { + if now < 0 { + now = time.Now().UnixMilli() } - if expectedSize != -1 && len(bytes) != expectedSize { - return nil, ErrInvalidSize + t := now + add + return t - t%consts.MillisecondsPerDecisecond +} + +// Both [t] and [epochLength] are in milliseconds. +func Epoch(t, epochLength int64) uint64 { + return uint64(t / epochLength) +} + +// ConstructCanonicalValidatorSet constructs the validator set order to use +// for address partitioning and warp validation. +// +// Source: https://github.com/ava-labs/avalanchego/blob/813bd481c764970b5c47c3ae9c0a40f2c28da8e4/vms/platformvm/warp/validator.go#L61-L92 +func ConstructCanonicalValidatorSet(vdrSet map[ids.NodeID]*validators.GetValidatorOutput) ([]ids.NodeID, []*warp.Validator, uint64, error) { + var ( + vdrs = make(map[string]*warp.Validator, len(vdrSet)) + paritionVdrs = make([]ids.NodeID, 0, len(vdrSet)) + totalWeight uint64 + err error + ) + for _, vdr := range vdrSet { + totalWeight, err = safemath.Add64(totalWeight, vdr.Weight) + if err != nil { + return nil, nil, 0, err + } + paritionVdrs = append(paritionVdrs, vdr.NodeID) + + if vdr.PublicKey == nil { + continue + } + + pkBytes := bls.PublicKeyToUncompressedBytes(vdr.PublicKey) + uniqueVdr, ok := vdrs[string(pkBytes)] + if !ok { + uniqueVdr = &warp.Validator{ + PublicKey: vdr.PublicKey, + PublicKeyBytes: pkBytes, + } + vdrs[string(pkBytes)] = uniqueVdr + } + + uniqueVdr.Weight += vdr.Weight // Impossible to overflow here + uniqueVdr.NodeIDs = append(uniqueVdr.NodeIDs, vdr.NodeID) } - return bytes, nil + utils.Sort(paritionVdrs) + vdrList := maps.Values(vdrs) + utils.Sort(vdrList) + return paritionVdrs, vdrList, totalWeight, nil +} + +// DirectorySize returns the size of all files, recursively, in a directory. +// +// DirectorySize is best-effort and is meant to be run on a dynamic directory. It will +// skip errors (which may arise from reading a file that was just deleted. +func DirectorySize(path string) uint64 { + var size uint64 + filepath.WalkDir(path, func(_ string, info fs.DirEntry, err error) error { + if !info.IsDir() { + finfo, err := info.Info() + if err != nil { + return nil + } + size += uint64(finfo.Size()) + } + return nil + }) + return size } diff --git a/vilmo/batch.go b/vilmo/batch.go new file mode 100644 index 0000000000..1427198a12 --- /dev/null +++ b/vilmo/batch.go @@ -0,0 +1,467 @@ +package vilmo + +import ( + "context" + "crypto/sha256" + "encoding/binary" + "fmt" + "hash" + "os" + "path/filepath" + "strconv" + + "github.com/AnomalyFi/hypersdk/consts" + "github.com/AnomalyFi/hypersdk/state" + "github.com/ava-labs/avalanchego/ids" + "github.com/ava-labs/avalanchego/utils/wrappers" + "go.uber.org/zap" + "golang.org/x/exp/mmap" +) + +var _ state.Mutable = (*Batch)(nil) + +// Batch is not thread-safe +// +// Batch assumes that keys are inserted in a **deterministic** order +// otherwise the checksum will differ across nodes. +// +// Batch assumes there is only one interaction per key per batch. +type Batch struct { + a *Vilmo + + newPath string + batch uint64 + + openWrites int64 // bytes + startingCursor int64 + + hasher hash.Hash + f *os.File + cursor int64 + + buf []byte + writer *writer + + l *log + + toReclaim *uint64 + reused bool +} + +func (a *Vilmo) NewBatch() (*Batch, error) { + a.commitLock.Lock() + batch := a.nextBatch + a.nextBatch++ + b := &Batch{ + a: a, + + newPath: filepath.Join(a.baseDir, strconv.FormatUint(batch, 10)), + batch: batch, + + hasher: sha256.New(), + + buf: make([]byte, batchBufferSize), + } + + // If we don't need to reclaim, we should create a new hashmap for this + // batch. + reclaim, err := b.reclaim() + if err != nil { + _ = b.f.Close() + // We don't remove this file because it may be a reuse file that we want to recover + return nil, err + } + if reclaim { + return b, nil + } + + // Create new log + f, err := os.Create(b.newPath) + if err != nil { + return nil, err + } + b.f = f + b.writer = newWriter(f, 0, b.a.bufferSize) + b.l = newLog(b.newPath, nil, b.a.batchSize) + if err := b.writeBatch(); err != nil { + return nil, err + } + opLen := opBatchLen() + b.openWrites += opLen + b.l.uselessBytes += opLen + return b, nil +} + +func (b *Batch) writeBuffer(value []byte, hash bool) error { + b.writer.Write(value) + if hash { + if _, err := b.hasher.Write(value); err != nil { + return err + } + } + b.cursor += int64(len(value)) + return nil +} + +func (b *Batch) reclaim() (bool, error) { + // Determine if we should delete the oldest batch + if b.batch < uint64(b.a.historyLen)+1 { + return false, nil + } + + // Find batch to reclaim + batchToClear := b.batch - uint64(b.a.historyLen) - 1 + previous := b.a.batches[batchToClear] + b.toReclaim = &batchToClear + b.a.logger.Debug("reclaiming previous batch file", zap.Uint64("batch", batchToClear)) + + // Determine if we should continue writing to the file or create a new one + if previous.aliveBytes < previous.uselessBytes/uselessDividerRecycle || previous.uselessBytes > forceRecycle { + b.a.logger.Debug( + "rewriting alive data to a new file", + zap.Int64("alive bytes", previous.aliveBytes), + zap.Int64("useless bytes", previous.uselessBytes), + zap.Uint64("batch", b.batch), + ) + + // Create new file + f, err := os.Create(b.newPath) + if err != nil { + return false, err + } + b.f = f + b.writer = newWriter(f, 0, b.a.bufferSize) + b.l = newLog(b.newPath, previous, b.a.batchSize) + if err := b.writeBatch(); err != nil { + return false, err + } + opLen := opBatchLen() + b.openWrites += opLen + b.l.uselessBytes += opLen + + // Iterate over alive records and add them to the batch file + iter := previous.alive.Iterator() + for next := iter.Next(); next != nil; next = iter.Next() { + // We manually parse [*record] to avoid interacting with the [keys] map. + var value []byte + if next.cached { + value = next.value + } else { + value = make([]byte, next.size) + _, err = previous.reader.ReadAt(value, next.ValueLoc()) + if err != nil { + return false, err + } + } + + // Create a new record for the key in the batch file + // + // This is the only time we can't reuse a record and need + // to re-add to the map (this is to keep a set of pending records + // prior to grabbing the key lock). + r := &record{ + log: b.l, + key: next.key, + } + start, err := b.writePut(next.key, value) + if err != nil { + return false, err + } + if len(value) < minDiskValueSize { + r.cached = true + r.value = value + } + r.loc = start + r.size = uint32(len(value)) + b.l.Add(r) + b.openWrites += opPutLen(next.key, value) + } + return true, nil + } + + // Open old batch for writing + b.a.logger.Debug("continuing to build on old batch", zap.Uint64("old", previous.batch), zap.Uint64("new", b.batch)) + f, err := os.OpenFile(previous.path, os.O_WRONLY, 0666) + if err != nil { + return false, err + } + fi, err := f.Stat() + if err != nil { + return false, err + } + b.f = f + b.writer = newWriter(f, fi.Size(), b.a.bufferSize) + b.cursor = int64(previous.reader.Len()) + b.startingCursor = b.cursor + b.l = previous + b.reused = true + if _, err := b.hasher.Write(previous.checksum[:]); err != nil { + return false, err + } + if err := b.writeBatch(); err != nil { + return false, err + } + opLen := opBatchLen() + b.openWrites += opLen + b.l.uselessBytes += opLen + + // Write nullifications for all keys no longer alive + for _, loc := range b.l.pendingNullify { + if err := b.writeNullify(loc); err != nil { + return false, err + } + b.openWrites += opNullifyLen() + b.a.logger.Debug("writing nullify record", zap.Int64("loc", loc)) + } + b.l.ResetNullify() + return true, nil +} + +// TODO: make this a true abort as long as called before Prepare +// +// We must release this lock to shutdown properly +func (b *Batch) Abort() error { + // Close in-progress file + if err := b.f.Close(); err != nil { + return err + } + + // Cleanup aborted work + if !b.reused { + if err := os.Remove(b.newPath); err != nil { + return err + } + } else { + if err := os.Truncate(b.l.path, b.startingCursor); err != nil { + return err + } + } + b.a.nextBatch-- + + // Release lock held acquired during [recycle] + b.a.commitLock.Unlock() + b.a.logger.Debug("aborted batch", zap.Uint64("batch", b.batch)) + return nil +} + +func (b *Batch) writePut(key string, value []byte) (int64, error) { + // Check input + if len(key) > int(consts.MaxUint16) { + return -1, ErrKeyTooLong + } + + // Write to disk + start := b.cursor + errs := &wrappers.Errs{} + b.buf = b.buf[:1] + b.buf[0] = opPut + errs.Add(b.writeBuffer(b.buf, true)) + b.buf = b.buf[:consts.Uint16Len] + binary.BigEndian.PutUint16(b.buf, uint16(len(key))) + errs.Add(b.writeBuffer(b.buf, true)) + errs.Add(b.writeBuffer(string2bytes(key), true)) + b.buf = b.buf[:consts.Uint32Len] + binary.BigEndian.PutUint32(b.buf, uint32(len(value))) + errs.Add(b.writeBuffer(b.buf, true)) + errs.Add(b.writeBuffer(value, true)) + return start, errs.Err +} + +func (b *Batch) writeDelete(key string) error { + errs := &wrappers.Errs{} + b.buf = b.buf[:1] + b.buf[0] = opDelete + errs.Add(b.writeBuffer(b.buf, true)) + b.buf = b.buf[:consts.Uint16Len] + binary.BigEndian.PutUint16(b.buf, uint16(len(key))) + errs.Add(b.writeBuffer(b.buf, true)) + errs.Add(b.writeBuffer(string2bytes(key), true)) + return errs.Err +} + +func (b *Batch) writeNullify(loc int64) error { + errs := &wrappers.Errs{} + b.buf = b.buf[:1] + b.buf[0] = opNullify + errs.Add(b.writeBuffer(b.buf, true)) + b.buf = b.buf[:consts.Uint64Len] + binary.BigEndian.PutUint64(b.buf, uint64(loc)) + errs.Add(b.writeBuffer(b.buf, true)) + return errs.Err +} + +func (b *Batch) writeBatch() error { + // Write to disk + errs := &wrappers.Errs{} + b.buf = b.buf[:1] + b.buf[0] = opBatch + errs.Add(b.writeBuffer(b.buf, true)) + b.buf = b.buf[:consts.Uint64Len] + binary.BigEndian.PutUint64(b.buf, b.batch) + errs.Add(b.writeBuffer(b.buf, true)) + return errs.Err +} + +func (b *Batch) writeChecksum() (ids.ID, error) { + // Write to disk + errs := &wrappers.Errs{} + b.buf = b.buf[:1] + b.buf[0] = opChecksum + errs.Add(b.writeBuffer(b.buf, true)) + checksum := ids.ID(b.hasher.Sum(nil)) + errs.Add(b.writeBuffer(checksum[:], false)) + return checksum, errs.Err +} + +// Prepare should be called right before we begin writing to the batch. As soon as +// this function is called, all reads to the database will be blocked. +// +// Prepare returns how many bytes were written to prepare the batch +// and whether we are rewriting an old batch file. +func (b *Batch) Prepare() (int64, bool) { + b.a.keyLock.Lock() + // Iterate over [alive] and update records for [keys] that were moved + // to a new log file. + if !b.reused { + iter := b.l.alive.Iterator() + for next := iter.Next(); next != nil; next = iter.Next() { + b.a.keys[next.key] = next + b.a.logger.Debug("updating key to new record", zap.String("key", next.key)) + } + } + + // Setup tracker for reference by this batch + // + // We wait to do this until here because we need to hold + // [keyLock] to ensure that [b.a.batches] is not referenced + // at the same time. + b.a.batches[b.batch] = b.l + return b.openWrites, b.toReclaim != nil && !b.reused +} + +// It is good to call, Insert after calling prepare to the batch. +func (b *Batch) Insert(_ context.Context, k []byte, value []byte) error { + key := string(k) + start, err := b.writePut(string(key), value) + if err != nil { + return err + } + past, ok := b.a.keys[key] + if ok { + past.log.Remove(past, b.l) + past.log = b.l + + // We avoid updating [keys] when just updating the value of a [record] + } else { + past = &record{log: b.l, key: key} + b.a.keys[key] = past + } + if len(value) >= minDiskValueSize { + past.cached = false + past.value = nil + } else { + past.cached = true + past.value = value + } + past.loc = start + past.size = uint32(len(value)) + b.l.Add(past) + return nil +} + +// It is good to call, Remove after calling prepare to the batch. +func (b *Batch) Remove(_ context.Context, k []byte) error { + key := string(k) + // Check input + if len(key) > int(consts.MaxUint16) { + return ErrKeyTooLong + } + + // Check if key even exists + past, ok := b.a.keys[key] + if !ok { + b.a.logger.Debug("attempted to delete non-existent key", zap.String("key", key)) + return nil + } + + // Write to disk + if err := b.writeDelete(key); err != nil { + return err + } + + // Account for useless bytes + // + // We check to see if the past batch is less + // than the current batch because we may have just recycled + // this key and it is already in [alive]. + past.log.Remove(past, b.l) + delete(b.a.keys, key) + b.l.uselessBytes += opDeleteLen(key) + return nil +} + +// GetValue returns the value associated with the key. Get value is not callable/blocked when prepare is called to the batch and write is not called. +func (b *Batch) GetValue(ctx context.Context, key []byte) ([]byte, error) { + return b.a.GetValue(ctx, key) +} + +// Write failure is not expected. If an error is returned, +// it should be treated as fatal. +// Write unlocks keylock and commitlock, reads to data base should be possible after write. +// Write returns any error and checksum of the batch. +func (b *Batch) Write() (ids.ID, error) { + defer func() { + b.a.keyLock.Unlock() + b.a.commitLock.Unlock() + }() + + // Add batch and checksum to file (allows for crash recovery on restart in the case that a file is partially written) + checksum, err := b.writeChecksum() + if err != nil { + return ids.Empty, err + } + // We always consider previous checksums useless in the case + // that we are reusing a file. + b.l.uselessBytes += opChecksumLen() + + // Flush all unwritten data to disk + if err := b.writer.Flush(); err != nil { + return ids.Empty, fmt.Errorf("%w: could not flush file", err) + } + + // Close file now that we don't need to write to it anymore + // + // Note: we don't require the file to be fsync'd here and assume + // we can recover the current state on restart. + if err := b.f.Close(); err != nil { + return ids.Empty, fmt.Errorf("%w: could not close file", err) + } + + // Handle old batch cleanup + if b.toReclaim != nil { + reclaimed := *b.toReclaim + reclaimedBatch := b.a.batches[reclaimed] + if err := reclaimedBatch.reader.Close(); err != nil { + return ids.Empty, fmt.Errorf("%w: could not close old batch", err) + } + if !b.reused { + if err := os.Remove(reclaimedBatch.path); err != nil { + return ids.Empty, fmt.Errorf("%w: could not remove old batch", err) + } + } + delete(b.a.batches, reclaimed) + } + + // Open file for mmap before keys become acessible + reader, err := mmap.Open(b.l.path) + if err != nil { + // Should never happen + return ids.Empty, fmt.Errorf("%w: could not mmap new batch", err) + } + + // Register the batch for reading + b.l.reader = reader + b.l.batch = b.batch + b.l.checksum = checksum + return checksum, nil +} diff --git a/vilmo/consts.go b/vilmo/consts.go new file mode 100644 index 0000000000..00a2f62c09 --- /dev/null +++ b/vilmo/consts.go @@ -0,0 +1,10 @@ +package vilmo + +import "github.com/ava-labs/avalanchego/utils/units" + +const ( + batchBufferSize = 16 // just need to be big enough for any binary numbers + minDiskValueSize = 64 + uselessDividerRecycle = 3 + forceRecycle = 128 * units.MiB // TODO: make this tuneable +) diff --git a/vilmo/dll.go b/vilmo/dll.go new file mode 100644 index 0000000000..7a18ac448a --- /dev/null +++ b/vilmo/dll.go @@ -0,0 +1,53 @@ +package vilmo + +type dll struct { + first *record + last *record +} + +func (d *dll) Add(r *record) { + // Clean record + r.prev = nil + r.next = nil + + // Add to linked list + if d.first == nil { + d.first = r + d.last = r + return + } + r.prev = d.last + d.last.next = r + d.last = r +} + +func (d *dll) Remove(r *record) { + // Remove from linked list + if r.prev == nil { + d.first = r.next + } else { + r.prev.next = r.next + } + if r.next == nil { + d.last = r.prev + } else { + r.next.prev = r.prev + } +} + +type dllIterator struct { + cursor *record +} + +func (d *dll) Iterator() *dllIterator { + return &dllIterator{cursor: d.first} +} + +func (d *dllIterator) Next() *record { + if d.cursor == nil { + return nil + } + r := d.cursor + d.cursor = d.cursor.next + return r +} diff --git a/vilmo/errors.go b/vilmo/errors.go new file mode 100644 index 0000000000..50d5b321a3 --- /dev/null +++ b/vilmo/errors.go @@ -0,0 +1,7 @@ +package vilmo + +import "errors" + +var ( + ErrKeyTooLong = errors.New("key too long") +) diff --git a/vilmo/log.go b/vilmo/log.go new file mode 100644 index 0000000000..af4f91d930 --- /dev/null +++ b/vilmo/log.go @@ -0,0 +1,323 @@ +package vilmo + +import ( + "bufio" + "crypto/sha256" + "fmt" + "io" + "os" + + "github.com/ava-labs/avalanchego/ids" + "github.com/ava-labs/avalanchego/utils/logging" + "go.uber.org/zap" + "golang.org/x/exp/mmap" +) + +// log files store all operations for vilmo. +// +// They are structured as repeated sequences of: +// [opBatch][opPut/opDelete]...[opPut/opDelete][opChecksum]... +type log struct { + // path is the location of this log on disk + path string + + // reader must be reopened anytime the underlying file + // is appended to. + reader *mmap.ReaderAt + + // aliveBytes/uselessBytes are used to determine when to rewrite + // a log file. + aliveBytes int64 + uselessBytes int64 // already includes all overwritten data, checksums, and deletes + + // batch and checksum are the latest checkpoints in a log file. + batch uint64 + checksum ids.ID + + // alive is used to determine the order to write living + // data in the case a log needs to be rewritten. + alive *dll + + // pendingNullify is a list of put records that have been deleted + // on other log files. In the case that this log file is recycled, + // we must persist these nullifications to ensure we can restore state + // on restart. + // + // Theoretically, we could wait to write nullifications until updates on + // other log files are actually discarded (assuming we don't rewrite this + // log). This is considered future work as it isn't necessarily clear + // this would be that much more efficient for the added complexity. + pendingNullify []int64 +} + +func newLog(path string, prev *log, batchSize int) *log { + l := &log{ + path: path, + alive: &dll{}, + } + if prev != nil { + l.pendingNullify = prev.pendingNullify + l.pendingNullify = l.pendingNullify[:0] + } else { + l.pendingNullify = make([]int64, 0, batchSize) + } + return l +} + +func (l *log) Add(record *record) { + l.aliveBytes += opPutLenWithValueLen(record.key, record.size) + + // Add to linked list + l.alive.Add(record) +} + +func (l *log) Remove(record *record, actor *log) { + opSize := opPutLenWithValueLen(record.key, record.size) + l.aliveBytes -= opSize + l.uselessBytes += opSize + + // Remove from linked list + l.alive.Remove(record) + + // We should only nullify a record if the update/delete is on another log. If it is + // on the same log file, we don't need to nullify it. + if record.log != actor { + l.pendingNullify = append(l.pendingNullify, record.loc) + l.uselessBytes += opNullifyLen() + } +} + +func (l *log) ResetNullify() { + l.pendingNullify = l.pendingNullify[:0] +} + +// reader tracks how many bytes we read of a file to support +// arbitrary truncation. +type reader struct { + cursor int64 + reader *bufio.Reader +} + +func (r *reader) Cursor() int64 { + return r.cursor +} + +func (r *reader) Read(p []byte) error { + n, err := io.ReadFull(r.reader, p) + r.cursor += int64(n) + return err +} + +type batchIndex struct { + batch uint64 + index int +} + +// load will attempt to load a log file from disk. +// +// If a batch is partially written or corrupt, the batch will be removed +// from the log file and the last non-corrupt batch will be returned. If +// there are no non-corrupt batches the file will be deleted and a nil *log +// will be retunred. +// +// Partial batch writing should not occur unless there is an unclean shutdown, +// as the usage of [Abort] prevents this. +func load(logger logging.Logger, path string, batchSize int) (*log, map[uint64][]any, error) { + // Open log file + f, err := os.Open(path) + if err != nil { + return nil, nil, err + } + fi, err := f.Stat() + if err != nil { + return nil, nil, err + } + fileSize := int64(fi.Size()) + + // Read log file ops + var ( + reader = &reader{reader: bufio.NewReader(f)} + + // We create the log here so that any read items can reference it. + l = newLog(path, nil, batchSize) + + lastBatch uint64 + batchSet bool + keys = map[int64]*batchIndex{} + + hasher = sha256.New() + ops []any + uselessBytes int64 + + committedByte int64 + committedChecksum ids.ID + committedBatch uint64 + committedUselessBytes int64 + committedOps = map[uint64][]any{} + + done bool + corrupt error + ) + for !done && corrupt == nil { + start := reader.Cursor() + opType, err := readOpType(reader, hasher) + if err != nil { + corrupt = err + break + } + if !batchSet && opType != opBatch { + corrupt = fmt.Errorf("expected batch op but got %d", opType) + break + } + switch opType { + case opPut: + key, value, err := readPut(reader, hasher) + if err != nil { + corrupt = err + break + } + r := &record{ + log: l, + key: key, + + loc: start, + size: uint32(len(value)), + } + if len(value) < minDiskValueSize { + r.cached = true + r.value = value + } + ops = append(ops, r) + keys[start] = &batchIndex{batch: lastBatch, index: len(ops) - 1} + + // We wait to adjust [aliveBytes] until we know if a value is actually + // added to [keys]. + case opDelete: + del, err := readDelete(reader, hasher) + if err != nil { + corrupt = err + break + } + uselessBytes += opDeleteLen(del) + ops = append(ops, del) + case opBatch: + batch, err := readBatch(reader, hasher) + if err != nil { + corrupt = err + break + } + uselessBytes += opBatchLen() + if batchSet { + corrupt = fmt.Errorf("batch %d already set", lastBatch) + break + } + lastBatch = batch + batchSet = true + ops = []any{} + case opChecksum: + checksum, err := readChecksum(reader) + if err != nil { + corrupt = err + break + } + uselessBytes += opChecksumLen() + computed := ids.ID(hasher.Sum(nil)) + if checksum != computed { + corrupt = fmt.Errorf("checksum mismatch expected=%d got=%d", checksum, computed) + break + } + ops = append(ops, checksum) + + // Update our track for last committed + committedByte = reader.Cursor() + committedChecksum = checksum + committedBatch = lastBatch + committedUselessBytes = uselessBytes + committedOps[lastBatch] = ops + + // Check if we should exit (only clean exit) + if reader.Cursor() == fileSize { + done = true + break + } + + // Keep reading + hasher = sha256.New() + if _, err := hasher.Write(checksum[:]); err != nil { + corrupt = err + break + } + batchSet = false + ops = nil + case opNullify: + loc, err := readNullify(reader, hasher) + if err != nil { + corrupt = err + break + } + uselessBytes += opNullifyLen() + bi, ok := keys[loc] + if !ok { + corrupt = fmt.Errorf("nullify key not found at %d", loc) + break + } + record := committedOps[bi.batch][bi.index].(*record) + uselessBytes += opPutLenWithValueLen(record.key, record.size) + + // It is not possible to nullify a put operation in the same + // batch, so this will not panic. + committedOps[bi.batch][bi.index] = nil + default: + corrupt = fmt.Errorf("unknown op type %d", opType) + } + } + + // Close file once we are done reading + if err := f.Close(); err != nil { + return nil, nil, err + } + + // If the log file is corrupt, attempt to revert + // to the last non-corrupt op. + if corrupt != nil { + logger.Warn( + "log file is corrupt", + zap.String("path", path), + zap.Error(corrupt), + ) + } + + // If after recovery the log file is empty, return to caller. + if committedByte == 0 { + // Remove the empty file + if err := os.Remove(path); err != nil { + return nil, nil, fmt.Errorf("%w: unable to remove useless file", err) + } + logger.Warn( + "removing corrupt log", + zap.String("path", path), + ) + return nil, nil, nil + } else if fileSize > committedByte { + if err := os.Truncate(path, committedByte); err != nil { + return nil, nil, fmt.Errorf("%w: unable to truncate file", err) + } + logger.Warn( + "truncating corrupt log", + zap.String("path", path), + zap.Int64("tip", fileSize), + zap.Int64("committed", committedByte), + ) + } + + // Open file for mmap + m, err := mmap.Open(path) + if err != nil { + return nil, nil, err + } + l.reader = m + l.uselessBytes = committedUselessBytes + l.batch = committedBatch + l.checksum = committedChecksum + return l, committedOps, nil +} diff --git a/vilmo/ops.go b/vilmo/ops.go new file mode 100644 index 0000000000..47000f8987 --- /dev/null +++ b/vilmo/ops.go @@ -0,0 +1,140 @@ +package vilmo + +import ( + "crypto/sha256" + "encoding/binary" + "hash" + + "github.com/AnomalyFi/hypersdk/consts" + "github.com/ava-labs/avalanchego/ids" +) + +const ( + opPut = uint8(0) // keyLen|key|valueLen|value + opDelete = uint8(1) // keyLen|key + opBatch = uint8(2) // batch (starts all segments) + opChecksum = uint8(3) // checksum (ends all segments) + opNullify = uint8(4) // keyLoc +) + +func readOpType(reader *reader, hasher hash.Hash) (uint8, error) { + op := make([]byte, consts.Uint8Len) + if err := reader.Read(op); err != nil { + return 0, err + } + if _, err := hasher.Write(op); err != nil { + return 0, err + } + return op[0], nil +} + +func readKey(reader *reader, hasher hash.Hash) (string, error) { + op := make([]byte, consts.Uint16Len) + if err := reader.Read(op); err != nil { + return "", err + } + if _, err := hasher.Write(op); err != nil { + return "", err + } + keyLen := binary.BigEndian.Uint16(op) + key := make([]byte, keyLen) + if err := reader.Read(key); err != nil { + return "", err + } + if _, err := hasher.Write(key); err != nil { + return "", err + } + return string(key), nil +} + +func readPut(reader *reader, hasher hash.Hash) (string, []byte, error) { + key, err := readKey(reader, hasher) + if err != nil { + return "", nil, err + } + + // Read value + op := make([]byte, consts.Uint32Len) + if err := reader.Read(op); err != nil { + return "", nil, err + } + if _, err := hasher.Write(op); err != nil { + return "", nil, err + } + valueLen := binary.BigEndian.Uint32(op) + value := make([]byte, valueLen) + if err := reader.Read(value); err != nil { + return "", nil, err + } + if _, err := hasher.Write(value); err != nil { + return "", nil, err + } + return key, value, nil +} + +func readDelete(reader *reader, hasher hash.Hash) (string, error) { + key, err := readKey(reader, hasher) + if err != nil { + return "", err + } + return key, nil +} + +func readBatch(reader *reader, hasher hash.Hash) (uint64, error) { + op := make([]byte, consts.Uint64Len) + if err := reader.Read(op); err != nil { + return 0, err + } + if _, err := hasher.Write(op); err != nil { + return 0, err + } + batch := binary.BigEndian.Uint64(op) + return batch, nil +} + +func readChecksum(reader *reader) (ids.ID, error) { + op := make([]byte, sha256.Size) + if err := reader.Read(op); err != nil { + return ids.Empty, err + } + return ids.ID(op), nil +} + +func readNullify(reader *reader, hasher hash.Hash) (int64, error) { + op := make([]byte, consts.Uint64Len) + if err := reader.Read(op); err != nil { + return 0, err + } + if _, err := hasher.Write(op); err != nil { + return 0, err + } + return int64(binary.BigEndian.Uint64(op)), nil +} + +func opPutLen(key string, value []byte) int64 { + return int64(consts.Uint8Len + consts.Uint16Len + len(key) + consts.Uint32Len + len(value)) +} + +func opPutLenWithValueLen(key string, valueLen uint32) int64 { + return int64(consts.Uint8Len+consts.Uint16Len) + int64(len(key)) + consts.Uint32Len + int64(valueLen) +} + +func opPutToValue(key string) int64 { + return int64(consts.Uint8Len + consts.Uint16Len + len(key) + consts.Uint32Len) +} + +func opDeleteLen(key string) int64 { + return int64(consts.Uint8Len + consts.Uint16Len + len(key)) +} + +func opBatchLen() int64 { + return int64(consts.Uint8Len + consts.Uint64Len) +} + +func opChecksumLen() int64 { + return int64(consts.Uint8Len + ids.IDLen) +} + +func opNullifyLen() int64 { + return int64(consts.Uint8Len + consts.Uint64Len) +} diff --git a/vilmo/record.go b/vilmo/record.go new file mode 100644 index 0000000000..2e9917f8b5 --- /dev/null +++ b/vilmo/record.go @@ -0,0 +1,34 @@ +package vilmo + +type record struct { + // log is the log file that contains this record + // + // By storing a pointer here, we can avoid a map + // lookup if we need to access this log. + log *log + + // loc is the offset of the record in the log file + // + // We store the beginning of the record here for using + // in nullify operations. + loc int64 + + // key is the length of the key + key string + + // Only populated if the value is less than [minDiskValueSize] + cached bool + value []byte + + // size is the size of the value in the log file + size uint32 + + // interleaved (across batches) doubly-linked list allows for removals + prev *record + next *record +} + +// ValueLoc returns the locaction of the value in the log file +func (r *record) ValueLoc() int64 { + return r.loc + opPutToValue(r.key) +} diff --git a/vilmo/utils.go b/vilmo/utils.go new file mode 100644 index 0000000000..59cac11feb --- /dev/null +++ b/vilmo/utils.go @@ -0,0 +1,10 @@ +package vilmo + +import "unsafe" + +// string2bytes avoid copying the string to a byte slice (which we need +// when writing to disk). +func string2bytes(str string) []byte { + d := unsafe.StringData(str) + return unsafe.Slice(d, len(str)) +} diff --git a/vilmo/vilmo.go b/vilmo/vilmo.go new file mode 100644 index 0000000000..66e297f055 --- /dev/null +++ b/vilmo/vilmo.go @@ -0,0 +1,268 @@ +package vilmo + +import ( + "context" + "errors" + "os" + "path/filepath" + "slices" + "strconv" + "sync" + "time" + + "github.com/AnomalyFi/hypersdk/state" + "github.com/ava-labs/avalanchego/database" + "github.com/ava-labs/avalanchego/ids" + "github.com/ava-labs/avalanchego/utils/logging" + "github.com/ava-labs/avalanchego/utils/units" + "go.uber.org/zap" + "golang.org/x/exp/maps" +) + +var _ state.Immutable = (*Vilmo)(nil) + +type Vilmo struct { + logger logging.Logger + baseDir string + batchSize int + bufferSize int + historyLen int + + commitLock sync.RWMutex + nextBatch uint64 + batches map[uint64]*log + + keyLock sync.RWMutex + keys map[string]*record +} + +type opsWrapper struct { + log *log + ops []any +} + +type Config struct { + initialSize int + batchSize int + bufferSize int + historyLen int // should not be changed +} + +func NewDefaultConfig() *Config { + return &Config{ + initialSize: 15_000_000, + batchSize: 50_000, + bufferSize: 64 * units.KiB, + historyLen: 256, + } +} + +// New returns a new Vilmo instance and the ID of the last committed file. +// TODO: return metrics too, while initiating new vilmo. +func New( + logger logging.Logger, + baseDir string, + cfg *Config, +) (*Vilmo, ids.ID, error) { + // Iterate over files in directory and put into sorted order + start := time.Now() + files := []uint64{} + err := filepath.Walk(baseDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if baseDir == path { + // Don't attempt to load self + return nil + } + if info.IsDir() { + // Skip anything unexpected + logger.Warn("found unexpected directory", zap.String("path", path)) + return nil + } + file, err := strconv.ParseUint(info.Name(), 10, 64) + if err != nil { + return err + } + files = append(files, file) + return nil + }) + if err != nil { + return nil, ids.Empty, err + } + + // Load log files from disk + // + // During this process, we attempt to recover any corrupt files + // on-disk and ensure that we don't have any gaps. We also ensure we don't + // end up with too many files. + batchOps := map[uint64]*opsWrapper{} + batches := make(map[uint64]*log, len(files)) + for _, file := range files { + path := filepath.Join(baseDir, strconv.FormatUint(file, 10)) + l, allOps, err := load(logger, path, cfg.batchSize) + if err != nil { + logger.Warn("could not load log", zap.String("path", path), zap.Error(err)) + return nil, ids.Empty, err + } + if l == nil { + // This means the file was empty and is now deleted + continue + } + for batch, ops := range allOps { + if _, ok := batchOps[batch]; ok { + logger.Warn("found duplicate batch", zap.Uint64("batch", batch), zap.String("current", path)) + return nil, ids.Empty, errors.New("duplicate batch") + } + batchOps[batch] = &opsWrapper{l, ops} + } + batches[l.batch] = l + } + if len(batches) > cfg.historyLen+1 { + logger.Warn("found too many logs", zap.Int("count", len(batches))) + return nil, ids.Empty, errors.New("too many logs") + } + + // Build current state from all log files + var ( + checksum ids.ID + keys = make(map[string]*record, cfg.initialSize) + replayableBatches = maps.Keys(batchOps) + ) + slices.Sort(replayableBatches) + for _, batch := range replayableBatches { + // There may be gaps between batches depending on which files were rewritten, + // that's ok. + opw := batchOps[batch] + for _, op := range opw.ops { + switch o := op.(type) { + case nil: + // This happens when a put operation is nullified + continue + case *record: + past, ok := keys[o.key] + if ok { + past.log.Remove(past, opw.log) + } + keys[o.key] = o + o.log.Add(o) + + // UselessBytes attributable to opPut are not yet accounted for in the log, + // so we adjust them in log.Remove/log.Add above. + case string: + past, ok := keys[o] + if !ok { + continue + } + past.log.Remove(past, opw.log) + delete(keys, o) + + // UselessBytes attributable to opDelete are already accounted for in the log, + // so we don't adjust them here. + case ids.ID: + checksum = o + default: + logger.Warn("found invalid operation", zap.Uint64("batch", batch), zap.Any("op", op)) + return nil, ids.Empty, errors.New("invalid operation") + } + } + } + + // Instantiate DB + adb := &Vilmo{ + logger: logger, + baseDir: baseDir, + batchSize: cfg.batchSize, + bufferSize: cfg.bufferSize, + historyLen: cfg.historyLen, + + keys: keys, + batches: batches, + } + if len(replayableBatches) > 0 { + adb.nextBatch = replayableBatches[len(replayableBatches)-1] + 1 + } + logger.Info( + "loaded batches", + zap.Int("logs", len(adb.batches)), + zap.Int("keys", len(adb.keys)), + zap.Uint64("next batch", adb.nextBatch), + zap.Stringer("last checksum", checksum), + zap.Duration("duration", time.Since(start)), + ) + return adb, checksum, nil +} + +func (a *Vilmo) get(key string) ([]byte, error) { + entry, ok := a.keys[key] + if !ok { + return nil, database.ErrNotFound + } + if entry.cached { + return slices.Clone(entry.value), nil + } + value := make([]byte, entry.size) + _, err := entry.log.reader.ReadAt(value, entry.ValueLoc()) + return value, err +} + +func (a *Vilmo) GetValue(_ context.Context, key []byte) ([]byte, error) { + a.keyLock.RLock() + b, err := a.get(string(key)) + a.keyLock.RUnlock() + return b, err +} + +func (a *Vilmo) Gets(_ context.Context, keys []string) ([][]byte, []error) { + a.keyLock.RLock() + defer a.keyLock.RUnlock() + + values := make([][]byte, len(keys)) + errors := make([]error, len(keys)) + for i, key := range keys { + values[i], errors[i] = a.get(key) + } + return values, errors +} + +func (a *Vilmo) Close() error { + a.commitLock.Lock() + defer a.commitLock.Unlock() + + for _, file := range a.batches { + if err := file.reader.Close(); err != nil { + return err + } + } + a.logger.Info( + "closing vilmo", + zap.Int("keys", len(a.keys)), + zap.Uint64("next batch", a.nextBatch), + zap.Uint64("logs", uint64(len(a.batches))), + ) + return nil +} + +func (a *Vilmo) Usage() (int, int64 /* alive bytes */, int64 /* useless bytes */) { + a.commitLock.RLock() + defer a.commitLock.RUnlock() + + var ( + aliveBytes int64 + uselessBytes int64 + ) + for n, batch := range a.batches { + aliveBytes += batch.aliveBytes + + // This includes discarded data that may be deleted (may be a little inaccurate) + uselessBytes += batch.uselessBytes + a.logger.Debug( + "log usage", + zap.Uint64("batch", n), + zap.Int64("alive", batch.aliveBytes), + zap.Int64("useless", batch.uselessBytes), + zap.Int64("total", batch.aliveBytes+batch.uselessBytes), + ) + } + return len(a.keys), aliveBytes, uselessBytes +} diff --git a/vilmo/vilmo_test.go b/vilmo/vilmo_test.go new file mode 100644 index 0000000000..e3027b9cc7 --- /dev/null +++ b/vilmo/vilmo_test.go @@ -0,0 +1,1002 @@ +package vilmo + +import ( + "bufio" + "context" + "crypto/rand" + "fmt" + "io" + "os" + "path/filepath" + "strconv" + "testing" + + "github.com/AnomalyFi/hypersdk/pebble" + "github.com/AnomalyFi/hypersdk/smap" + "github.com/AnomalyFi/hypersdk/tstate" + "github.com/ava-labs/avalanchego/database" + "github.com/ava-labs/avalanchego/ids" + "github.com/ava-labs/avalanchego/utils/linked" + "github.com/ava-labs/avalanchego/utils/logging" + "github.com/ava-labs/avalanchego/utils/maybe" + "github.com/ava-labs/avalanchego/utils/units" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + "golang.org/x/exp/mmap" +) + +const ( + defaultInitialSize = 10_000_000 + defaultBufferSize = 64 * units.KiB + defaultHistoryLen = 100 +) + +// reuseKeys is a value [0,itemsPerBatch] that determines how many keys are reused across batches +func randomKeyValues(batches int, itemsPerBatch int, keySize int, valueSize int, reuseKeys int) ([][][]byte, [][][]byte) { + keys := make([][][]byte, batches) + values := make([][][]byte, batches) + for i := 0; i < batches; i++ { + keys[i] = make([][]byte, itemsPerBatch) + values[i] = make([][]byte, itemsPerBatch) + for j := 0; j < itemsPerBatch; j++ { + // Create key + if i == 0 || j >= reuseKeys { + k := make([]byte, keySize) + rand.Read(k) + keys[i][j] = k + } else { + keys[i][j] = keys[0][j] + } + + // Create value + v := make([]byte, valueSize) + rand.Read(v) + values[i][j] = v + } + } + return keys, values +} + +func TestVilmo(t *testing.T) { + // Prepare + require := require.New(t) + ctx := context.TODO() + baseDir := t.TempDir() + logger := logging.NewLogger( + "vilmo", + logging.NewWrappedCore( + logging.Debug, + os.Stdout, + logging.Colors.ConsoleEncoder(), + ), + ) + logger.Info("created directory", zap.String("path", baseDir)) + + // Create + db, last, err := New(logger, baseDir, defaultInitialSize, 10, defaultBufferSize, defaultHistoryLen) + require.NoError(err) + require.Equal(ids.Empty, last) + + // Put + b, err := db.NewBatch() + require.NoError(err) + openBytes, rewrite := b.Prepare() + require.Equal(opBatchLen(), openBytes) + require.False(rewrite) + require.NoError(b.Insert(ctx, []byte("hello"), []byte("world"))) + batch, err := b.Write() + require.NoError(err) + require.NotEqual(ids.Empty, batch) + + // Get + v, err := db.GetValue(ctx, []byte("hello")) + require.NoError(err) + require.Equal([]byte("world"), v) + + // Restart + keys, aliveBytes, uselessBytes := db.Usage() + require.NoError(db.Close()) + db, last, err = New(logger, baseDir, defaultInitialSize, 10, defaultBufferSize, defaultHistoryLen) + require.NoError(err) + require.Equal(batch, last) + keys2, aliveBytes2, uselessBytes2 := db.Usage() + require.Equal(keys, keys2) + require.Equal(aliveBytes, aliveBytes2) + require.Equal(uselessBytes, uselessBytes2) + + // Get + v, err = db.GetValue(ctx, []byte("hello")) + require.NoError(err) + require.Equal([]byte("world"), v) + + // Modify file + require.NoError(db.Close()) + f, err := os.OpenFile(filepath.Join(baseDir, "0"), os.O_RDWR|os.O_APPEND, 0666) + require.NoError(err) + _, err = f.WriteString("corrupt") + require.NoError(err) + require.NoError(f.Close()) + + // Repair corruption and restart + db, last, err = New(logger, baseDir, defaultInitialSize, 10, defaultBufferSize, defaultHistoryLen) + require.NoError(err) + require.Equal(batch, last) + keys3, aliveBytes3, uselessBytes3 := db.Usage() + require.Equal(keys, keys3) + require.Equal(aliveBytes, aliveBytes3) + require.Equal(uselessBytes, uselessBytes3) + + // Get + v, err = db.GetValue(ctx, []byte("hello")) + require.NoError(err) + require.Equal([]byte("world"), v) +} + +func TestVilmoAbort(t *testing.T) { + // Prepare + require := require.New(t) + ctx := context.TODO() + baseDir := t.TempDir() + logger := logging.NewLogger( + "vilmo", + logging.NewWrappedCore( + logging.Debug, + os.Stdout, + logging.Colors.ConsoleEncoder(), + ), + ) + logger.Info("created directory", zap.String("path", baseDir)) + + // Create + db, last, err := New(logger, baseDir, defaultInitialSize, 10, defaultBufferSize, 1) + require.NoError(err) + require.Equal(ids.Empty, last) + + // Insert key (add enough items such that we will not rewrite file) + b, err := db.NewBatch() + require.NoError(err) + openBytes, rewrite := b.Prepare() + require.Equal(opBatchLen(), openBytes) + require.False(rewrite) + require.NoError(b.Insert(ctx, []byte("hello"), []byte("world"))) + require.NoError(b.Insert(ctx, []byte("hello2"), []byte("world2"))) + require.NoError(b.Insert(ctx, []byte("hello3"), []byte("world3"))) + _, err = b.Write() + require.NoError(err) + + // Create a batch gap + b, err = db.NewBatch() + require.NoError(err) + openBytes, rewrite = b.Prepare() + require.Equal(opBatchLen(), openBytes) + require.False(rewrite) + require.NoError(b.Insert(ctx, []byte("hello"), []byte("world10"))) + checksum, err := b.Write() + require.NoError(err) + keys, alive, useless := db.Usage() + + // Create new batch then abort + b, err = db.NewBatch() + require.NoError(err) + require.NoError(b.Abort()) + require.NoError(db.Close()) + + // Reload database + db, last, err = New(logger, baseDir, defaultInitialSize, 10, defaultBufferSize, 1) + require.NoError(err) + require.Equal(checksum, last) + keys2, alive2, useless2 := db.Usage() + require.Equal(keys, keys2) + require.Equal(alive, alive2) + require.Equal(useless, useless2) + + // Write batch + b, err = db.NewBatch() + require.NoError(err) + openBytes, rewrite = b.Prepare() + require.Equal(opBatchLen()+opNullifyLen(), openBytes) + require.False(rewrite) + require.NoError(b.Insert(ctx, []byte("hello"), []byte("world11"))) + require.NoError(b.Remove(ctx, []byte("hello2"))) + checksum, err = b.Write() + require.NoError(err) + keys, alive, useless = db.Usage() + require.NoError(db.Close()) + + // Reload database and ensure correct values set in keys + db, last, err = New(logger, baseDir, defaultInitialSize, 10, defaultBufferSize, 1) + require.NoError(err) + require.Equal(checksum, last) + keys2, alive2, useless2 = db.Usage() + require.Equal(keys, keys2) + require.Equal(alive, alive2) + require.Equal(useless, useless2) + + // Ensure data is correct + v, err := db.GetValue(ctx, []byte("hello")) + require.NoError(err) + require.Equal([]byte("world11"), v) + _, err = db.GetValue(ctx, []byte("hello2")) + require.ErrorIs(err, database.ErrNotFound) + v, err = db.GetValue(ctx, []byte("hello3")) + require.NoError(err) + require.Equal([]byte("world3"), v) + require.NoError(db.Close()) +} + +func TestVilmoComplexReload(t *testing.T) { + // Prepare + require := require.New(t) + ctx := context.TODO() + baseDir := t.TempDir() + logger := logging.NewLogger( + "vilmo", + logging.NewWrappedCore( + logging.Debug, + os.Stdout, + logging.Colors.ConsoleEncoder(), + ), + ) + logger.Info("created directory", zap.String("path", baseDir)) + + // Create + db, last, err := New(logger, baseDir, defaultInitialSize, 10, defaultBufferSize, 1) + require.NoError(err) + require.Equal(ids.Empty, last) + + // Insert key + b, err := db.NewBatch() + require.NoError(err) + openBytes, rewrite := b.Prepare() + require.Equal(opBatchLen(), openBytes) + require.False(rewrite) + require.NoError(b.Insert(ctx, []byte("hello"), []byte("world"))) + require.NoError(b.Insert(ctx, []byte("unrelated"), []byte("junk"))) // add extra data to prevent rewrite + require.NoError(b.Insert(ctx, []byte("unrelated2"), []byte("junk2"))) + _, err = b.Write() + require.NoError(err) + + // Delete key + b, err = db.NewBatch() + require.NoError(err) + openBytes, rewrite = b.Prepare() + require.Equal(opBatchLen(), openBytes) + require.False(rewrite) + require.NoError(b.Remove(ctx, []byte("hello"))) + _, err = b.Write() + require.NoError(err) + + // Put new key + b, err = db.NewBatch() + require.NoError(err) + openBytes, rewrite = b.Prepare() + require.Equal(opBatchLen()+opNullifyLen(), openBytes) + require.False(rewrite) + require.NoError(b.Insert(ctx, []byte("hello2"), []byte("world2"))) + lastChecksum, err := b.Write() + require.NoError(err) + require.NotEqual(ids.Empty, lastChecksum) + + // Reload and ensure values match + keys, alive, useless := db.Usage() + require.NoError(db.Close()) + db, last, err = New(logger, baseDir, defaultInitialSize, 10, defaultBufferSize, 1) + require.NoError(err) + require.Equal(lastChecksum, last) + keys2, alive2, useless2 := db.Usage() + require.Equal(keys, keys2) + require.Equal(alive, alive2) + require.Equal(useless, useless2) + _, err = db.GetValue(ctx, []byte("hello")) + require.ErrorIs(err, database.ErrNotFound) + v, err := db.GetValue(ctx, []byte("hello2")) + require.NoError(err) + require.Equal([]byte("world2"), v) +} + +func TestVilmoReinsertHistory(t *testing.T) { + // Prepare + require := require.New(t) + ctx := context.TODO() + baseDir := t.TempDir() + logger := logging.NewLogger( + "vilmo", + logging.NewWrappedCore( + logging.Debug, + os.Stdout, + logging.Colors.ConsoleEncoder(), + ), + ) + logger.Info("created directory", zap.String("path", baseDir)) + + // Create + db, last, err := New(logger, baseDir, defaultInitialSize, 10, defaultBufferSize, 1) + require.NoError(err) + require.Equal(ids.Empty, last) + + // Insert key + b, err := db.NewBatch() + require.NoError(err) + openBytes, rewrite := b.Prepare() + require.Equal(opBatchLen(), openBytes) + require.False(rewrite) + require.NoError(b.Insert(ctx, []byte("hello"), []byte("world"))) + _, err = b.Write() + require.NoError(err) + + // Create a batch gap + b, err = db.NewBatch() + require.NoError(err) + openBytes, rewrite = b.Prepare() + require.Equal(opBatchLen(), openBytes) + require.False(rewrite) + require.NoError(b.Insert(ctx, []byte("world"), []byte("hello"))) + _, err = b.Write() + require.NoError(err) + + // Modify recycled key + b, err = db.NewBatch() + require.NoError(err) + openBytes, rewrite = b.Prepare() + require.Equal(opBatchLen(), openBytes) + require.False(rewrite) + require.NoError(b.Insert(ctx, []byte("hello"), []byte("world2"))) + _, err = b.Write() + require.NoError(err) + + // Delete recycled key + b, err = db.NewBatch() + require.NoError(err) + openBytes, rewrite = b.Prepare() + require.Equal(opBatchLen(), openBytes) + require.False(rewrite) + require.NoError(b.Remove(ctx, []byte("world"))) + checksum, err := b.Write() + require.NoError(err) + keys, alive, useless := db.Usage() + + // Restart and ensure data is correct + require.NoError(db.Close()) + db, last, err = New(logger, baseDir, defaultInitialSize, 10, defaultBufferSize, 1) + require.NoError(err) + require.Equal(checksum, last) + v, err := db.GetValue(ctx, []byte("hello")) + require.NoError(err) + require.Equal([]byte("world2"), v) + _, err = db.GetValue(ctx, []byte("world")) + require.ErrorIs(err, database.ErrNotFound) + keys2, alive2, useless2 := db.Usage() + require.Equal(keys, keys2) + require.Equal(alive, alive2) + require.Equal(useless, useless2) +} + +func TestVilmoClearNullifyOnNew(t *testing.T) { + // Prepare + require := require.New(t) + ctx := context.TODO() + baseDir := t.TempDir() + logger := logging.NewLogger( + "appenddb", + logging.NewWrappedCore( + logging.Debug, + os.Stdout, + logging.Colors.ConsoleEncoder(), + ), + ) + logger.Info("created directory", zap.String("path", baseDir)) + + // Create + db, last, err := New(logger, baseDir, defaultInitialSize, 10, defaultBufferSize, 1) + require.NoError(err) + require.Equal(ids.Empty, last) + + // Insert key + b, err := db.NewBatch() + require.NoError(err) + openBytes, rewrite := b.Prepare() + require.Equal(opBatchLen(), openBytes) + require.False(rewrite) + require.NoError(b.Insert(ctx, []byte("hello"), []byte("world"))) + require.NoError(b.Insert(ctx, []byte("hello1"), []byte("world"))) + require.NoError(b.Insert(ctx, []byte("hello2"), []byte("world"))) + require.NoError(b.Insert(ctx, []byte("hello3"), []byte("world"))) + _, err = b.Write() + require.NoError(err) + + // Insert a batch gap (add nullifiers) + b, err = db.NewBatch() + require.NoError(err) + openBytes, rewrite = b.Prepare() + require.Equal(opBatchLen(), openBytes) + require.False(rewrite) + require.NoError(b.Remove(ctx, []byte("hello"))) + require.NoError(b.Remove(ctx, []byte("hello1"))) + require.NoError(b.Remove(ctx, []byte("hello2"))) + require.NoError(b.Remove(ctx, []byte("hello3"))) + _, err = b.Write() + require.NoError(err) + + // Rewrite batch + b, err = db.NewBatch() + require.NoError(err) + initBytes, rewrite := b.Prepare() + require.True(rewrite) + require.Equal(opBatchLen(), initBytes) + _, err = b.Write() + require.NoError(err) + require.Zero(len(db.batches[2].pendingNullify)) + require.NoError(db.Close()) +} + +func TestVilmoPrune(t *testing.T) { + // Prepare + require := require.New(t) + ctx := context.TODO() + baseDir := t.TempDir() + logger := logging.NewLogger( + "vilmo", + logging.NewWrappedCore( + logging.Debug, + os.Stdout, + logging.Colors.ConsoleEncoder(), + ), + ) + logger.Info("created directory", zap.String("path", baseDir)) + + // Create + db, last, err := New(logger, baseDir, defaultInitialSize, 10, defaultBufferSize, 10) + require.NoError(err) + require.Equal(ids.Empty, last) + + // Insert 100 batches + var lastBatch ids.ID + for i := 0; i < 100; i++ { + b, err := db.NewBatch() + require.NoError(err) + b.Prepare() + switch { + case i == 0: + // Never modify again + require.NoError(b.Insert(ctx, []byte("hello"), []byte("world"))) + case i < 99: + for j := 0; j < 10; j++ { + require.NoError(b.Insert(ctx, []byte(strconv.Itoa(j)), []byte(strconv.Itoa(i)))) + } + default: + require.NoError(b.Remove(ctx, []byte(strconv.Itoa(0)))) + } + lastBatch, err = b.Write() + require.NoError(err) + require.NotEqual(ids.Empty, lastBatch) + } + + // Ensure data is correct + v, err := db.GetValue(ctx, []byte("hello")) + require.NoError(err) + require.Equal([]byte("world"), v) + for i := 0; i < 10; i++ { + v, err = db.GetValue(ctx, []byte(strconv.Itoa(i))) + if i == 0 { + require.ErrorIs(err, database.ErrNotFound) + } else { + require.NoError(err) + require.Equal([]byte("98"), v) + } + } + + // Ensure files were pruned + keys, alive, useless := db.Usage() + require.NoError(db.Close()) + files, err := os.ReadDir(baseDir) + require.NoError(err) + require.Len(files, 11) // 10 historical batches + + // Restart + db, last, err = New(logger, baseDir, defaultInitialSize, 10, defaultBufferSize, 10) + require.NoError(err) + require.Equal(lastBatch, last) + keys2, alive2, useless2 := db.Usage() + require.Equal(keys, keys2) + require.Equal(alive, alive2) + require.Equal(useless, useless2) + + // Ensure data is correct after restart + v, err = db.GetValue(ctx, []byte("hello")) + require.NoError(err) + require.Equal([]byte("world"), v) + for i := 0; i < 10; i++ { + v, err = db.GetValue(ctx, []byte(strconv.Itoa(i))) + if i == 0 { + require.ErrorIs(err, database.ErrNotFound) + } else { + require.NoError(err) + require.Equal([]byte("98"), v) + } + } + + // Write to new batches + for i := 0; i < 10; i++ { + b, err := db.NewBatch() + require.NoError(err) + b.Prepare() + for j := 0; j < 10; j++ { + if i == 5 { + require.NoError(b.Remove(ctx, []byte(strconv.Itoa(j)))) + } else { + require.NoError(b.Insert(ctx, []byte(strconv.Itoa(j)), []byte(strconv.Itoa(i)))) + } + } + lastBatch, err = b.Write() + require.NoError(err) + require.NotEqual(ids.Empty, lastBatch) + } + keys, alive, useless = db.Usage() + require.NoError(db.Close()) + files, err = os.ReadDir(baseDir) + require.NoError(err) + require.Len(files, 11) // 10 historical batches + + // Read from new batches + db, last, err = New(logger, baseDir, defaultInitialSize, 10, defaultBufferSize, 10) + require.NoError(err) + require.Equal(lastBatch, last) + keys2, alive2, useless2 = db.Usage() + require.Equal(keys, keys2) + require.Equal(alive, alive2) + require.Equal(useless, useless2) + for i := 0; i < 10; i++ { + v, err = db.GetValue(ctx, []byte(strconv.Itoa(i))) + require.NoError(err) + require.Equal([]byte(strconv.Itoa(9)), v) + } + v, err = db.GetValue(ctx, []byte("hello")) + require.NoError(err) + require.Equal([]byte("world"), v) + require.NoError(db.Close()) +} + +func TestVilmoLarge(t *testing.T) { + for _, valueSize := range []int{32, minDiskValueSize * 2} { // ensure mem and mmap work + t.Run(fmt.Sprintf("valueSize=%d", valueSize), func(t *testing.T) { + // Prepare + require := require.New(t) + ctx := context.TODO() + baseDir := t.TempDir() + logger := logging.NewLogger( + "vilmo", + logging.NewWrappedCore( + logging.Debug, + os.Stdout, + logging.Colors.ConsoleEncoder(), + ), + ) + logger.Info("created directory", zap.String("path", baseDir)) + + // Create + batchSize := 10_000 + db, last, err := New(logger, baseDir, defaultInitialSize, batchSize, defaultBufferSize, 5) + require.NoError(err) + require.Equal(ids.Empty, last) + + // Write 1M unique keys in 10 batches + batches := 10 + keys, values := randomKeyValues(batches, batchSize, 32, 32, 0) + checksums := make([]ids.ID, batches) + for i := 0; i < batches; i++ { + b, err := db.NewBatch() + require.NoError(err) + openBytes, rewrite := b.Prepare() + require.Equal(opBatchLen(), openBytes) + require.False(rewrite) + for j := 0; j < batchSize; j++ { + require.NoError(b.Insert(ctx, keys[i][j], values[i][j])) + } + checksum, err := b.Write() + require.NoError(err) + require.Zero(len(db.batches[b.batch].pendingNullify)) + + // Ensure data is correct + for j := 0; j < batchSize; j++ { + v, err := db.GetValue(ctx, keys[i][j]) + require.NoError(err) + require.Equal(values[i][j], v) + } + checksums[i] = checksum + } + + // Restart + aliveKeys, aliveBytes, uselessBytes := db.Usage() + require.NoError(db.Close()) + db, last, err = New(logger, baseDir, defaultInitialSize, batchSize, defaultBufferSize, 5) + require.NoError(err) + require.Equal(checksums[batches-1], last) + aliveKeys2, aliveBytes2, uselessBytes2 := db.Usage() + require.Equal(aliveKeys, aliveKeys2) + require.Equal(aliveBytes, aliveBytes2) + require.Equal(uselessBytes, uselessBytes2) + + // Ensure data is correct after restart + for i := 0; i < batchSize; i++ { + v, err := db.GetValue(ctx, keys[9][i]) + require.NoError(err) + require.Equal(values[9][i], v) + } + require.NoError(db.Close()) + + // Create another database and ensure checksums match + db2, last, err := New(logger, t.TempDir(), defaultInitialSize, batchSize, defaultBufferSize, 5) + require.NoError(err) + require.Equal(ids.Empty, last) + for i := 0; i < batches; i++ { + b, err := db2.NewBatch() + require.NoError(err) + b.Prepare() + for j := 0; j < batchSize; j++ { + require.NoError(b.Insert(ctx, keys[i][j], values[i][j])) + } + checksum, err := b.Write() + require.NoError(err) + require.Equal(checksums[i], checksum) + } + }) + } +} + +func TestMMapReuse(t *testing.T) { + // Put value in file + require := require.New(t) + tdir := t.TempDir() + path := filepath.Join(tdir, "file") + f, err := os.Create(path) + require.NoError(err) + _, err = f.Write([]byte("hello")) + require.NoError(err) + require.NoError(f.Close()) + + // Read from file + m, err := mmap.Open(path) + require.NoError(err) + b := make([]byte, 5) + _, err = m.ReadAt(b, 0) + require.NoError(err) + require.Equal([]byte("hello"), b) + + // Write to file + f, err = os.OpenFile(path, os.O_WRONLY|os.O_APPEND, 0666) + require.NoError(err) + _, err = f.Write([]byte("world")) + require.NoError(err) + require.NoError(f.Close()) + + // Attempt to read from modified file (will fail) + _, err = m.ReadAt(b, 5) + require.ErrorIs(io.EOF, err) +} + +func BenchmarkVilmo(b *testing.B) { + ctx := context.TODO() + batches := 10 + for _, batchSize := range []int{25_000, 50_000, 100_000, 500_000, 1_000_000} { + for _, reuse := range []int{0, batchSize / 4, batchSize / 3, batchSize / 2, batchSize} { + for _, historyLen := range []int{1, 5, 10} { + for _, bufferSize := range []int{2 * units.KiB, 4 * units.KiB, defaultBufferSize, 4 * defaultBufferSize} { + keys, values := randomKeyValues(batches, batchSize, 32, 32, reuse) + b.Run(fmt.Sprintf("keys=%d reuse=%d history=%d buffer=%d", batchSize, reuse, historyLen, bufferSize), func(b *testing.B) { + for i := 0; i < b.N; i++ { + db, _, err := New(logging.NoLog{}, b.TempDir(), defaultInitialSize, batchSize, bufferSize, historyLen) + if err != nil { + b.Error(err) + } + for j := 0; j < batches; j++ { + batch, err := db.NewBatch() + if err != nil { + b.Error(err) + } + batch.Prepare() + for k := 0; k < batchSize; k++ { + if err := batch.Insert(ctx, keys[j][k], values[j][k]); err != nil { + b.Error(err) + } + } + if _, err = batch.Write(); err != nil { + b.Error(err) + } + } + if err := db.Close(); err != nil { + b.Error(err) + } + } + }) + } + } + } + } +} + +func BenchmarkPebbleDB(b *testing.B) { + batches := 10 + for _, batchSize := range []int{25_000, 50_000, 100_000, 500_000, 1_000_000} { + for _, reuse := range []int{0, batchSize / 4, batchSize / 3, batchSize / 2, batchSize} { + keys, values := randomKeyValues(batches, batchSize, 32, 32, reuse) + b.Run(fmt.Sprintf("keys=%d reuse=%d", batchSize, reuse), func(b *testing.B) { + for i := 0; i < b.N; i++ { + db, _, err := pebble.New(b.TempDir(), pebble.NewDefaultConfig()) + if err != nil { + b.Error(err) + } + for j := 0; j < batches; j++ { + batch := db.NewBatch() + for k := 0; k < batchSize; k++ { + if err := batch.Put(keys[j][k], values[j][k]); err != nil { + b.Error(err) + } + } + if err := batch.Write(); err != nil { + b.Error(err) + } + } + if err := db.Close(); err != nil { + b.Error(err) + } + } + }) + } + } +} + +func simpleRandomKeyValues(items int, size int) ([][]byte, [][]byte) { + keys := make([][]byte, items) + values := make([][]byte, items) + for i := 0; i < items; i++ { + k := make([]byte, size) + rand.Read(k) + keys[i] = k + v := make([]byte, size) + rand.Read(v) + values[i] = v + } + return keys, values +} + +type hasmapIterator struct { + hm *linked.Hashmap[string, []byte] +} + +func (hi *hasmapIterator) Iterate(f func(k string, v []byte) error) error { + iter := hi.hm.NewIterator() + for iter.Next() { + if err := f(iter.Key(), iter.Value()); err != nil { + return err + } + } + return nil +} + +func BenchmarkWriter(b *testing.B) { + var ( + items = 100_000 + pkeys, pvalues = simpleRandomKeyValues(items, 32) + ) + + b.Run("direct", func(b *testing.B) { + require := require.New(b) + for i := 0; i < b.N; i++ { + dir := b.TempDir() + f, err := os.Create(filepath.Join(dir, "file")) + require.NoError(err) + for j := 0; j < items; j++ { + _, err := f.Write(pkeys[j]) + require.NoError(err) + _, err = f.Write(pvalues[j]) + require.NoError(err) + } + require.NoError(f.Close()) + } + }) + + b.Run("bufio", func(b *testing.B) { + require := require.New(b) + for i := 0; i < b.N; i++ { + dir := b.TempDir() + f, err := os.Create(filepath.Join(dir, "file")) + require.NoError(err) + w := bufio.NewWriterSize(f, defaultBufferSize) + for j := 0; j < items; j++ { + _, err := w.Write(pkeys[j]) + require.NoError(err) + _, err = w.Write(pvalues[j]) + require.NoError(err) + } + require.NoError(w.Flush()) + require.NoError(f.Close()) + } + }) + + b.Run("no-block writer", func(b *testing.B) { + require := require.New(b) + for i := 0; i < b.N; i++ { + dir := b.TempDir() + f, err := os.Create(filepath.Join(dir, "file")) + require.NoError(err) + w := newWriter(f, 0, defaultBufferSize) + for j := 0; j < items; j++ { + w.Write(pkeys[j]) + w.Write(pvalues[j]) + } + require.NoError(w.Flush()) + require.NoError(f.Close()) + } + }) + + b.Run("batch", func(b *testing.B) { + require := require.New(b) + db, last, err := New(logging.NoLog{}, b.TempDir(), defaultInitialSize, 100_000, defaultBufferSize, 15) + require.NoError(err) + require.Equal(ids.Empty, last) + b.ResetTimer() + for i := 0; i < b.N; i++ { + b, err := db.NewBatch() + require.NoError(err) + b.Prepare() + for j := 0; j < items; j++ { + require.NoError(b.Insert(context.TODO(), pkeys[j], pvalues[j])) + } + _, err = b.Write() + require.NoError(err) + } + require.NoError(db.Close()) + }) + + hm := linked.NewHashmap[string, []byte]() + for i := 0; i < items; i++ { + hm.Put(string(pkeys[i]), pvalues[i]) + } + b.Run("hashmap", func(b *testing.B) { + require := require.New(b) + db, last, err := New(logging.NoLog{}, b.TempDir(), defaultInitialSize, 100_000, defaultBufferSize, 15) + require.NoError(err) + require.Equal(ids.Empty, last) + b.ResetTimer() + for i := 0; i < b.N; i++ { + b, err := db.NewBatch() + require.NoError(err) + b.Prepare() + iter := hm.NewIterator() + for iter.Next() { + require.NoError(b.Insert(context.TODO(), []byte(iter.Key()), iter.Value())) + } + _, err = b.Write() + require.NoError(err) + } + require.NoError(db.Close()) + }) + + hm2 := linked.NewHashmap[string, []byte]() + for i := 0; i < items; i++ { + hm2.Put(string(pkeys[i]), pvalues[i]) + } + hmi := &hasmapIterator{hm: hm2} + b.Run("iterate", func(b *testing.B) { + require := require.New(b) + db, last, err := New(logging.NoLog{}, b.TempDir(), defaultInitialSize, 100_000, defaultBufferSize, 15) + require.NoError(err) + require.Equal(ids.Empty, last) + b.ResetTimer() + for i := 0; i < b.N; i++ { + b, err := db.NewBatch() + require.NoError(err) + b.Prepare() + require.NoError(hmi.Iterate(func(k string, v []byte) error { + return b.Insert(context.TODO(), []byte(k), v) + })) + _, err = b.Write() + require.NoError(err) + } + require.NoError(db.Close()) + }) + + smap := smap.New[[]byte](100_000) + for i, key := range pkeys { + smap.Put(string(key), pvalues[i]) + } + b.Run("smap", func(b *testing.B) { + require := require.New(b) + db, last, err := New(logging.NoLog{}, b.TempDir(), defaultInitialSize, 100_000, defaultBufferSize, 15) + require.NoError(err) + require.Equal(ids.Empty, last) + b.ResetTimer() + for i := 0; i < b.N; i++ { + b, err := db.NewBatch() + require.NoError(err) + b.Prepare() + for _, key := range pkeys { + value, _ := smap.Get(string(key)) + require.NoError(b.Insert(context.TODO(), key, value)) + } + _, err = b.Write() + require.NoError(err) + } + require.NoError(db.Close()) + }) + + ts := tstate.New(100_000 * 2) + for i := 0; i < 10; i++ { + ts.PrepareChunk(i, 100_000) // purposely add gaps + for j := 0; j < 10_000; j++ { + tsv := ts.NewWriteView(i, 10_000+j) // purposely add gap + tsv.Put(context.TODO(), pkeys[i*10_000+j], pvalues[i*10_000+j]) + tsv.Put(context.TODO(), []byte("blah"), []byte("blah")) // ensure multiple values + tsv.Commit() + } + } + b.Run("tstate", func(b *testing.B) { + require := require.New(b) + db, last, err := New(logging.NoLog{}, b.TempDir(), defaultInitialSize, 100_000, defaultBufferSize, 15) + require.NoError(err) + require.Equal(ids.Empty, last) + b.ResetTimer() + for i := 0; i < b.N; i++ { + b, err := db.NewBatch() + require.NoError(err) + b.Prepare() + require.NoError(ts.Iterate(func(k []byte, v maybe.Maybe[[]byte]) error { + if v.IsNothing() { + return b.Remove(context.TODO(), []byte(k)) + } else { + return b.Insert(context.TODO(), []byte(k), v.Value()) + } + })) + _, err = b.Write() + require.NoError(err) + } + require.NoError(db.Close()) + }) +} + +func BenchmarkMapUpdate(b *testing.B) { + m := make(map[string][]byte, 10_000_000) + pkeys, pvalues := simpleRandomKeyValues(10_000_000, 32) + for i := 0; i < 10_000_000; i++ { + m[string(pkeys[i])] = pvalues[i] + } + pkeysu, pvaluesu := simpleRandomKeyValues(100_000, 32) + b.ResetTimer() + for j := 0; j < 100_000; j++ { + m[string(pkeysu[j])] = pvaluesu[j] + } +} + +func BenchmarkBatchRewrite(b *testing.B) { + require := require.New(b) + keys, values := randomKeyValues(30, 100_000, 32, 32, 100_000) + db, last, err := New(logging.NoLog{}, b.TempDir(), defaultInitialSize, 100_000, defaultBufferSize, 15) + require.NoError(err) + require.Equal(ids.Empty, last) + + b.Run("initial", func(b *testing.B) { + batch, err := db.NewBatch() + require.NoError(err) + batch.Prepare() + for i := 0; i < 100_000; i++ { + require.NoError(batch.Insert(context.TODO(), keys[0][i], values[0][i])) + } + _, err = batch.Write() + require.NoError(err) + }) + + for j := 1; j < 30; j++ { + b.Run(fmt.Sprintf("rewrite=%d", j), func(b *testing.B) { + batch, err := db.NewBatch() + require.NoError(err) + batch.Prepare() + for i := 0; i < 100_000; i++ { + require.NoError(batch.Insert(context.TODO(), keys[j][i], values[j][i])) + } + _, err = batch.Write() + require.NoError(err) + }) + + require.NoError(db.Close()) + } +} diff --git a/vilmo/writer.go b/vilmo/writer.go new file mode 100644 index 0000000000..72671f815c --- /dev/null +++ b/vilmo/writer.go @@ -0,0 +1,72 @@ +package vilmo + +import ( + "os" + "sync" + + "golang.org/x/sync/errgroup" +) + +type writer struct { + f *os.File + offset int64 + + errg *errgroup.Group + pool sync.Pool + + bufferSize int + buf []byte + pos int +} + +func newWriter(f *os.File, start int64, bufferSize int) *writer { + w := &writer{f: f, offset: start, errg: &errgroup.Group{}, bufferSize: bufferSize} + w.pool.New = func() interface{} { + // Pool must return pointers to actually avoid memory allocations + b := make([]byte, bufferSize) + return &b + } + pbuf := w.pool.Get().(*[]byte) + w.buf = (*pbuf) + return w +} + +func (w *writer) Write(data []byte) { + dataPos := 0 + for dataPos < len(data) { + // Add data we can write to buffer + space := w.bufferSize - w.pos + writable := min(space, len(data)-dataPos) + copy(w.buf[w.pos:], data[dataPos:dataPos+writable]) + w.pos += writable + dataPos += writable + + // If the buffer is full, write it to disk at offset + if w.pos == w.bufferSize { + buf := w.buf + offset := w.offset + w.errg.Go(func() error { + _, err := w.f.WriteAt(buf, offset) + w.pool.Put(&buf) + return err + }) + w.offset += int64(w.pos) + w.pos = 0 + + // Attempt to reuse buffer bytes once previous writes complete + pbuf := w.pool.Get().(*[]byte) + w.buf = (*pbuf) + } + } +} + +func (w *writer) Flush() error { + // If we have written to the current buffer, write it to disk + if w.pos > 0 { + w.errg.Go(func() error { + _, err := w.f.WriteAt(w.buf[:w.pos], w.offset) + return err + }) + } + return w.errg.Wait() +} diff --git a/vm/chunk_authorizer.go b/vm/chunk_authorizer.go new file mode 100644 index 0000000000..4cea7c4b56 --- /dev/null +++ b/vm/chunk_authorizer.go @@ -0,0 +1,176 @@ +package vm + +import ( + "time" + + "github.com/AnomalyFi/hypersdk/chain" + "github.com/AnomalyFi/hypersdk/eheap" + "github.com/AnomalyFi/hypersdk/workers" + "github.com/ava-labs/avalanchego/ids" + "go.uber.org/zap" +) + +var ( + t = true + f = false + truePtr = &t + falsePtr = &f +) + +type job struct { + chunk *chain.Chunk + result *bool + done chan struct{} +} + +func (j *job) ID() ids.ID { return j.chunk.ID() } +func (j *job) Expiry() int64 { return j.chunk.Slot } + +type ChunkAuthorizer struct { + vm *VM + authWorkers workers.Workers + + jobs *eheap.ExpiryHeap[*job] + + required chan ids.ID + optimistic chan ids.ID +} + +func NewChunkAuthorizer(vm *VM) *ChunkAuthorizer { + return &ChunkAuthorizer{ + vm: vm, + jobs: eheap.New[*job](128), + required: make(chan ids.ID, 128), + optimistic: make(chan ids.ID, 128), + } +} + +func (c *ChunkAuthorizer) Run() { + c.authWorkers = workers.NewParallel(c.vm.GetAuthExecutionCores(), 4) + defer c.authWorkers.Stop() + + for { + // Exit if the VM is shutting down + select { + case <-c.vm.stop: + return + default: + } + + // Check if there are any jobs that should be authorized required + select { + case id := <-c.required: + c.auth(id) + continue + default: + } + + // Wait for either a priortized cert or any optimistic cert + select { + case id := <-c.required: + c.auth(id) + case id := <-c.optimistic: + c.auth(id) + case <-c.vm.stop: + return + } + } +} + +func (c *ChunkAuthorizer) auth(id ids.ID) { + result, ok := c.jobs.Get(id) + if !ok { + // This can happen if chunk is expired before we get to it + c.vm.Logger().Debug("skipping missing job", zap.Stringer("chunkID", id)) + return + } + if result.result != nil { + // Can happen if a cert is in [required] and [optimistic] + return + } + if !c.vm.config.GetVerifyAuth() || c.vm.snowCtx.NodeID == result.chunk.Producer { // trust ourself + result.result = truePtr + close(result.done) + return + } + + // Record time it takes to authorize the chunk + start := time.Now() + defer func() { + c.vm.metrics.chunkAuth.Observe(float64(time.Since(start))) + }() + + chunk := result.chunk + authJob, err := c.authWorkers.NewJob(len(chunk.Txs)) + if err != nil { + panic(err) + } + batchVerifier := chain.NewAuthBatch(c.vm, authJob, chunk.AuthCounts()) + for _, tx := range chunk.Txs { + // Enqueue transaction for execution + msg, err := tx.Digest() + if err != nil { + c.vm.Logger().Error("chunk failed auth", zap.Stringer("chunk", chunk.ID()), zap.Error(err)) + result.result = falsePtr + close(result.done) + return + } + if c.vm.IsRPCAuthorized(tx.ID()) { + c.vm.RecordRPCAuthorizedTx() + continue + } + + // We can only pre-check transactions that would invalidate the chunk prior to verifying signatures. + batchVerifier.Add(msg, tx.Auth) + } + batchVerifier.Done(nil) + if err := authJob.Wait(); err != nil { + c.vm.Logger().Error("chunk failed auth", zap.Stringer("chunk", chunk.ID()), zap.Error(err)) + result.result = falsePtr + } else { + c.vm.Logger().Debug("chunk authorized", zap.Stringer("chunk", chunk.ID())) + result.result = truePtr + } + close(result.done) +} + +// It is safe to call [Add] multiple times with the same chunk +func (c *ChunkAuthorizer) Add(chunk *chain.Chunk) { + // Check if already added + c.jobs.Add(&job{ + chunk: chunk, + done: make(chan struct{}), + }) + + // Queue chunk for authorization + c.optimistic <- chunk.ID() +} + +// It is not safe to call [Wait] multiple times with the same chunk or to call +// it concurrently. +func (c *ChunkAuthorizer) Wait(id ids.ID) bool { + result, ok := c.jobs.Get(id) + if !ok { + // This panic would also catch if a chunk was included twice + panic("waiting on certificate that wasn't enqueued") + } + + // If cert auth is not done yet, make sure to prioritize it + c.required <- id + + // Wait for result + <-result.done + + // Remove chunk from tracking, it will never be requested again + c.jobs.Remove(id) + return *result.result +} + +func (c *ChunkAuthorizer) SetMin(t int64) []ids.ID { + elems := c.jobs.SetMin(t) + items := make([]ids.ID, len(elems)) + for i, elem := range elems { + items[i] = elem.ID() + } + return items +} diff --git a/vm/chunk_manager.go b/vm/chunk_manager.go new file mode 100644 index 0000000000..0fb0d653e0 --- /dev/null +++ b/vm/chunk_manager.go @@ -0,0 +1,1590 @@ +package vm + +import ( + "bytes" + "context" + "encoding/binary" + "encoding/hex" + "errors" + "fmt" + "sync" + "time" + + "github.com/AnomalyFi/hypersdk/cache" + "github.com/AnomalyFi/hypersdk/chain" + "github.com/AnomalyFi/hypersdk/codec" + "github.com/AnomalyFi/hypersdk/consts" + "github.com/AnomalyFi/hypersdk/eheap" + "github.com/AnomalyFi/hypersdk/emap" + "github.com/AnomalyFi/hypersdk/opool" + "github.com/AnomalyFi/hypersdk/utils" + "github.com/AnomalyFi/hypersdk/workers" + "github.com/ava-labs/avalanchego/ids" + "github.com/ava-labs/avalanchego/snow/engine/common" + "github.com/ava-labs/avalanchego/utils/buffer" + "github.com/ava-labs/avalanchego/utils/crypto/bls" + "github.com/ava-labs/avalanchego/utils/set" + "github.com/ava-labs/avalanchego/utils/units" + "github.com/ava-labs/avalanchego/version" + "github.com/ava-labs/avalanchego/vms/platformvm/warp" + "go.uber.org/zap" + "golang.org/x/exp/maps" + "golang.org/x/sync/errgroup" +) + +const ( + chunkMsg uint8 = 0x0 + chunkSignatureMsg uint8 = 0x1 + chunkCertificateMsg uint8 = 0x2 + chunkCertifiedMsg uint8 = 0x3 // chunk + chunk certificate + txMsg uint8 = 0x4 + + chunkReq uint8 = 0x0 + // TODO: add support for filtered chunk requests + + minWeightNumerator = 67 + weightDenominator = 100 + + // While the most efficient size to send over the network is ~MTU, the overhead + // of handling messages causes performance degradation (high CPU usage) at high-throughput. + // A byproduct of larger batches, however, is that compression is more efficient and we + // can better leverage batch signature verification. + gossipTxTargetSize = 256 * units.KiB + gossipTxPrealloc = 2_048 + gossipBatchWait = 100 * time.Millisecond +) + +type simpleChunkWrapper struct { + chunk ids.ID + slot int64 +} + +func (scw *simpleChunkWrapper) ID() ids.ID { + return scw.chunk +} + +func (scw *simpleChunkWrapper) Expiry() int64 { + return scw.slot +} + +type chunkWrapper struct { + l sync.Mutex + + sent time.Time + chunk *chain.Chunk + signatures map[string]*chain.ChunkSignature +} + +func (cw *chunkWrapper) ID() ids.ID { + return cw.chunk.ID() +} + +func (cw *chunkWrapper) Expiry() int64 { + return cw.chunk.Slot +} + +type seenWrapper struct { + chunkID ids.ID + expiry int64 + seen int64 +} + +func (sw *seenWrapper) ID() ids.ID { + return sw.chunkID +} + +func (sw *seenWrapper) Expiry() int64 { + return sw.expiry +} + +type certWrapper struct { + cert *chain.ChunkCertificate + seen int64 +} + +func (cw *certWrapper) ID() ids.ID { + return cw.cert.ID() +} + +// Expiry here is really the time we first saw, +// which is used to ensure we pop it first when iterating over +// the heap. +// +// TODO: change name of [Expiry] to something more generic +func (cw *certWrapper) Expiry() int64 { + return cw.seen +} + +// Store fifo chunks for building +type CertStore struct { + l sync.Mutex + + minTime int64 + seen *eheap.ExpiryHeap[*seenWrapper] // only cleared on expiry + eh *eheap.ExpiryHeap[*certWrapper] // cleared when we build, updated when we get a more useful cert +} + +func NewCertStore() *CertStore { + return &CertStore{ + seen: eheap.New[*seenWrapper](64), // TODO: add a config + eh: eheap.New[*certWrapper](64), // TODO: add a config + } +} + +// We never want to drop a chunk with more signatures, so this isn't an identical implementation +// to the tx repeat case. Because block building is fast, we don't need to worry about this impacting +// performance too much. +func (c *CertStore) StartStream() { + c.l.Lock() +} + +// Stream removes and returns the highest valued item in m.eh. +// +// Stream assumes the lock is already held. +func (c *CertStore) Stream(ctx context.Context) (*chain.ChunkCertificate, bool) { // O(log N) + first, ok := c.eh.PopMin() + if !ok { + return nil, false + } + return first.cert, true +} + +func (c *CertStore) FinishStream(certs []*chain.ChunkCertificate) { + c.l.Unlock() + for _, cert := range certs { + c.Update(cert) + } +} + +// Called when we get a valid cert or if a block is rejected with valid certs. +// +// If called more than once for the same ChunkID, the cert will be updated if it has +// more signers. +// +// TODO: update if more weight rather than using signer heuristic? +func (c *CertStore) Update(cert *chain.ChunkCertificate) { + c.l.Lock() + defer c.l.Unlock() + + // Only keep certs around that could be included. + if cert.Slot < c.minTime { + return + } + + // Record the first time we saw this certificate, so we can properly + // sort during building. + firstSeen := int64(0) + v, ok := c.seen.Get(cert.ID()) + if !ok { + firstSeen = time.Now().UnixMilli() + c.seen.Add(&seenWrapper{ + chunkID: cert.ID(), + expiry: cert.Slot, + seen: firstSeen, + }) + } else { + firstSeen = v.seen + } + + // Store the certificate + elem, ok := c.eh.Get(cert.ID()) + if !ok { + c.eh.Add(&certWrapper{ + cert: cert, + seen: firstSeen, + }) + return + } + // If the existing certificate has more signers than the + // new certificate, don't update. + // + // TODO: we should use weight here, not just number of signers + if elem.cert.Signers.Len() > cert.Signers.Len() { + return + } + elem.cert = cert + c.eh.Update(elem) +} + +// Called when a block is accepted with valid certs. +func (c *CertStore) SetMin(ctx context.Context, t int64) []*chain.ChunkCertificate { + c.l.Lock() + defer c.l.Unlock() + + c.minTime = t + removedElems := c.seen.SetMin(t) + certs := make([]*chain.ChunkCertificate, 0, len(removedElems)) + for _, removed := range removedElems { + cw, ok := c.eh.Remove(removed.ID()) + if !ok { + continue + } + certs = append(certs, cw.cert) + } + return certs +} + +func (c *CertStore) Get(cid ids.ID) (*chain.ChunkCertificate, bool) { + c.l.Lock() + defer c.l.Unlock() + + elem, ok := c.eh.Get(cid) + if !ok { + return nil, false + } + return elem.cert, true +} + +// TODO: move to standalone package +type ChunkManager struct { + vm *VM + done chan struct{} + + appSender common.AppSender + incomingChunks chan *chunkGossipWrapper + incomingTxs chan *txGossipWrapper + + epochHeights *cache.FIFO[uint64, uint64] + + built *emap.EMap[*chunkWrapper] + // TODO: rebuild stored on startup + stored *eheap.ExpiryHeap[*simpleChunkWrapper] // tracks all chunks we've stored, so we can ensure even unused are deleted + + // TODO: track which chunks we've received per nodeID per slot + + certs *CertStore + auth *ChunkAuthorizer + + // Ensures that only one request job is running at a time + waiterL sync.Mutex + waiter chan struct{} + + // Handles concurrent fetching of chunks + callbacksL sync.Mutex + requestID uint32 + callbacks map[uint32]func([]byte) + + // connected includes all connected nodes, not just those that are validators (we use + // this for sending txs/requesting chunks from only connected nodes) + connectedL sync.Mutex + connected set.Set[ids.NodeID] + + txL sync.Mutex + txQueue buffer.Deque[*txGossip] + txNodes map[ids.NodeID]*txGossip +} + +type txGossip struct { + nodeID ids.NodeID + txs map[ids.ID]*chain.Transaction + size int + expiry time.Time + sent bool +} + +func NewChunkManager(vm *VM) *ChunkManager { + epochHeights, err := cache.NewFIFO[uint64, uint64](48) + if err != nil { + panic(err) + } + return &ChunkManager{ + vm: vm, + done: make(chan struct{}), + + incomingChunks: make(chan *chunkGossipWrapper, vm.config.GetChunkStorageBacklog()), + incomingTxs: make(chan *txGossipWrapper, vm.config.GetAuthGossipBacklog()), + + epochHeights: epochHeights, + + built: emap.NewEMap[*chunkWrapper](), + stored: eheap.New[*simpleChunkWrapper](64), + + certs: NewCertStore(), + auth: NewChunkAuthorizer(vm), + + callbacks: make(map[uint32]func([]byte)), + + connected: set.NewSet[ids.NodeID](64), // TODO: make a const + + // TODO: move to separate package + txQueue: buffer.NewUnboundedDeque[*txGossip](64), // TODO: make a const + txNodes: make(map[ids.NodeID]*txGossip), + } +} + +func (c *ChunkManager) getEpochInfo(ctx context.Context, t int64) (uint64, uint64, error) { + r := c.vm.Rules(time.Now().UnixMilli()) + epoch := utils.Epoch(t, r.GetEpochDuration()) + if h, ok := c.epochHeights.Get(epoch); ok { + return epoch, h, nil + } + _, heights, err := c.vm.Engine().GetEpochHeights(ctx, []uint64{epoch}) + if err != nil { + return 0, 0, err + } + if heights[0] == nil { + return 0, 0, errors.New("epoch is not yet set") + } + c.epochHeights.Put(epoch, *heights[0]) + return epoch, *heights[0], nil +} + +func (c *ChunkManager) Connected(_ context.Context, nodeID ids.NodeID, _ *version.Application) error { + c.connectedL.Lock() + defer c.connectedL.Unlock() + + c.connected.Add(nodeID) + return nil +} + +func (c *ChunkManager) Disconnected(_ context.Context, nodeID ids.NodeID) error { + c.connectedL.Lock() + defer c.connectedL.Unlock() + + c.connected.Remove(nodeID) + return nil +} + +func (c *ChunkManager) PushSignature(ctx context.Context, nodeID ids.NodeID, sig *chain.ChunkSignature) { + msg := make([]byte, 1+sig.Size()) + msg[0] = chunkSignatureMsg + sigBytes, err := sig.Marshal() + if err != nil { + c.vm.Logger().Warn("failed to marshal chunk", zap.Error(err)) + return + } + copy(msg[1:], sigBytes) + c.appSender.SendAppGossip(ctx, common.SendConfig{NodeIDs: set.Of(nodeID)}, msg) // skips validators we aren't connected to +} + +func (c *ChunkManager) VerifyAndStoreChunk(ctx context.Context, nodeID ids.NodeID, chunk *chain.Chunk) error { + // Check if we already received + if c.stored.Has(chunk.ID()) { + c.vm.metrics.gossipChunkInvalid.Inc() + return errors.New("already received chunk") + } + + // Check if chunk < slot + if chunk.Slot < c.vm.lastAccepted.StatefulBlock.Timestamp { + c.vm.metrics.gossipChunkInvalid.Inc() + return errors.New("dropping expired chunk") + } + + // Check that producer is the sender + if chunk.Producer != nodeID { + c.vm.metrics.gossipChunkInvalid.Inc() + return errors.New("dropping chunk gossip that isn't from producer") + } + + // Determine if chunk producer is a validator and that their key is valid + _, epochHeight, err := c.getEpochInfo(ctx, chunk.Slot) + if err != nil { + c.vm.metrics.gossipChunkInvalid.Inc() + return fmt.Errorf("%w: unable to determine chunk epoch", err) + } + isValidator, signerKey, _, err := c.vm.proposerMonitor.IsValidator(ctx, epochHeight, chunk.Producer) + if err != nil { + c.vm.metrics.gossipChunkInvalid.Inc() + return errors.New("unable to determine if producer is validator") + } + if !isValidator { + c.vm.metrics.gossipChunkInvalid.Inc() + return errors.New("dropping chunk gossip from non-validator") + } + if signerKey == nil || !bytes.Equal(bls.PublicKeyToCompressedBytes(chunk.Signer), bls.PublicKeyToCompressedBytes(signerKey)) { + c.vm.metrics.gossipChunkInvalid.Inc() + return errors.New("dropping validator signed chunk with wrong key") + } + + // Verify signature of chunk + if !chunk.VerifySignature(c.vm.snowCtx.NetworkID, c.vm.snowCtx.ChainID) { + c.vm.metrics.gossipChunkInvalid.Inc() + return errors.New("dropping chunk with invalid signature") + } + + // TODO: only store 1 chunk per slot per validator + // TODO: warn if chunk is dropped for a conflict during fetching (same producer, slot, different chunkID) + + // Persist chunk to disk (delete if not used in time but need to store to protect + // against shutdown risk across network -> chunk may no longer be accessible after included + // in referenced certificate) + if err := c.vm.StoreChunk(chunk); err != nil { + return fmt.Errorf("%w: unable to persist chunk to disk", err) + } + c.stored.Add(&simpleChunkWrapper{chunk: chunk.ID(), slot: chunk.Slot}) + return nil +} + +func (c *ChunkManager) VerifyAndTrackCertificate(ctx context.Context, cert *chain.ChunkCertificate) error { + // Determine epoch for certificate + _, epochHeight, err := c.getEpochInfo(ctx, cert.Slot) + if err != nil { + c.vm.metrics.gossipCertInvalid.Inc() + return fmt.Errorf("%w: unable to determine certificate epoch", err) + } + + // Verify certificate using the epoch validator set + aggrPubKey, err := c.vm.proposerMonitor.GetAggregatePublicKey(ctx, epochHeight, cert.Signers, minWeightNumerator, weightDenominator) + if err != nil { + c.vm.metrics.gossipCertInvalid.Inc() + return fmt.Errorf("%w: unable to get aggregate public key", err) + } + if !cert.VerifySignature(c.vm.snowCtx.NetworkID, c.vm.snowCtx.ChainID, aggrPubKey) { + c.vm.metrics.gossipCertInvalid.Inc() + return errors.New("dropping invalid certificate") + } + c.vm.metrics.certsReceived.Inc() + + // If we don't have the chunk, we wait to fetch it until the certificate is included in an accepted block. + + // TODO: if this certificate conflicts with a chunk we signed, post the conflict (slashable fault) + + // Store chunk certificate for building + c.certs.Update(cert) + return nil +} + +func (c *ChunkManager) AppGossip(ctx context.Context, nodeID ids.NodeID, msg []byte) error { + start := time.Now() + if len(msg) == 0 { + c.vm.Logger().Warn("dropping empty message", zap.Stringer("nodeID", nodeID)) + return nil + } + switch msg[0] { + case chunkMsg: + c.vm.metrics.chunksReceived.Inc() + chunk, err := chain.UnmarshalChunk(msg[1:], c.vm) + if err != nil { + c.vm.metrics.gossipChunkInvalid.Inc() + c.vm.Logger().Warn("unable to unmarshal chunk", zap.Stringer("nodeID", nodeID), zap.String("chunk", hex.EncodeToString(msg[1:])), zap.Error(err)) + return nil + } + + // Enqueue chunk for verification, if we have a backlog drop it + c.vm.metrics.gossipChunkBacklog.Inc() + select { + case c.incomingChunks <- &chunkGossipWrapper{nodeID: nodeID, chunk: chunk}: + // TODO: dedup incoming chunks to prevent backlog on same chunk (would block filedb) + default: + // TODO: track backlog by nodeID + c.vm.Logger().Warn("dropping chunk gossip because too big of a backlog", zap.Stringer("nodeID", nodeID)) + c.vm.metrics.gossipChunkBacklog.Dec() + c.vm.metrics.chunkGossipDropped.Inc() + } + + c.vm.Logger().Debug( + "received chunk from gossip", + zap.Stringer("chunkID", chunk.ID()), + zap.Stringer("nodeID", nodeID), + zap.Int("size", len(msg)-1), + zap.Duration("t", time.Since(start)), + ) + case chunkSignatureMsg: + c.vm.metrics.sigsReceived.Inc() + chunkSignature, err := chain.UnmarshalChunkSignature(msg[1:]) + if err != nil { + c.vm.metrics.gossipChunkSigInvalid.Inc() + c.vm.Logger().Warn("dropping chunk gossip from non-validator", zap.Stringer("nodeID", nodeID), zap.Error(err)) + return nil + } + + // Check if we broadcast this chunk + cw, ok := c.built.Get(chunkSignature.Chunk) + if !ok || cw.chunk.Slot != chunkSignature.Slot { + c.vm.metrics.gossipChunkSigInvalid.Inc() + c.vm.Logger().Warn("dropping useless chunk signature", zap.Bool("built", ok), zap.Stringer("nodeID", nodeID), zap.Stringer("chunkID", chunkSignature.Chunk)) + return nil + } + + // Determine if chunk signer is a validator and that their key is valid + _, epochHeight, err := c.getEpochInfo(ctx, chunkSignature.Slot) + if err != nil { + c.vm.metrics.gossipChunkSigInvalid.Inc() + c.vm.Logger().Warn("unable to determine chunk epoch", zap.Int64("slot", chunkSignature.Slot)) + return nil + } + isValidator, signerKey, _, err := c.vm.proposerMonitor.IsValidator(ctx, epochHeight, nodeID) + if err != nil { + c.vm.metrics.gossipChunkSigInvalid.Inc() + c.vm.Logger().Warn("unable to determine if signer is validator", zap.Stringer("signer", nodeID), zap.Error(err)) + return nil + } + if !isValidator { + c.vm.metrics.gossipChunkSigInvalid.Inc() + c.vm.Logger().Warn("dropping chunk signature from non-validator", zap.Stringer("nodeID", nodeID)) + return nil + } + if signerKey == nil || !bytes.Equal(bls.PublicKeyToCompressedBytes(chunkSignature.Signer), bls.PublicKeyToCompressedBytes(signerKey)) { + c.vm.metrics.gossipChunkSigInvalid.Inc() + c.vm.Logger().Warn("dropping validator signed chunk with wrong key", zap.Stringer("nodeID", nodeID)) + return nil + } + + // Verify signature + if !chunkSignature.VerifySignature(c.vm.snowCtx.NetworkID, c.vm.snowCtx.ChainID) { + c.vm.metrics.gossipChunkSigInvalid.Inc() + c.vm.Logger().Warn("dropping chunk signature with invalid signature", zap.Stringer("nodeID", nodeID)) + return nil + } + + // Store signature for chunk + cw.l.Lock() + cw.signatures[string(bls.PublicKeyToCompressedBytes(chunkSignature.Signer))] = chunkSignature // canonical validator set requires fetching signature by bls public key + + // Count pending weight + var weight uint64 + vdrList, totalWeight, err := c.vm.proposerMonitor.GetWarpValidatorSet(ctx, epochHeight) + if err != nil { + panic(err) + } + for _, vdr := range vdrList { + k := string(bls.PublicKeyToCompressedBytes(vdr.PublicKey)) + if _, ok := cw.signatures[k]; ok { + weight += vdr.Weight // cannot overflow + } + } + cw.l.Unlock() + + // Check if weight is sufficient + // + // Fees are proportional to the weight of the chunk, so we may want to wait until it has more than the minimum. + // + // TODO: add a timeout here in case we never get above target + if err := warp.VerifyWeight(weight, totalWeight, c.vm.config.GetMinimumCertificateBroadcastNumerator(), weightDenominator); err != nil { + c.vm.Logger().Debug("chunk does not have sufficient weight to create certificate", zap.Stringer("chunkID", chunkSignature.Chunk), zap.Error(err)) + return nil + } + + // Record time to collect and then reset to ensure we don't log multiple times + firstCertificate := !cw.sent.IsZero() + if firstCertificate { + c.vm.metrics.collectChunkSignatures.Observe(float64(time.Since(cw.sent))) + cw.sent = time.Time{} + } + + // Construct certificate + canonicalValidators, _, err := c.vm.proposerMonitor.GetWarpValidatorSet(ctx, epochHeight) + if err != nil { + c.vm.Logger().Warn("cannot get canonical validator set", zap.Error(err)) + return nil + } + signers := set.NewBits() + orderedSignatures := []*bls.Signature{} + cw.l.Lock() + for i, vdr := range canonicalValidators { + sig, ok := cw.signatures[string(bls.PublicKeyToCompressedBytes(vdr.PublicKey))] + if !ok { + continue + } + signers.Add(i) + orderedSignatures = append(orderedSignatures, sig.Signature) + } + cw.l.Unlock() + aggSignature, err := bls.AggregateSignatures(orderedSignatures) + if err != nil { + c.vm.Logger().Warn("cannot generate aggregate signature", zap.Error(err)) + return nil + } + + // Construct and update stored certificate + cert := &chain.ChunkCertificate{ + Chunk: chunkSignature.Chunk, + Slot: chunkSignature.Slot, + + Signers: signers, + Signature: aggSignature, + } + c.vm.Logger().Debug( + "constructed chunk certificate", + zap.Uint64("Pheight", epochHeight), + zap.Stringer("chunkID", chunkSignature.Chunk), + zap.Uint64("weight", weight), + zap.Uint64("totalWeight", totalWeight), + zap.Duration("t", time.Since(start)), + ) + + // We don't send our own certs to the optimistic verifier because we + // don't verify the signatures in those chunks anyways. + c.certs.Update(cert) + + // Broadcast certificate to validators (we will broadcast each time the number of signers goes up) + c.PushChunkCertificate(ctx, cert) + + // Send chunk and cert to non-validators (this will ensure they don't need to fetch chunks and that + // will allow them to kickoff signature verification earlier). + // + // TODO: limit number of nodes we send to/use a whitelist to avoid + // a sybil attack on our bandwidth (that being said, they'll try to request + // these from us anyways). + if !firstCertificate { + return nil + } + c.PushCertifiedChunk(ctx, cw.chunk, cert) + case chunkCertificateMsg: + cert, err := chain.UnmarshalChunkCertificate(msg[1:]) + if err != nil { + c.vm.metrics.gossipCertInvalid.Inc() + c.vm.Logger().Warn("dropping chunk gossip from non-validator", zap.Stringer("nodeID", nodeID), zap.Error(err)) + return nil + } + + // Handle cert verification + if err := c.VerifyAndTrackCertificate(ctx, cert); err != nil { + c.vm.Logger().Warn("unable to verify and store certificate", zap.Stringer("nodeID", nodeID), zap.Stringer("chunkID", cert.Chunk), zap.Error(err)) + return nil + } + + // Attempt to add chunk to the optimistic verifier + // + // If the chunk has already been added, we just skip it. + // + // This operation should be cached, so it should be fast. + chunk, err := c.vm.GetChunk(cert.Slot, cert.Chunk) + if chunk == nil { + c.vm.Logger().Debug("skipping optimistic chunk auth because chunk is missing", zap.Stringer("chunkID", cert.Chunk), zap.Error(err)) + return nil + } + c.auth.Add(chunk) + case chunkCertifiedMsg: + chunk, cert, err := parseCertifiedChunkMsg(msg, c.vm) + if err != nil { + c.vm.Logger().Warn("dropping invalid certified chunk", zap.Stringer("nodeID", nodeID), zap.Error(err)) + return nil + } + + // Enqueue chunk and cert for verification, if we have a backlog drop it + c.vm.metrics.gossipChunkBacklog.Inc() + select { + case c.incomingChunks <- &chunkGossipWrapper{nodeID: nodeID, chunk: chunk, cert: cert}: + // TODO: dedup incoming chunks to prevent backlog on same chunk (would block filedb) + default: + // TODO: track backlog by nodeID + c.vm.Logger().Warn("dropping chunk and cert gossip because too big of a backlog", zap.Stringer("nodeID", nodeID)) + c.vm.metrics.gossipChunkBacklog.Dec() + c.vm.metrics.chunkGossipDropped.Inc() + } + case txMsg: + authCounts, txs, err := chain.UnmarshalTxs(msg[1:], gossipTxPrealloc, c.vm.actionRegistry, c.vm.authRegistry) + if err != nil { + c.vm.Logger().Warn("dropping invalid tx gossip from non-validator", zap.Stringer("nodeID", nodeID), zap.Error(err)) + c.vm.metrics.gossipTxMsgInvalid.Inc() + return nil + } + txLen := len(txs) + c.vm.RecordTxsReceived(txLen) + + // Enqueue txs for verification, if we have a backlog just drop them + c.vm.metrics.gossipTxBacklog.Add(float64(txLen)) + select { + case c.incomingTxs <- &txGossipWrapper{nodeID: nodeID, txs: txs, authCounts: authCounts}: + default: + c.vm.metrics.gossipTxBacklog.Add(-float64(txLen)) + c.vm.metrics.txGossipDropped.Add(float64(txLen)) + c.vm.Logger().Warn("dropping tx gossip because too big of backlog", zap.Stringer("nodeID", nodeID)) + } + + c.vm.Logger().Debug( + "received txs from gossip", + zap.Int("txs", len(txs)), + zap.Stringer("nodeID", nodeID), + zap.Duration("t", time.Since(start)), + ) + default: + c.vm.Logger().Warn("dropping unknown message type", zap.Stringer("nodeID", nodeID)) + } + return nil +} + +func (c *ChunkManager) AppRequest( + ctx context.Context, + nodeID ids.NodeID, + requestID uint32, + _ time.Time, + request []byte, +) error { + if len(request) == 0 { + c.vm.Logger().Warn("dropping empty message", zap.Stringer("nodeID", nodeID)) + return nil + } + switch request[0] { + case chunkReq: + rid := request[1:] + if len(rid) != consts.Uint64Len+ids.IDLen { + c.vm.Logger().Warn("dropping invalid request", zap.Stringer("nodeID", nodeID)) + return nil + } + slot := int64(binary.BigEndian.Uint64(rid[:consts.Uint64Len])) + id := ids.ID(rid[consts.Uint64Len:]) + chunk, err := c.vm.GetChunkBytes(slot, id) + if chunk == nil || err != nil { + c.vm.Logger().Warn( + "unable to fetch chunk", + zap.Stringer("nodeID", nodeID), + zap.Int64("slot", slot), + zap.Stringer("chunkID", id), + zap.Error(err), + ) + c.appSender.SendAppError(ctx, nodeID, requestID, -1, err.Error()) // TODO: add error so caller knows it is missing + return nil + } + c.appSender.SendAppResponse(ctx, nodeID, requestID, chunk) + default: + c.vm.Logger().Warn("dropping unknown message type", zap.Stringer("nodeID", nodeID)) + } + return nil +} + +func (w *ChunkManager) AppRequestFailed( + _ context.Context, + nodeID ids.NodeID, + requestID uint32, +) error { + w.callbacksL.Lock() + callback, ok := w.callbacks[requestID] + delete(w.callbacks, requestID) + w.callbacksL.Unlock() + if !ok { + w.vm.Logger().Warn("dropping unknown response", zap.Stringer("nodeID", nodeID), zap.Uint32("requestID", requestID)) + return nil + } + callback(nil) + return nil +} + +func (w *ChunkManager) AppError( + _ context.Context, + nodeID ids.NodeID, + requestID uint32, + _ int32, + err string, +) error { + w.callbacksL.Lock() + callback, ok := w.callbacks[requestID] + delete(w.callbacks, requestID) + w.callbacksL.Unlock() + if !ok { + w.vm.Logger().Warn("dropping unknown response", zap.Stringer("nodeID", nodeID), zap.Uint32("requestID", requestID)) + return nil + } + w.vm.Logger().Warn("dropping request with error", zap.Stringer("nodeID", nodeID), zap.Uint32("requestID", requestID), zap.String("error", err)) + callback(nil) + return nil +} + +func (w *ChunkManager) AppResponse( + _ context.Context, + nodeID ids.NodeID, + requestID uint32, + response []byte, +) error { + w.callbacksL.Lock() + callback, ok := w.callbacks[requestID] + delete(w.callbacks, requestID) + w.callbacksL.Unlock() + if !ok { + w.vm.Logger().Warn("dropping unknown response", zap.Stringer("nodeID", nodeID), zap.Uint32("requestID", requestID)) + return nil + } + callback(response) + return nil +} + +func (*ChunkManager) CrossChainAppRequest(context.Context, ids.ID, uint32, time.Time, []byte) error { + return nil +} + +func (*ChunkManager) CrossChainAppRequestFailed(context.Context, ids.ID, uint32) error { + return nil +} + +func (*ChunkManager) CrossChainAppResponse(context.Context, ids.ID, uint32, []byte) error { + return nil +} + +func (*ChunkManager) CrossChainAppError(context.Context, ids.ID, uint32, int32, string) error { + return nil +} + +type chunkGossipWrapper struct { + nodeID ids.NodeID + chunk *chain.Chunk + cert *chain.ChunkCertificate +} + +type txGossipWrapper struct { + nodeID ids.NodeID + txs []*chain.Transaction + authCounts map[uint8]int +} + +func (c *ChunkManager) Run(appSender common.AppSender) { + c.appSender = appSender + defer close(c.done) + + beneficiary := c.vm.Beneficiary() + skipChunks := false + if bytes.Equal(beneficiary[:], codec.EmptyAddress[:]) { + c.vm.Logger().Warn("no beneficiary set, not building chunks") + skipChunks = true + } + c.vm.Logger().Info("starting chunk manager", zap.Any("beneficiary", beneficiary)) + + // While we could try to shae the same goroutine for some of these items (as they aren't + // expected to take much time), it is safter to split apart. + g := &errgroup.Group{} + g.Go(func() error { + // TODO: return a proper error if something unexpected happens rather than panic + c.auth.Run() + return nil + }) + g.Go(func() error { + t := time.NewTicker(c.vm.config.GetChunkBuildFrequency()) + defer t.Stop() + for { + select { + case <-t.C: + if !c.vm.isReady() { + c.vm.Logger().Debug("skipping chunk loop because vm isn't ready") + continue + } + if skipChunks { + continue + } + + // Attempt to build a chunk + chunkStart := time.Now() + chunk, err := chain.BuildChunk(context.TODO(), c.vm) + switch { + case errors.Is(err, chain.ErrNoTxs) || errors.Is(err, chain.ErrNotAValidator): + c.vm.Logger().Debug("unable to build chunk", zap.Error(err)) + continue + case err != nil: + c.vm.Logger().Error("unable to build chunk", zap.Error(err)) + continue + default: + } + c.auth.Add(chunk) // this will be a no-op because we are the producer + c.PushChunk(context.TODO(), chunk) + chunkBytes := chunk.Size() + c.vm.metrics.chunkBuild.Observe(float64(time.Since(chunkStart))) + c.vm.metrics.chunkBytesBuilt.Add(float64(chunkBytes)) + c.vm.metrics.mempoolLen.Set(float64(c.vm.Mempool().Len(context.TODO()))) + c.vm.metrics.mempoolSize.Set(float64(c.vm.Mempool().Size(context.TODO()))) + case <-c.vm.stop: + // If engine taking too long to process message, Shutdown will not + // be called. + c.vm.Logger().Info("stopping chunk manager") + return nil + } + } + }) + g.Go(func() error { + t := time.NewTicker(c.vm.config.GetBlockBuildFrequency()) + defer t.Stop() + for { + select { + case <-t.C: + if !c.vm.isReady() { + c.vm.Logger().Info("skipping block loop because vm isn't ready") + continue + } + + // Attempt to build a block + select { + case c.vm.EngineChan() <- common.PendingTxs: + default: + } + case <-c.vm.stop: + // If engine taking too long to process message, Shutdown will not + // be called. + c.vm.Logger().Info("stopping chunk manager") + return nil + } + } + }) + g.Go(func() error { + t := time.NewTicker(50 * time.Millisecond) + defer t.Stop() + for { + select { + case <-t.C: + now := time.Now() + gossipable := []*txGossip{} + c.txL.Lock() + for { + gossip, ok := c.txQueue.PopLeft() + if !ok { + break + } + if !gossip.expiry.Before(now) { + c.txQueue.PushLeft(gossip) + break + } + delete(c.txNodes, gossip.nodeID) + if gossip.sent { + continue + } + gossipable = append(gossipable, gossip) + } + c.txL.Unlock() + for _, gossip := range gossipable { + c.sendTxGossip(context.TODO(), gossip) + } + case <-c.vm.stop: + // If engine taking too long to process message, Shutdown will not + // be called. + c.vm.Logger().Info("stopping chunk manager") + return nil + } + } + }) + for i := 0; i < c.vm.config.GetAuthGossipCores(); i++ { + g.Go(func() error { + for { + select { + case txw := <-c.incomingTxs: + c.vm.metrics.gossipTxBacklog.Add(-float64(len(txw.txs))) + ctx := context.TODO() + w := workers.NewSerial() + job, err := w.NewJob(len(txw.txs)) + if err != nil { + c.vm.metrics.txGossipDropped.Add(float64(len(txw.txs))) + c.vm.Logger().Warn("unable to spawn new worker", zap.Error(err)) + continue + } + invalid := false + batchVerifier := chain.NewAuthBatch(c.vm, job, txw.authCounts) + for _, tx := range txw.txs { + epoch, epochHeight, err := c.getEpochInfo(ctx, tx.Base.Timestamp) + if err != nil { + c.vm.Logger().Debug("unable to determine tx epoch", zap.Int64("t", tx.Base.Timestamp)) + invalid = true + break + } + partition, err := c.vm.proposerMonitor.AddressPartition(ctx, epoch, epochHeight, tx.Sponsor(), tx.Partition()) + if err != nil { + c.vm.Logger().Debug("unable to compute address partition", zap.Error(err)) + invalid = true + break + } + if partition != c.vm.snowCtx.NodeID { + c.vm.Logger().Debug("dropping tx gossip from non-partition", zap.Stringer("nodeID", txw.nodeID)) + invalid = true + break + } + // Verify signature async + msg, err := tx.Digest() + if err != nil { + c.vm.Logger().Warn( + "unable to compute tx digest", + zap.Stringer("peerID", txw.nodeID), + zap.Error(err), + ) + invalid = true + break + } + if !c.vm.GetVerifyAuth() { + continue + } + batchVerifier.Add(msg, tx.Auth) + } + + // Don't wait for signatures if invalid + // + // TODO: stop job + if invalid { + batchVerifier.Done(nil) + c.vm.Logger().Debug("dropping invalid tx gossip", zap.Stringer("nodeID", txw.nodeID)) + c.vm.metrics.gossipTxInvalid.Add(float64(len(txw.txs))) + continue + } + + // Wait for signature verification to finish + batchVerifier.Done(nil) + if err := job.Wait(); err != nil { + c.vm.Logger().Debug( + "received invalid gossip", + zap.Stringer("peerID", txw.nodeID), + zap.Error(err), + ) + c.vm.metrics.gossipTxInvalid.Add(float64(len(txw.txs))) + continue + } + + // Submit txs + errs := c.vm.Submit(ctx, false, txw.txs) + now := time.Now().UnixMilli() + for i, err := range errs { + tx := txw.txs[i] + if err == nil { + c.vm.metrics.txTimeRemainingMempool.Observe(float64(tx.Expiry() - now)) + continue + } + c.vm.metrics.txGossipDropped.Inc() + c.vm.Logger().Warn( + "did not add incoming tx to mempool", + zap.Stringer("peerID", txw.nodeID), + zap.Stringer("txID", tx.ID()), + zap.Error(err), + ) + } + c.vm.metrics.mempoolLen.Set(float64(c.vm.Mempool().Len(context.TODO()))) + c.vm.metrics.mempoolSize.Set(float64(c.vm.Mempool().Size(context.TODO()))) + case <-c.vm.stop: + // If engine taking too long to process message, Shutdown will not + // be called. + c.vm.Logger().Info("stopping chunk manager") + return nil + } + } + }) + } + for i := 0; i < c.vm.config.GetChunkStorageCores(); i++ { + g.Go(func() error { + for { + select { + case cw := <-c.incomingChunks: + c.vm.metrics.gossipChunkBacklog.Dec() + ctx := context.TODO() + nodeID := cw.nodeID + chunk := cw.chunk + cert := cw.cert + + // Handle chunk verification + if err := c.VerifyAndStoreChunk(ctx, nodeID, chunk); err != nil { + c.vm.Logger().Warn("unable to verify and store chunk", zap.Stringer("nodeID", nodeID), zap.Stringer("chunkID", chunk.ID()), zap.Error(err)) + continue + } + + // Handle certificate verification and enqueue chunk for auth + if cert != nil { + if err := c.VerifyAndTrackCertificate(ctx, cert); err != nil { + c.vm.Logger().Warn("unable to verify and store certificate", zap.Stringer("nodeID", nodeID), zap.Stringer("chunkID", cert.Chunk), zap.Error(err)) + continue + } + c.auth.Add(chunk) + } else { + // In the case that we receive the cert for a chunk before the chunk itself, + // we check to see if we have the cert. + if _, ok := c.certs.Get(chunk.ID()); ok { + c.auth.Add(chunk) + } + } + + // Sign chunk if validator + _, epochHeight, err := c.getEpochInfo(ctx, chunk.Slot) + if err != nil { + c.vm.Logger().Warn("unable to determine chunk epoch", zap.Int64("slot", chunk.Slot), zap.Error(err)) + continue + } + amValidator, _, _, err := c.vm.proposerMonitor.IsValidator(ctx, epochHeight, c.vm.snowCtx.NodeID) + if err != nil { + c.vm.Logger().Warn("unable to determine if am validator", zap.Error(err)) + continue + } + if !amValidator { + // We don't sign the chunk if we're not a validator + continue + } + chunkSignature := &chain.ChunkSignature{ + Chunk: chunk.ID(), + Slot: chunk.Slot, + } + digest, err := chunkSignature.Digest() + if err != nil { + c.vm.Logger().Warn("cannot generate cert digest", zap.Stringer("nodeID", nodeID), zap.Error(err)) + continue + } + warpMessage, err := warp.NewUnsignedMessage(c.vm.snowCtx.NetworkID, c.vm.snowCtx.ChainID, digest) + if err != nil { + c.vm.Logger().Warn("unable to build warp message", zap.Error(err)) + continue + } + sig, err := c.vm.snowCtx.WarpSigner.Sign(warpMessage) + if err != nil { + c.vm.Logger().Warn("unable to sign chunk digest", zap.Error(err)) + continue + } + // We don't include the signer in the digest because we can't verify + // the aggregate signature over the chunk if we do. + chunkSignature.Signer = c.vm.snowCtx.PublicKey + chunkSignature.Signature, err = bls.SignatureFromBytes(sig) + if err != nil { + c.vm.Logger().Warn("unable to parse signature", zap.Error(err)) + continue + } + c.PushSignature(ctx, nodeID, chunkSignature) + case <-c.vm.stop: + c.vm.Logger().Info("stopping chunk storage worker") + return nil + } + } + }) + } + if err := g.Wait(); err != nil { + c.vm.Logger().Error("chunk manager stopped with error", zap.Error(err)) + } +} + +// Drop all chunks material that can no longer be included anymore (may have already been included). +// +// This functions returns an array of chunkIDs that can be used to delete unused chunks from persistent storage. +func (c *ChunkManager) SetBuildableMin(ctx context.Context, t int64) { + removedBuilt := c.built.SetMin(t) + expiredBuilt := 0 + for _, cid := range removedBuilt { + if c.vm.IsSeenChunk(context.TODO(), cid) { + continue + } + expiredBuilt++ + if cert, ok := c.certs.Get(cid); ok { + c.vm.Logger().Warn( + "dropping built chunk", + zap.Stringer("chunkID", cid), + zap.Int64("slot", cert.Slot), + zap.Int("signers", cert.Signers.Len()), + ) + } + } + c.vm.metrics.expiredBuiltChunks.Add(float64(expiredBuilt)) + removedCerts := c.certs.SetMin(ctx, t) + expiredCerts := 0 + for _, cert := range removedCerts { + if c.vm.IsSeenChunk(context.TODO(), cert.Chunk) { + continue + } + expiredCerts++ + } + c.vm.metrics.expiredCerts.Add(float64(expiredCerts)) +} + +// Remove chunks we included in a block to accurately account for unused chunks +func (c *ChunkManager) RemoveStored(chunk ids.ID) { + c.stored.Remove(chunk) +} + +// We keep track of all chunks we've stored, so we can delete them later. +func (c *ChunkManager) SetStoredMin(t int64) []*simpleChunkWrapper { + return c.stored.SetMin(t) +} + +func makeChunkMsg(chunk *chain.Chunk) ([]byte, error) { + msg := make([]byte, 1+chunk.Size()) + msg[0] = chunkMsg + chunkBytes, err := chunk.Marshal() + if err != nil { + return nil, err + } + copy(msg[1:], chunkBytes) + return msg, nil +} + +func (c *ChunkManager) PushChunk(ctx context.Context, chunk *chain.Chunk) { + msg, err := makeChunkMsg(chunk) + if err != nil { + c.vm.Logger().Warn("failed to marshal chunk", zap.Error(err)) + return + } + _, epochHeight, err := c.getEpochInfo(ctx, chunk.Slot) + if err != nil { + c.vm.Logger().Warn("unable to determine chunk epoch", zap.Int64("t", chunk.Slot), zap.Error(err)) + return + } + validators, err := c.vm.proposerMonitor.GetValidatorSet(ctx, epochHeight, false) + if err != nil { + panic(err) + } + cw := &chunkWrapper{ + sent: time.Now(), + chunk: chunk, + signatures: make(map[string]*chain.ChunkSignature, len(validators)+1), + } + + // Persist our own chunk + if err := c.vm.StoreChunk(chunk); err != nil { + panic(err) + } + + // Sign our own chunk + chunkSignature := &chain.ChunkSignature{ + Chunk: chunk.ID(), + Slot: chunk.Slot, + } + digest, err := chunkSignature.Digest() + if err != nil { + panic(err) + } + warpMessage, err := warp.NewUnsignedMessage(c.vm.snowCtx.NetworkID, c.vm.snowCtx.ChainID, digest) + if err != nil { + panic(err) + } + sig, err := c.vm.snowCtx.WarpSigner.Sign(warpMessage) + if err != nil { + panic(err) + } + // We don't include the signer in the digest because we can't verify + // the aggregate signature over the chunk if we do. + chunkSignature.Signer = c.vm.snowCtx.PublicKey + chunkSignature.Signature, err = bls.SignatureFromBytes(sig) + if err != nil { + panic(err) + } + // TODO: can probably use uncompressed bytes here + cw.signatures[string(bls.PublicKeyToCompressedBytes(chunkSignature.Signer))] = chunkSignature + c.built.Add([]*chunkWrapper{cw}) + c.stored.Add(&simpleChunkWrapper{chunk: chunk.ID(), slot: chunk.Slot}) + + // Send chunk to all validators + // + // TODO: consider changing to request (for signature)? -> would allow for a job poller style where we could keep sending? + // + // This would put some sort of latency requirement for other nodes to persist/sign the chunk (we should probably just let it flow + // more loosely. + c.appSender.SendAppGossip(ctx, common.SendConfig{NodeIDs: validators}, msg) // skips validators we aren't connected to +} + +func makeChunkCertificateMsg(cert *chain.ChunkCertificate) ([]byte, error) { + msg := make([]byte, 1+cert.Size()) + msg[0] = chunkCertificateMsg + certBytes, err := cert.Marshal() + if err != nil { + return nil, err + } + copy(msg[1:], certBytes) + return msg, nil +} + +func (c *ChunkManager) PushChunkCertificate(ctx context.Context, cert *chain.ChunkCertificate) { + msg, err := makeChunkCertificateMsg(cert) + if err != nil { + c.vm.Logger().Warn("failed to marshal chunk cert", zap.Error(err)) + return + } + _, epochHeight, err := c.getEpochInfo(ctx, cert.Slot) + if err != nil { + c.vm.Logger().Warn("unable to determine chunk epoch", zap.Int64("slot", cert.Slot), zap.Error(err)) + return + } + validators, err := c.vm.proposerMonitor.GetValidatorSet(ctx, epochHeight, false) + if err != nil { + panic(err) + } + c.appSender.SendAppGossip(ctx, common.SendConfig{NodeIDs: validators}, msg) // skips validators we aren't connected to +} + +func makeCertifiedChunkMsg(chunk *chain.Chunk, cert *chain.ChunkCertificate) ([]byte, error) { + // We assume there is enough room to attach a cert to a chunk and still be under the network + // size limit + p := codec.NewWriter(1+codec.BytesLenSize(chunk.Size())+codec.BytesLenSize(cert.Size()), consts.NetworkSizeLimit) + p.PackByte(chunkCertifiedMsg) + c, err := chunk.Marshal() + if err != nil { + return nil, err + } + p.PackBytes(c) + ct, err := cert.Marshal() + if err != nil { + return nil, err + } + p.PackBytes(ct) + return p.Bytes(), p.Err() +} + +func parseCertifiedChunkMsg(msg []byte, vm *VM) (*chain.Chunk, *chain.ChunkCertificate, error) { + p := codec.NewReader(msg, consts.NetworkSizeLimit) + p.UnpackByte() + var chunkBytes []byte + p.UnpackBytes(consts.NetworkSizeLimit, true, &chunkBytes) + chunk, err := chain.UnmarshalChunk(chunkBytes, vm) + if err != nil { + return nil, nil, err + } + var certBytes []byte + p.UnpackBytes(consts.NetworkSizeLimit, true, &certBytes) + cert, err := chain.UnmarshalChunkCertificate(certBytes) + if err != nil { + return nil, nil, err + } + return chunk, cert, nil +} + +func (c *ChunkManager) PushCertifiedChunk(ctx context.Context, chunk *chain.Chunk, cert *chain.ChunkCertificate) { + // Get non-validators + recipients := set.Set[ids.NodeID]{} + _, epochHeight, err := c.getEpochInfo(ctx, cert.Slot) + if err != nil { + c.vm.Logger().Warn("unable to determine chunk epoch", zap.Int64("slot", cert.Slot), zap.Error(err)) + return + } + validators, err := c.vm.proposerMonitor.GetValidatorSet(ctx, epochHeight, false) + if err != nil { + panic(err) + } + c.connectedL.Lock() + connected := c.connected.List() + c.connectedL.Unlock() + for _, recipient := range connected { + if validators.Contains(recipient) || recipient == c.vm.snowCtx.NodeID { + continue + } + recipients.Add(recipient) + c.vm.metrics.optimisticCertifiedGossip.Inc() + } + + // Push msg + msg, err := makeCertifiedChunkMsg(chunk, cert) + if err != nil { + c.vm.Logger().Warn("failed to marshal certified chunk", zap.Error(err)) + return + } + c.appSender.SendAppGossip(ctx, common.SendConfig{NodeIDs: recipients}, msg) +} + +// RestoreChunkCertificates re-inserts certs into the CertStore for inclusion. These chunks are sorted +// by the time they are first seen, so we always try to include certs that have been valid and around +// the longest first. +func (c *ChunkManager) RestoreChunkCertificates(ctx context.Context, certs []*chain.ChunkCertificate) { + for _, cert := range certs { + c.certs.Update(cert) + } +} + +func (c *ChunkManager) HandleTx(ctx context.Context, tx *chain.Transaction) { + // TODO: drop if issued recently (recall we are not tracking in our mempool)? + // -> We could track these txs in the mempool to prevent this issue. + // -> Would then limit what we'd actually issue + // -> This is more complex than it seems because if the node becomes a validator, it will then + // start issuing chunks with invalid txs (not in correct partition). + + // Find transaction partition + epoch, epochHeight, err := c.getEpochInfo(ctx, tx.Base.Timestamp) + if err != nil { + c.vm.Logger().Warn("cannot lookup epoch", zap.Error(err)) + return + } + partition, err := c.vm.proposerMonitor.AddressPartition(ctx, epoch, epochHeight, tx.Sponsor(), tx.Partition()) + if err != nil { + c.vm.Logger().Warn("unable to compute address partition", zap.Error(err)) + return + } + + // Add to mempool if we are the issuer + if partition == c.vm.snowCtx.NodeID { + c.vm.metrics.mempoolSize.Set(float64(c.vm.mempool.Len(ctx))) + c.vm.mempool.Add(ctx, []*chain.Transaction{tx}) + c.vm.Logger().Debug("adding tx to mempool", zap.Stringer("txID", tx.ID())) + return + } + + // Handle gossip addition + txSize := tx.Size() + txID := tx.ID() + c.txL.Lock() + var gossipable *txGossip + gossip, ok := c.txNodes[partition] + if ok { + if gossip.size+txSize > gossipTxTargetSize { + delete(c.txNodes, partition) + gossip.sent = true + gossipable = gossip + } else { + if _, ok := gossip.txs[txID]; !ok { + gossip.txs[txID] = tx + gossip.size += txSize + } + c.txL.Unlock() + return + } + } + gossip = &txGossip{ + nodeID: partition, + txs: make(map[ids.ID]*chain.Transaction, gossipTxPrealloc), + expiry: time.Now().Add(gossipBatchWait), + size: consts.IntLen + txSize, + } + gossip.txs[txID] = tx + c.txNodes[partition] = gossip + c.txQueue.PushRight(gossip) + c.txL.Unlock() + + // Send any gossip if exit early + if gossipable == nil { + return + } + c.sendTxGossip(ctx, gossipable) +} + +func (c *ChunkManager) sendTxGossip(ctx context.Context, gossip *txGossip) { + txs := maps.Values(gossip.txs) + txBytes, err := chain.MarshalTxs(txs) + if err != nil { + panic(err) + } + msg := make([]byte, 1+len(txBytes)) + msg[0] = txMsg + copy(msg[1:], txBytes) + c.appSender.SendAppGossip(ctx, common.SendConfig{NodeIDs: set.Of(gossip.nodeID)}, msg) // skips validators we aren't connected to + c.vm.Logger().Debug( + "sending txs to partition", + zap.Int("txs", len(txs)), + zap.Stringer("partition", gossip.nodeID), + zap.Int("size", len(msg)), + ) + c.vm.RecordTxsGossiped(len(txs)) +} + +// This function should be spawned in a goroutine because it blocks +func (c *ChunkManager) RequestChunks(block uint64, certs []*chain.ChunkCertificate, chunks chan *chain.Chunk) { + // Ensure only one fetch is running at a time (all bandwidth should be allocated towards fetching chunks for next block) + myWaiter := make(chan struct{}) + c.waiterL.Lock() + waiter := c.waiter + c.waiter = myWaiter + c.waiterL.Unlock() + + // Kickoff job async + go func() { + if waiter != nil { // nil at first + select { + case <-waiter: + case <-c.vm.stop: + return + } + } + + // Kickoff fetch + fetchStart := time.Now() + workers := min(len(certs), c.vm.config.GetMissingChunkFetchers()) + f := opool.New(workers, len(certs)) + for _, rcert := range certs { + cert := rcert + f.Go(func() (func(), error) { + // Look for chunk + chunk, err := c.vm.GetChunk(cert.Slot, cert.Chunk) + if chunk != nil { + c.auth.Add(chunk) + return func() { chunks <- chunk }, nil + } + c.vm.Logger().Debug("missing chunk", zap.Stringer("chunkID", cert.Chunk), zap.Int64("slot", cert.Slot), zap.Error(err)) + + // Look for chunk epoch + _, epochHeight, err := c.getEpochInfo(context.TODO(), cert.Slot) + if err != nil { + c.vm.Logger().Warn("cannot lookup epoch", zap.Error(err)) + return nil, err + } + + // Fetch missing chunk + attempts := 0 + for { + // Check to see if we received chunk while we were fetching + if attempts > 0 { + chunk, _ := c.vm.GetChunk(cert.Slot, cert.Chunk) + if chunk != nil { + c.vm.metrics.uselessFetchChunkAttempts.Inc() + c.vm.Logger().Debug("received chunk while fetching", zap.Stringer("chunkID", cert.Chunk)) + c.auth.Add(chunk) + return func() { chunks <- chunk }, nil + } + } + + // Make request + c.vm.metrics.fetchChunkAttempts.Add(1) + c.vm.Logger().Debug("fetching missing chunk", zap.Int64("slot", cert.Slot), zap.Stringer("chunkID", cert.Chunk), zap.Int("previous attempts", attempts)) + attempts++ + + bytesChan := make(chan []byte, 1) + c.callbacksL.Lock() + requestID := c.requestID + c.callbacks[requestID] = func(response []byte) { + bytesChan <- response + close(bytesChan) + } + c.requestID++ + c.callbacksL.Unlock() + var validator ids.NodeID + for { + // Chunk should be sent to all validators, so we can just pick a random one + // + // TODO: consider using cert to select validators? + randomValidator, err := c.vm.proposerMonitor.RandomValidator(context.Background(), epochHeight) + if err != nil { + return nil, err + } + c.connectedL.Lock() + contains := c.connected.Contains(randomValidator) + c.connectedL.Unlock() + if contains { + validator = randomValidator + break + } + c.vm.Logger().Warn("skipping disconnected validator", zap.Stringer("nodeID", randomValidator)) + // TODO: put some time delay here to avoid spinning when disconnected? + } + request := make([]byte, 1+consts.Uint64Len+ids.IDLen) + request[0] = chunkReq + binary.BigEndian.PutUint64(request[1:], uint64(cert.Slot)) + copy(request[1+consts.Uint64Len:], cert.Chunk[:]) + if err := c.appSender.SendAppRequest(context.Background(), set.Of(validator), requestID, request); err != nil { + c.vm.Logger().Warn("failed to send chunk request", zap.Error(err)) + continue + } + + // Wait for reponse or exit + var bytes []byte + select { + case bytes = <-bytesChan: + case <-c.vm.stop: + return nil, errors.New("stopping") + } + if len(bytes) == 0 { + c.vm.Logger().Warn("failed to fetch chunk", zap.Stringer("chunkID", cert.Chunk), zap.Stringer("nodeID", validator)) + continue + } + + // Handle response + // + // Note: we may have received the chunk we are fetching from push gossip + // while fetching for it. + chunk, err := chain.UnmarshalChunk(bytes, c.vm) + if err != nil { + c.vm.Logger().Warn("failed to unmarshal chunk", zap.Error(err)) + continue + } + if chunk.ID() != cert.Chunk { + c.vm.Logger().Warn("unexpected chunk", zap.Stringer("chunkID", chunk.ID()), zap.Stringer("expectedChunkID", cert.Chunk)) + continue + } + // Check if received during push gossip + if c.stored.Has(chunk.ID()) { + c.auth.Add(chunk) + c.vm.metrics.uselessFetchChunkAttempts.Inc() + return func() { chunks <- chunk }, nil + } + if err := c.vm.StoreChunk(chunk); err != nil { + return nil, err + } + c.stored.Add(&simpleChunkWrapper{chunk: chunk.ID(), slot: cert.Slot}) + c.auth.Add(chunk) + return func() { chunks <- chunk }, nil + } + }) + } + ferr := f.Wait() + close(chunks) // We always close chunks, so the execution engine can handle this issue and shutdown + if ferr != nil { + // This is a FATAL because exiting here will cause the execution engine to hang + c.vm.Logger().Fatal("failed to fetch chunks", zap.Error(ferr)) + return + } + c.vm.Logger().Info("finished fetching chunks", zap.Uint64("height", block), zap.Duration("t", time.Since(fetchStart))) + c.vm.metrics.fetchMissingChunks.Observe(float64(time.Since(fetchStart))) + + // Invoke next waiter + close(myWaiter) + }() +} + +func (c *ChunkManager) Done() { + <-c.done +} diff --git a/vm/dependencies.go b/vm/dependencies.go index 647049dc8d..71224edaa1 100644 --- a/vm/dependencies.go +++ b/vm/dependencies.go @@ -13,12 +13,12 @@ import ( "github.com/ava-labs/avalanchego/utils/profiler" "github.com/ava-labs/avalanchego/x/merkledb" - "github.com/AnomalyFi/hypersdk/builder" "github.com/AnomalyFi/hypersdk/chain" "github.com/AnomalyFi/hypersdk/codec" - "github.com/AnomalyFi/hypersdk/gossiper" + "github.com/AnomalyFi/hypersdk/filedb" "github.com/AnomalyFi/hypersdk/state" "github.com/AnomalyFi/hypersdk/trace" + "github.com/AnomalyFi/hypersdk/vilmo" avametrics "github.com/ava-labs/avalanchego/api/metrics" avatrace "github.com/ava-labs/avalanchego/trace" @@ -29,32 +29,32 @@ type Handlers map[string]http.Handler type Config interface { GetTraceConfig() *trace.Config GetMempoolSize() int - GetAuthVerificationCores() int + GetAuthExecutionCores() int + GetAuthRPCCores() int + GetAuthRPCBacklog() int + GetAuthGossipCores() int + GetAuthGossipBacklog() int GetVerifyAuth() bool - GetRootGenerationCores() int - GetTransactionExecutionCores() int - GetStateFetchConcurrency() int + GetPrecheckCores() int + GetActionExecutionCores() int + GetMissingChunkFetchers() int + GetChunkStorageCores() int + GetChunkStorageBacklog() int + GetBeneficiary() codec.Address GetMempoolSponsorSize() int GetMempoolExemptSponsors() []codec.Address GetStreamingBacklogSize() int + GetAcceptorSize() int // how far back we can fall in processing accepted blocks. GetStoreBlockResultsOnDisk() bool - GetStateHistoryLength() int // how many roots back of data to keep to serve state queries - GetIntermediateNodeCacheSize() int // how many bytes to keep in intermediate cache - GetStateIntermediateWriteBufferSize() int // how many bytes to keep unwritten in intermediate cache - GetStateIntermediateWriteBatchSize() int // how many bytes to write from intermediate cache at once - GetValueNodeCacheSize() int // how many bytes to keep in value cache - GetAcceptorSize() int // how far back we can fall in processing accepted blocks - GetStateSyncParallelism() int - GetStateSyncMinBlocks() uint64 - GetStateSyncServerDelay() time.Duration GetParsedBlockCacheSize() int GetAcceptedBlockWindow() int GetAcceptedBlockWindowCache() int GetContinuousProfilerConfig() *profiler.Config - GetTargetBuildDuration() time.Duration + GetTargetChunkBuildDuration() time.Duration + GetChunkBuildFrequency() time.Duration + GetBlockBuildFrequency() time.Duration GetProcessingBuildSkip() int - GetTargetGossipDuration() time.Duration - GetBlockCompactionFrequency() int + // GetTargetGossipDuration() time.Duration GetETHL1RPC() string GetETHL1WS() string } @@ -81,12 +81,10 @@ type Controller interface { ) ( config Config, genesis Genesis, - builder builder.Builder, - gossiper gossiper.Gossiper, - // TODO: consider splitting out blockDB for use with more experimental - // databases + vmDB database.Database, - stateDB database.Database, + blobDB *filedb.FileDB, + stateDB *vilmo.Vilmo, handler Handlers, actionRegistry chain.ActionRegistry, authRegistry chain.AuthRegistry, diff --git a/vm/metrics.go b/vm/metrics.go index 62226aeae6..7b89960b5d 100644 --- a/vm/metrics.go +++ b/vm/metrics.go @@ -17,73 +17,162 @@ type executorMetrics struct { } func (em *executorMetrics) RecordBlocked() { + if em == nil { + return + } em.blocked.Inc() } func (em *executorMetrics) RecordExecutable() { + if em == nil { + return + } em.executable.Inc() } +// TODO: rename all of these type Metrics struct { - txsSubmitted prometheus.Counter // includes gossip - txsReceived prometheus.Counter - seenTxsReceived prometheus.Counter - txsGossiped prometheus.Counter - txsVerified prometheus.Counter - txsAccepted prometheus.Counter - stateChanges prometheus.Counter - stateOperations prometheus.Counter - buildCapped prometheus.Counter - emptyBlockBuilt prometheus.Counter - clearedMempool prometheus.Counter - deletedBlocks prometheus.Counter - blocksFromDisk prometheus.Counter - blocksHeightsFromDisk prometheus.Counter - executorBuildBlocked prometheus.Counter - executorBuildExecutable prometheus.Counter - executorVerifyBlocked prometheus.Counter - executorVerifyExecutable prometheus.Counter - mempoolSize prometheus.Gauge - bandwidthPrice prometheus.Gauge - computePrice prometheus.Gauge - storageReadPrice prometheus.Gauge - storageAllocatePrice prometheus.Gauge - storageWritePrice prometheus.Gauge - rootCalculated metric.Averager - waitRoot metric.Averager - waitSignatures metric.Averager - blockBuild metric.Averager - blockParse metric.Averager - blockVerify metric.Averager - blockAccept metric.Averager - blockProcess metric.Averager + txsSubmitted prometheus.Counter // includes gossip + txsReceived prometheus.Counter + txsGossiped prometheus.Counter + txsIncluded prometheus.Counter + txsInvalid prometheus.Counter + chunkBuildTxsDropped prometheus.Counter + blockBuildCertsDropped prometheus.Counter + remainingMempool prometheus.Counter + chunkBytesBuilt prometheus.Counter + deletedBlocks prometheus.Counter + deletedUselessChunks prometheus.Counter + deletedIncludedChunks prometheus.Counter + deletedFilteredChunks prometheus.Counter + blocksFromDisk prometheus.Counter + blocksHeightsFromDisk prometheus.Counter + executorBlocked prometheus.Counter + executorExecutable prometheus.Counter + chunksReceived prometheus.Counter + sigsReceived prometheus.Counter + certsReceived prometheus.Counter + chunksExecuted prometheus.Counter + txRPCAuthorized prometheus.Counter + blockVerifyFailed prometheus.Counter + gossipTxMsgInvalid prometheus.Counter + gossipTxInvalid prometheus.Counter + chunkGossipDropped prometheus.Counter + gossipChunkInvalid prometheus.Counter + gossipChunkSigInvalid prometheus.Counter + gossipCertInvalid prometheus.Counter + rpcTxInvalid prometheus.Counter + expiredBuiltChunks prometheus.Counter + expiredCerts prometheus.Counter + mempoolExpired prometheus.Counter + fetchChunkAttempts prometheus.Counter + uselessFetchChunkAttempts prometheus.Counter + txGossipDropped prometheus.Counter + unitsExecutedBandwidth prometheus.Counter + unitsExecutedCompute prometheus.Counter + unitsExecutedRead prometheus.Counter + unitsExecutedAllocate prometheus.Counter + unitsExecutedWrite prometheus.Counter + uselessChunkAuth prometheus.Counter + optimisticCertifiedGossip prometheus.Counter + engineBacklog prometheus.Gauge + rpcTxBacklog prometheus.Gauge + chainDataSize prometheus.Gauge + executedProcessingBacklog prometheus.Gauge + mempoolLen prometheus.Gauge + mempoolSize prometheus.Gauge + gossipTxBacklog prometheus.Gauge + gossipChunkBacklog prometheus.Gauge + websocketConnections prometheus.Gauge + lastAcceptedEpoch prometheus.Gauge + lastExecutedEpoch prometheus.Gauge + appendDBKeys prometheus.Gauge + appendDBAliveBytes prometheus.Gauge + appendDBUselessBytes prometheus.Gauge + waitRepeat metric.Averager + waitQueue metric.Averager + waitAuth metric.Averager + waitExec metric.Averager + waitPrecheck metric.Averager + waitCommit metric.Averager + appendDBBatchInit metric.Averager + appendDBBatchPrepare metric.Averager + tstateIterate metric.Averager + appendDBBatchWrite metric.Averager + appendDBBatchInitBytes metric.Averager + appendDBBatchesRewritten prometheus.Counter + stateChanges metric.Averager + chunkBuild metric.Averager + blockBuild metric.Averager + blockParse metric.Averager + blockVerify metric.Averager + blockAccept metric.Averager + blockProcess metric.Averager + blockExecute metric.Averager + executedChunkProcess metric.Averager + executedBlockProcess metric.Averager + fetchMissingChunks metric.Averager + collectChunkSignatures metric.Averager + txTimeRemainingMempool metric.Averager + chunkAuth metric.Averager - executorBuildRecorder executor.Metrics - executorVerifyRecorder executor.Metrics + executorRecorder executor.Metrics } func newMetrics() (*prometheus.Registry, *Metrics, error) { r := prometheus.NewRegistry() - rootCalculated, err := metric.NewAverager( - "chain_root_calculated", - "time spent calculating the state root in verify", + waitRepeat, err := metric.NewAverager( + "chain_wait_repeat", + "time spent waiting for repeat", + r, + ) + if err != nil { + return nil, nil, err + } + waitQueue, err := metric.NewAverager( + "chain_wait_queue", + "time spent iterating over chunk to queue txs for execution", + r, + ) + if err != nil { + return nil, nil, err + } + waitAuth, err := metric.NewAverager( + "chain_wait_auth", + "time spent waiting for auth", + r, + ) + if err != nil { + return nil, nil, err + } + waitExec, err := metric.NewAverager( + "chain_wait_exec", + "time spent waiting for execution after auth finishes", + r, + ) + if err != nil { + return nil, nil, err + } + waitPrecheck, err := metric.NewAverager( + "chain_wait_precheck", + "time spent waiting for precheck", r, ) if err != nil { return nil, nil, err } - waitRoot, err := metric.NewAverager( - "chain_wait_root", - "time spent waiting for root calculation in verify", + waitCommit, err := metric.NewAverager( + "chain_wait_commit", + "time spent waiting to commit state after execution", r, ) if err != nil { return nil, nil, err } - waitSignatures, err := metric.NewAverager( - "chain_wait_signatures", - "time spent waiting for signature verification in verify", + chunkBuild, err := metric.NewAverager( + "chain_chunk_build", + "time spent building chunks", r, ) if err != nil { @@ -123,7 +212,111 @@ func newMetrics() (*prometheus.Registry, *Metrics, error) { } blockProcess, err := metric.NewAverager( "chain_block_process", - "time spent processing blocks", + "time spent processing accepted blocks", + r, + ) + if err != nil { + return nil, nil, err + } + blockExecute, err := metric.NewAverager( + "chain_block_execute", + "time spent executing blocks", + r, + ) + if err != nil { + return nil, nil, err + } + executedChunkProcess, err := metric.NewAverager( + "chain_executed_chunk_process", + "time spent processing executed chunks", + r, + ) + if err != nil { + return nil, nil, err + } + executedBlockProcess, err := metric.NewAverager( + "chain_executed_block_process", + "time spent processing executed blocks", + r, + ) + if err != nil { + return nil, nil, err + } + fetchMissingChunks, err := metric.NewAverager( + "chain_fetch_missing_chunks", + "time spent fetching missing chunks", + r, + ) + if err != nil { + return nil, nil, err + } + collectChunkSignatures, err := metric.NewAverager( + "chain_collect_chunk_signatures", + "time spent collecting chunk signatures", + r, + ) + if err != nil { + return nil, nil, err + } + txTimeRemainingMempool, err := metric.NewAverager( + "chain_tx_time_remaining_mempool", + "valid time for inclusion when a tx is included in the mempool", + r, + ) + if err != nil { + return nil, nil, err + } + chunkAuth, err := metric.NewAverager( + "chain_chunk_auth", + "time spent authenticating chunks", + r, + ) + if err != nil { + return nil, nil, err + } + stateChanges, err := metric.NewAverager( + "chain_state_changes", + "changes to state in a block", + r, + ) + if err != nil { + return nil, nil, err + } + appendDBBatchInit, err := metric.NewAverager( + "vilmo_batch_init", + "batch initialization latency", + r, + ) + if err != nil { + return nil, nil, err + } + appendDBBatchInitBytes, err := metric.NewAverager( + "vilmo_batch_init_bytes", + "bytes written during batch initialization", + r, + ) + if err != nil { + return nil, nil, err + } + appendDBBatchPrepare, err := metric.NewAverager( + "vilmo_batch_prepare", + "batch preparation latency", + r, + ) + if err != nil { + return nil, nil, err + } + tstateIterate, err := metric.NewAverager( + "chain_tstate_iterate", + "time spent iterating over tstate", + r, + ) + if err != nil { + return nil, nil, err + } + appendDBBatchWrite, err := metric.NewAverager( + "vilmo_batch_write", + "batch write latency", r, ) if err != nil { @@ -131,66 +324,76 @@ func newMetrics() (*prometheus.Registry, *Metrics, error) { } m := &Metrics{ + appendDBBatchesRewritten: prometheus.NewCounter(prometheus.CounterOpts{ + Namespace: "vilmo", + Name: "batches_rewritten", + Help: "number of batches rewritten", + }), txsSubmitted: prometheus.NewCounter(prometheus.CounterOpts{ Namespace: "vm", Name: "txs_submitted", Help: "number of txs submitted to vm", }), - txsReceived: prometheus.NewCounter(prometheus.CounterOpts{ - Namespace: "vm", - Name: "txs_received", - Help: "number of txs received over gossip", - }), - seenTxsReceived: prometheus.NewCounter(prometheus.CounterOpts{ - Namespace: "vm", - Name: "seen_txs_received", - Help: "number of txs received over gossip that we've already seen", - }), txsGossiped: prometheus.NewCounter(prometheus.CounterOpts{ Namespace: "vm", Name: "txs_gossiped", Help: "number of txs gossiped by vm", }), - txsVerified: prometheus.NewCounter(prometheus.CounterOpts{ + txsReceived: prometheus.NewCounter(prometheus.CounterOpts{ Namespace: "vm", - Name: "txs_verified", - Help: "number of txs verified by vm", + Name: "txs_received", + Help: "number of txs received over gossip", }), - txsAccepted: prometheus.NewCounter(prometheus.CounterOpts{ + txsIncluded: prometheus.NewCounter(prometheus.CounterOpts{ Namespace: "vm", - Name: "txs_accepted", - Help: "number of txs accepted by vm", + Name: "txs_included", + Help: "number of txs included in accepted blocks", }), - stateChanges: prometheus.NewCounter(prometheus.CounterOpts{ + chunkBuildTxsDropped: prometheus.NewCounter(prometheus.CounterOpts{ Namespace: "chain", - Name: "state_changes", - Help: "number of state changes", + Name: "chunk_build_txs_dropped", + Help: "number of txs dropped while building chunks", }), - stateOperations: prometheus.NewCounter(prometheus.CounterOpts{ + blockBuildCertsDropped: prometheus.NewCounter(prometheus.CounterOpts{ Namespace: "chain", - Name: "state_operations", - Help: "number of state operations", + Name: "block_build_certs_dropped", + Help: "number of certs dropped while building blocks", }), - buildCapped: prometheus.NewCounter(prometheus.CounterOpts{ - Namespace: "chain", - Name: "build_capped", - Help: "number of times build capped by target duration", + txsInvalid: prometheus.NewCounter(prometheus.CounterOpts{ + Namespace: "vm", + Name: "txs_invalid", + Help: "number of invalid txs included in accepted blocks", }), - emptyBlockBuilt: prometheus.NewCounter(prometheus.CounterOpts{ + remainingMempool: prometheus.NewCounter(prometheus.CounterOpts{ Namespace: "chain", - Name: "empty_block_built", - Help: "number of times empty block built", + Name: "remaining_mempool", + Help: "number of times mempool not cleared while building", }), - clearedMempool: prometheus.NewCounter(prometheus.CounterOpts{ + chunkBytesBuilt: prometheus.NewCounter(prometheus.CounterOpts{ Namespace: "chain", - Name: "cleared_mempool", - Help: "number of times cleared mempool while building", + Name: "chunk_bytes_built", + Help: "number of bytes in built chunks", }), deletedBlocks: prometheus.NewCounter(prometheus.CounterOpts{ Namespace: "vm", Name: "deleted_blocks", Help: "number of blocks deleted", }), + deletedUselessChunks: prometheus.NewCounter(prometheus.CounterOpts{ + Namespace: "vm", + Name: "deleted_useless_chunks", + Help: "number of useless chunks deleted", + }), + deletedIncludedChunks: prometheus.NewCounter(prometheus.CounterOpts{ + Namespace: "vm", + Name: "deleted_included_chunks", + Help: "number of included chunks deleted", + }), + deletedFilteredChunks: prometheus.NewCounter(prometheus.CounterOpts{ + Namespace: "vm", + Name: "deleted_filtered_chunks", + Help: "number of filtered chunks deleted", + }), blocksFromDisk: prometheus.NewCounter(prometheus.CounterOpts{ Namespace: "vm", Name: "blocks_from_disk", @@ -201,94 +404,304 @@ func newMetrics() (*prometheus.Registry, *Metrics, error) { Name: "block_heights_from_disk", Help: "number of block heights attempted to load from disk", }), - executorBuildBlocked: prometheus.NewCounter(prometheus.CounterOpts{ + executorBlocked: prometheus.NewCounter(prometheus.CounterOpts{ + Namespace: "chain", + Name: "executor_blocked", + Help: "executor tasks blocked during processing", + }), + executorExecutable: prometheus.NewCounter(prometheus.CounterOpts{ Namespace: "chain", - Name: "executor_build_blocked", - Help: "executor tasks blocked during build", + Name: "executor_executable", + Help: "executor tasks executable during processing", }), - executorBuildExecutable: prometheus.NewCounter(prometheus.CounterOpts{ + chunksReceived: prometheus.NewCounter(prometheus.CounterOpts{ Namespace: "chain", - Name: "executor_build_executable", - Help: "executor tasks executable during build", + Name: "chunks_received", + Help: "chunks received from validators", }), - executorVerifyBlocked: prometheus.NewCounter(prometheus.CounterOpts{ + sigsReceived: prometheus.NewCounter(prometheus.CounterOpts{ Namespace: "chain", - Name: "executor_verify_blocked", - Help: "executor tasks blocked during verify", + Name: "sigs_received", + Help: "signatures received from validators", }), - executorVerifyExecutable: prometheus.NewCounter(prometheus.CounterOpts{ + certsReceived: prometheus.NewCounter(prometheus.CounterOpts{ Namespace: "chain", - Name: "executor_verify_executable", - Help: "executor tasks executable during verify", + Name: "certs_received", + Help: "certificates received from validators", + }), + chunksExecuted: prometheus.NewCounter(prometheus.CounterOpts{ + Namespace: "chain", + Name: "chunks_executed", + Help: "chunks executed by the engine", + }), + txRPCAuthorized: prometheus.NewCounter(prometheus.CounterOpts{ + Namespace: "chain", + Name: "tx_rpc_authorized", + Help: "number of txs authorized during RPC processing", + }), + blockVerifyFailed: prometheus.NewCounter(prometheus.CounterOpts{ + Namespace: "chain", + Name: "block_verify_failed", + Help: "number of blocks that failed verification", + }), + gossipTxMsgInvalid: prometheus.NewCounter(prometheus.CounterOpts{ + Namespace: "chain", + Name: "gossip_tx_msg_invalid", + Help: "number of invalid transaction messages received over gossip", + }), + gossipTxInvalid: prometheus.NewCounter(prometheus.CounterOpts{ + Namespace: "chain", + Name: "gossip_tx_invalid", + Help: "number of invalid transactions received over gossip", + }), + gossipChunkInvalid: prometheus.NewCounter(prometheus.CounterOpts{ + Namespace: "chain", + Name: "gossip_chunk_invalid", + Help: "number of invalid chunks received over gossip", + }), + gossipChunkSigInvalid: prometheus.NewCounter(prometheus.CounterOpts{ + Namespace: "chain", + Name: "gossip_chunk_sig_invalid", + Help: "number of invalid chunk signatures received over gossip", + }), + gossipCertInvalid: prometheus.NewCounter(prometheus.CounterOpts{ + Namespace: "chain", + Name: "gossip_cert_invalid", + Help: "number of invalid certificates received over gossip", + }), + rpcTxInvalid: prometheus.NewCounter(prometheus.CounterOpts{ + Namespace: "chain", + Name: "rpc_tx_invalid", + Help: "number of invalid transactions received over RPC", + }), + expiredBuiltChunks: prometheus.NewCounter(prometheus.CounterOpts{ + Namespace: "chain", + Name: "expired_built_chunks", + Help: "number of chunks that expired after being built", + }), + expiredCerts: prometheus.NewCounter(prometheus.CounterOpts{ + Namespace: "chain", + Name: "expired_certs", + Help: "number of certificates that expired", + }), + mempoolExpired: prometheus.NewCounter(prometheus.CounterOpts{ + Namespace: "chain", + Name: "mempool_expired", + Help: "number of transactions that expired while in the mempool", + }), + fetchChunkAttempts: prometheus.NewCounter(prometheus.CounterOpts{ + Namespace: "chain", + Name: "fetch_chunk_attempts", + Help: "number of attempts to fetch a chunk", + }), + uselessFetchChunkAttempts: prometheus.NewCounter(prometheus.CounterOpts{ + Namespace: "chain", + Name: "useless_fetch_chunk_attempts", + Help: "number of attempts to fetch a chunk that were useless (received via push)", + }), + txGossipDropped: prometheus.NewCounter(prometheus.CounterOpts{ + Namespace: "chain", + Name: "tx_gossip_dropped", + Help: "number of tx gossip messages dropped", + }), + chunkGossipDropped: prometheus.NewCounter(prometheus.CounterOpts{ + Namespace: "chain", + Name: "chunk_gossip_dropped", + Help: "number of chunks dropped from gossip", + }), + unitsExecutedBandwidth: prometheus.NewCounter(prometheus.CounterOpts{ + Namespace: "chain", + Name: "units_executed_bandwidth", + Help: "number of bandwidth units executed", + }), + unitsExecutedCompute: prometheus.NewCounter(prometheus.CounterOpts{ + Namespace: "chain", + Name: "units_executed_compute", + Help: "number of compute units executed", + }), + unitsExecutedRead: prometheus.NewCounter(prometheus.CounterOpts{ + Namespace: "chain", + Name: "units_executed_read", + Help: "number of read units executed", + }), + unitsExecutedAllocate: prometheus.NewCounter(prometheus.CounterOpts{ + Namespace: "chain", + Name: "units_executed_allocate", + Help: "number of allocate units executed", + }), + unitsExecutedWrite: prometheus.NewCounter(prometheus.CounterOpts{ + Namespace: "chain", + Name: "units_executed_write", + Help: "number of write units executed", + }), + optimisticCertifiedGossip: prometheus.NewCounter(prometheus.CounterOpts{ + Namespace: "chain", + Name: "optimistic_certified_gossip", + Help: "number of optimistic certified messages sent over gossip", + }), + uselessChunkAuth: prometheus.NewCounter(prometheus.CounterOpts{ + Namespace: "chain", + Name: "useless_chunk_auth", + Help: "number of chunks that were authenticated but not executed", + }), + engineBacklog: prometheus.NewGauge(prometheus.GaugeOpts{ + Namespace: "chain", + Name: "engine_backlog", + Help: "number of blocks waiting to be executed", + }), + rpcTxBacklog: prometheus.NewGauge(prometheus.GaugeOpts{ + Namespace: "chain", + Name: "rpc_tx_backlog", + Help: "number of transactions waiting to be processed from RPC", + }), + chainDataSize: prometheus.NewGauge(prometheus.GaugeOpts{ + Namespace: "chain", + Name: "data_size", + Help: "size of the chain data directory", + }), + executedProcessingBacklog: prometheus.NewGauge(prometheus.GaugeOpts{ + Namespace: "chain", + Name: "executed_processing_backlog", + Help: "number of blocks waiting to be processed after execution", + }), + mempoolLen: prometheus.NewGauge(prometheus.GaugeOpts{ + Namespace: "chain", + Name: "mempool_len", + Help: "number of transactions in the mempool", }), mempoolSize: prometheus.NewGauge(prometheus.GaugeOpts{ Namespace: "chain", Name: "mempool_size", - Help: "number of transactions in the mempool", + Help: "bytes in the mempool", }), - bandwidthPrice: prometheus.NewGauge(prometheus.GaugeOpts{ + gossipChunkBacklog: prometheus.NewGauge(prometheus.GaugeOpts{ Namespace: "chain", - Name: "bandwidth_price", - Help: "unit price of bandwidth", + Name: "gossip_chunk_backlog", + Help: "number of chunks waiting to be processed from gossip", }), - computePrice: prometheus.NewGauge(prometheus.GaugeOpts{ + gossipTxBacklog: prometheus.NewGauge(prometheus.GaugeOpts{ Namespace: "chain", - Name: "compute_price", - Help: "unit price of compute", + Name: "gossip_tx_backlog", + Help: "number of transactions waiting to be processed from gossip", }), - storageReadPrice: prometheus.NewGauge(prometheus.GaugeOpts{ + websocketConnections: prometheus.NewGauge(prometheus.GaugeOpts{ Namespace: "chain", - Name: "storage_read_price", - Help: "unit price of storage reads", + Name: "websocket_connections", + Help: "number of websocket connections", }), - storageAllocatePrice: prometheus.NewGauge(prometheus.GaugeOpts{ + lastAcceptedEpoch: prometheus.NewGauge(prometheus.GaugeOpts{ Namespace: "chain", - Name: "storage_create_price", - Help: "unit price of storage creates", + Name: "last_accepted_epoch", + Help: "last accepted epoch", }), - storageWritePrice: prometheus.NewGauge(prometheus.GaugeOpts{ + lastExecutedEpoch: prometheus.NewGauge(prometheus.GaugeOpts{ Namespace: "chain", - Name: "storage_modify_price", - Help: "unit price of storage modifications", + Name: "last_executed_epoch", + Help: "last executed epoch", }), - rootCalculated: rootCalculated, - waitRoot: waitRoot, - waitSignatures: waitSignatures, - blockBuild: blockBuild, - blockParse: blockParse, - blockVerify: blockVerify, - blockAccept: blockAccept, - blockProcess: blockProcess, + appendDBKeys: prometheus.NewGauge(prometheus.GaugeOpts{ + Namespace: "vilmo", + Name: "keys", + Help: "number of keys", + }), + appendDBAliveBytes: prometheus.NewGauge(prometheus.GaugeOpts{ + Namespace: "vilmo", + Name: "alive_bytes", + Help: "number of alive bytes on disk", + }), + appendDBUselessBytes: prometheus.NewGauge(prometheus.GaugeOpts{ + Namespace: "vilmo", + Name: "useless_bytes", + Help: "number of useless bytes on disk", + }), + waitRepeat: waitRepeat, + waitQueue: waitQueue, + waitAuth: waitAuth, + waitExec: waitExec, + waitPrecheck: waitPrecheck, + waitCommit: waitCommit, + chunkBuild: chunkBuild, + blockBuild: blockBuild, + blockParse: blockParse, + blockVerify: blockVerify, + blockAccept: blockAccept, + blockProcess: blockProcess, + blockExecute: blockExecute, + executedChunkProcess: executedChunkProcess, + executedBlockProcess: executedBlockProcess, + fetchMissingChunks: fetchMissingChunks, + collectChunkSignatures: collectChunkSignatures, + txTimeRemainingMempool: txTimeRemainingMempool, + chunkAuth: chunkAuth, + stateChanges: stateChanges, + appendDBBatchInit: appendDBBatchInit, + appendDBBatchInitBytes: appendDBBatchInitBytes, + appendDBBatchPrepare: appendDBBatchPrepare, + tstateIterate: tstateIterate, + appendDBBatchWrite: appendDBBatchWrite, } - m.executorBuildRecorder = &executorMetrics{blocked: m.executorBuildBlocked, executable: m.executorBuildExecutable} - m.executorVerifyRecorder = &executorMetrics{blocked: m.executorVerifyBlocked, executable: m.executorVerifyExecutable} + m.executorRecorder = &executorMetrics{blocked: m.executorBlocked, executable: m.executorExecutable} errs := wrappers.Errs{} errs.Add( r.Register(m.txsSubmitted), r.Register(m.txsReceived), - r.Register(m.seenTxsReceived), r.Register(m.txsGossiped), - r.Register(m.txsVerified), - r.Register(m.txsAccepted), - r.Register(m.stateChanges), - r.Register(m.stateOperations), - r.Register(m.mempoolSize), - r.Register(m.buildCapped), - r.Register(m.emptyBlockBuilt), - r.Register(m.clearedMempool), + r.Register(m.txsIncluded), + r.Register(m.txsInvalid), + r.Register(m.chunkBuildTxsDropped), + r.Register(m.blockBuildCertsDropped), + r.Register(m.remainingMempool), + r.Register(m.chunkBytesBuilt), r.Register(m.deletedBlocks), + r.Register(m.deletedUselessChunks), + r.Register(m.deletedIncludedChunks), + r.Register(m.deletedFilteredChunks), r.Register(m.blocksFromDisk), r.Register(m.blocksHeightsFromDisk), - r.Register(m.executorBuildBlocked), - r.Register(m.executorBuildExecutable), - r.Register(m.executorVerifyBlocked), - r.Register(m.executorVerifyExecutable), - r.Register(m.bandwidthPrice), - r.Register(m.computePrice), - r.Register(m.storageReadPrice), - r.Register(m.storageAllocatePrice), - r.Register(m.storageWritePrice), + r.Register(m.executorBlocked), + r.Register(m.executorExecutable), + r.Register(m.chunksReceived), + r.Register(m.sigsReceived), + r.Register(m.certsReceived), + r.Register(m.chunksExecuted), + r.Register(m.txRPCAuthorized), + r.Register(m.blockVerifyFailed), + r.Register(m.gossipTxMsgInvalid), + r.Register(m.gossipTxInvalid), + r.Register(m.chunkGossipDropped), + r.Register(m.gossipChunkInvalid), + r.Register(m.gossipChunkSigInvalid), + r.Register(m.gossipCertInvalid), + r.Register(m.rpcTxInvalid), + r.Register(m.expiredBuiltChunks), + r.Register(m.expiredCerts), + r.Register(m.mempoolExpired), + r.Register(m.fetchChunkAttempts), + r.Register(m.uselessFetchChunkAttempts), + r.Register(m.engineBacklog), + r.Register(m.rpcTxBacklog), + r.Register(m.chainDataSize), + r.Register(m.executedProcessingBacklog), + r.Register(m.mempoolLen), + r.Register(m.mempoolSize), + r.Register(m.gossipChunkBacklog), + r.Register(m.gossipTxBacklog), + r.Register(m.websocketConnections), + r.Register(m.lastAcceptedEpoch), + r.Register(m.lastExecutedEpoch), + r.Register(m.txGossipDropped), + r.Register(m.unitsExecutedBandwidth), + r.Register(m.unitsExecutedCompute), + r.Register(m.unitsExecutedRead), + r.Register(m.unitsExecutedAllocate), + r.Register(m.unitsExecutedWrite), + r.Register(m.uselessChunkAuth), + r.Register(m.optimisticCertifiedGossip), + r.Register(m.appendDBBatchesRewritten), + r.Register(m.appendDBKeys), + r.Register(m.appendDBAliveBytes), + r.Register(m.appendDBUselessBytes), ) return r, m, errs.Err } diff --git a/vm/network_state_sync.go b/vm/network_state_sync.go deleted file mode 100644 index 1f5f74adde..0000000000 --- a/vm/network_state_sync.go +++ /dev/null @@ -1,84 +0,0 @@ -// Copyright (C) 2023, Ava Labs, Inc. All rights reserved. -// See the file LICENSE for licensing terms. - -package vm - -import ( - "context" - "time" - - "github.com/ava-labs/avalanchego/ids" - "github.com/ava-labs/avalanchego/version" -) - -type StateSyncHandler struct { - vm *VM -} - -func NewStateSyncHandler(vm *VM) *StateSyncHandler { - return &StateSyncHandler{vm} -} - -func (s *StateSyncHandler) Connected( - ctx context.Context, - nodeID ids.NodeID, - v *version.Application, -) error { - return s.vm.stateSyncNetworkClient.Connected(ctx, nodeID, v) -} - -func (s *StateSyncHandler) Disconnected(ctx context.Context, nodeID ids.NodeID) error { - return s.vm.stateSyncNetworkClient.Disconnected(ctx, nodeID) -} - -func (*StateSyncHandler) AppGossip(context.Context, ids.NodeID, []byte) error { - return nil -} - -func (s *StateSyncHandler) AppRequest( - ctx context.Context, - nodeID ids.NodeID, - requestID uint32, - deadline time.Time, - request []byte, -) error { - if delay := s.vm.config.GetStateSyncServerDelay(); delay > 0 { - time.Sleep(delay) - } - return s.vm.stateSyncNetworkServer.AppRequest(ctx, nodeID, requestID, deadline, request) -} - -func (s *StateSyncHandler) AppRequestFailed( - ctx context.Context, - nodeID ids.NodeID, - requestID uint32, -) error { - return s.vm.stateSyncNetworkClient.AppRequestFailed(ctx, nodeID, requestID) -} - -func (s *StateSyncHandler) AppResponse( - ctx context.Context, - nodeID ids.NodeID, - requestID uint32, - response []byte, -) error { - return s.vm.stateSyncNetworkClient.AppResponse(ctx, nodeID, requestID, response) -} - -func (*StateSyncHandler) CrossChainAppRequest( - context.Context, - ids.ID, - uint32, - time.Time, - []byte, -) error { - return nil -} - -func (*StateSyncHandler) CrossChainAppRequestFailed(context.Context, ids.ID, uint32) error { - return nil -} - -func (*StateSyncHandler) CrossChainAppResponse(context.Context, ids.ID, uint32, []byte) error { - return nil -} diff --git a/vm/proposer_monitor.go b/vm/proposer_monitor.go index d94bbed11f..f9e843b32f 100644 --- a/vm/proposer_monitor.go +++ b/vm/proposer_monitor.go @@ -5,16 +5,22 @@ package vm import ( "context" + "encoding/binary" + "errors" "fmt" + "math/big" "sync" "time" + "github.com/AnomalyFi/hypersdk/codec" + "github.com/AnomalyFi/hypersdk/consts" + "github.com/AnomalyFi/hypersdk/crypto/bls" + "github.com/AnomalyFi/hypersdk/utils" "github.com/ava-labs/avalanchego/cache" "github.com/ava-labs/avalanchego/ids" "github.com/ava-labs/avalanchego/snow/validators" - "github.com/ava-labs/avalanchego/utils/crypto/bls" "github.com/ava-labs/avalanchego/utils/set" - "github.com/ava-labs/avalanchego/vms/proposervm/proposer" + "github.com/ava-labs/avalanchego/vms/platformvm/warp" "go.uber.org/zap" ) @@ -23,46 +29,122 @@ const ( proposerMonitorLRUSize = 60 ) -type ProposerMonitor struct { - vm *VM - proposer proposer.Windower +type proposerInfo struct { + validators map[ids.NodeID]*validators.GetValidatorOutput + partitionSet []ids.NodeID + warpSet []*warp.Validator + totalWeight uint64 +} - currentPHeight uint64 - lastFetchedPHeight time.Time - validators map[ids.NodeID]*validators.GetValidatorOutput - validatorPublicKeys map[string]struct{} +type ProposerMonitor struct { + vm *VM - proposerCache *cache.LRU[string, []ids.NodeID] + fetchLock sync.Mutex + proposers *cache.LRU[uint64, *proposerInfo] // safe for concurrent use. - rl sync.Mutex + currentLock sync.Mutex + currentPHeight uint64 + lastFetchedPHeight time.Time + currentValidators map[ids.NodeID]*validators.GetValidatorOutput } func NewProposerMonitor(vm *VM) *ProposerMonitor { return &ProposerMonitor{ - vm: vm, - proposer: proposer.New( - vm.snowCtx.ValidatorState, - vm.snowCtx.SubnetID, - vm.snowCtx.ChainID, - ), - proposerCache: &cache.LRU[string, []ids.NodeID]{Size: proposerMonitorLRUSize}, + vm: vm, + proposers: &cache.LRU[uint64, *proposerInfo]{Size: proposerMonitorLRUSize}, } } -func (p *ProposerMonitor) refresh(ctx context.Context) error { - p.rl.Lock() - defer p.rl.Unlock() - - // Refresh P-Chain height if [refreshTime] has elapsed - if time.Since(p.lastFetchedPHeight) < refreshTime { +// TODO: don't add validators that won't be validators for the entire epoch +func (p *ProposerMonitor) fetch(ctx context.Context, height uint64) *proposerInfo { + validators, err := p.vm.snowCtx.ValidatorState.GetValidatorSet( + ctx, + height, + p.vm.snowCtx.SubnetID, + ) + if err != nil { + p.vm.snowCtx.Log.Error("failed to fetch proposer set", zap.Uint64("height", height), zap.Error(err)) + return nil + } + partitionSet, warpSet, totalWeight, err := utils.ConstructCanonicalValidatorSet(validators) + if err != nil { + p.vm.snowCtx.Log.Error("failed to construct canonical validator set", zap.Uint64("height", height), zap.Error(err)) return nil } - start := time.Now() + info := &proposerInfo{ + validators: validators, + partitionSet: partitionSet, + warpSet: warpSet, + totalWeight: totalWeight, + } + p.proposers.Put(height, info) + return info +} + +// Fetch is used to pre-cache sets that will be used later +// +// TODO: remove lock? replace with lockmap? +func (p *ProposerMonitor) Fetch(ctx context.Context, height uint64) *proposerInfo { + p.fetchLock.Lock() + defer p.fetchLock.Unlock() + + return p.fetch(ctx, height) +} + +func (p *ProposerMonitor) IsValidator(ctx context.Context, height uint64, nodeID ids.NodeID) (bool, *bls.PublicKey, uint64, error) { + info, ok := p.proposers.Get(height) + if !ok { + info = p.Fetch(ctx, height) + } + if info == nil { + return false, nil, 0, errors.New("could not get validator set for height") + } + output, exists := info.validators[nodeID] + if exists { + return true, output.PublicKey, output.Weight, nil + } + return false, nil, 0, nil +} + +// GetWarpValidatorSet returns the validator set of [subnetID] in a canonical ordering. +// Also returns the total weight on [subnetID]. +func (p *ProposerMonitor) GetWarpValidatorSet(ctx context.Context, height uint64) ([]*warp.Validator, uint64, error) { + info, ok := p.proposers.Get(height) + if !ok { + info = p.Fetch(ctx, height) + } + if info == nil { + return nil, 0, errors.New("could not get validator set for height") + } + return info.warpSet, info.totalWeight, nil +} + +func (p *ProposerMonitor) GetValidatorSet(ctx context.Context, height uint64, includeMe bool) (set.Set[ids.NodeID], error) { + info, ok := p.proposers.Get(height) + if !ok { + info = p.Fetch(ctx, height) + } + if info == nil { + return nil, errors.New("could not get validator set for height") + } + vdrSet := set.NewSet[ids.NodeID](len(info.validators)) + for v := range info.validators { + if v == p.vm.snowCtx.NodeID && !includeMe { + continue + } + vdrSet.Add(v) + } + return vdrSet, nil +} + +func (p *ProposerMonitor) refreshCurrent(ctx context.Context) error { pHeight, err := p.vm.snowCtx.ValidatorState.GetCurrentHeight(ctx) if err != nil { + p.currentLock.Unlock() return err } - p.validators, err = p.vm.snowCtx.ValidatorState.GetValidatorSet( + p.currentPHeight = pHeight + validators, err := p.vm.snowCtx.ValidatorState.GetValidatorSet( ctx, pHeight, p.vm.snowCtx.SubnetID, @@ -70,71 +152,108 @@ func (p *ProposerMonitor) refresh(ctx context.Context) error { if err != nil { return err } - pks := map[string]struct{}{} - for _, v := range p.validators { - if v.PublicKey == nil { - continue - } - pks[string(bls.PublicKeyToCompressedBytes(v.PublicKey))] = struct{}{} - } - p.validatorPublicKeys = pks - p.vm.snowCtx.Log.Info( - "refreshed proposer monitor", - zap.Uint64("previous", p.currentPHeight), - zap.Uint64("new", pHeight), - zap.Duration("t", time.Since(start)), - ) - p.currentPHeight = pHeight p.lastFetchedPHeight = time.Now() + p.currentValidators = validators + return nil +} + +// Prevent unnecessary map copies +func (p *ProposerMonitor) IterateCurrentValidators( + ctx context.Context, + f func(ids.NodeID, *validators.GetValidatorOutput), +) error { + // Refresh P-Chain height if [refreshTime] has elapsed + p.currentLock.Lock() + if time.Since(p.lastFetchedPHeight) > refreshTime { + if err := p.refreshCurrent(ctx); err != nil { + p.currentLock.Unlock() + return err + } + } + validators := p.currentValidators + p.currentLock.Unlock() + + // Iterate over the validators + for k, v := range validators { + f(k, v) + } return nil } -func (p *ProposerMonitor) IsValidator(ctx context.Context, nodeID ids.NodeID) (bool, error) { - if err := p.refresh(ctx); err != nil { +func (p *ProposerMonitor) IsValidHeight(ctx context.Context, height uint64) (bool, error) { + p.currentLock.Lock() + defer p.currentLock.Unlock() + + if height <= p.currentPHeight { + return true, nil + } + if err := p.refreshCurrent(ctx); err != nil { return false, err } - _, ok := p.validators[nodeID] - return ok, nil + return height <= p.currentPHeight, nil } -func (p *ProposerMonitor) Proposers( +// Prevent unnecessary map copies +func (p *ProposerMonitor) IterateValidators( ctx context.Context, - diff int, - depth int, -) (set.Set[ids.NodeID], error) { - if err := p.refresh(ctx); err != nil { - return nil, err + height uint64, + f func(ids.NodeID, *validators.GetValidatorOutput), +) error { + info, ok := p.proposers.Get(height) + if !ok { + info = p.Fetch(ctx, height) } - preferredBlk, err := p.vm.GetStatelessBlock(ctx, p.vm.preferred) - if err != nil { - return nil, err - } - proposersToGossip := set.NewSet[ids.NodeID](diff * depth) - udepth := uint64(depth) - for i := uint64(1); i <= uint64(diff); i++ { - height := preferredBlk.Hght + i - key := fmt.Sprintf("%d-%d", height, p.currentPHeight) - var proposers []ids.NodeID - if v, ok := p.proposerCache.Get(key); ok { - proposers = v - } else { - proposers, err = p.proposer.Proposers(ctx, height, p.currentPHeight, diff) - if err != nil { - return nil, err - } - p.proposerCache.Put(key, proposers) - } - arrLen := min(udepth, uint64(len(proposers))) - proposersToGossip.Add(proposers[:arrLen]...) + if info == nil { + return errors.New("could not get validator set for height") } - return proposersToGossip, nil + for k, v := range info.validators { + f(k, v) + } + return nil } -func (p *ProposerMonitor) Validators( - ctx context.Context, -) (map[ids.NodeID]*validators.GetValidatorOutput, map[string]struct{}) { - if err := p.refresh(ctx); err != nil { - return nil, nil +func (p *ProposerMonitor) RandomValidator(ctx context.Context, height uint64) (ids.NodeID, error) { + info, ok := p.proposers.Get(height) + if !ok { + info = p.Fetch(ctx, height) + } + if info == nil { + return ids.NodeID{}, errors.New("could not get validator set for height") } - return p.validators, p.validatorPublicKeys + for k := range info.validators { // Golang map iteration order is random + return k, nil + } + return ids.NodeID{}, fmt.Errorf("no validators") +} + +// TODO: Generate a Deterministic PRNG for assigning namespace to validator. +func (p *ProposerMonitor) AddressPartition(ctx context.Context, epoch uint64, height uint64, addr codec.Address, partition uint8) (ids.NodeID, error) { + // Get determinisitc ordering of validators + info, ok := p.proposers.Get(height) + if !ok { + info = p.Fetch(ctx, height) + } + if info == nil { + return ids.NodeID{}, errors.New("could not get validator set for height") + } + if len(info.partitionSet) == 0 { + return ids.NodeID{}, errors.New("no validators") + } + + // Compute seed + seedBytes := make([]byte, consts.Uint64Len*2+codec.AddressLen) + binary.BigEndian.PutUint64(seedBytes, epoch) // ensures partitions rotate even if P-Chain height is static + binary.BigEndian.PutUint64(seedBytes[consts.Uint64Len:], height) + copy(seedBytes[consts.Uint64Len*2:], addr[:]) + seed := utils.ToID(seedBytes) + + // Select validator + // + // It is important to ensure each partition is actually a unique validator, otherwise + // the censorship resistance that partitions are supposed to provide is lost (all partitions + // could be allocated to a single validator if we aren't careful). + seedInt := new(big.Int).SetBytes(seed[:]) + partitionInt := new(big.Int).Add(seedInt, big.NewInt(int64(partition))) + partitionIdx := new(big.Int).Mod(partitionInt, big.NewInt(int64(len(info.partitionSet)))).Int64() + return info.partitionSet[int(partitionIdx)], nil } diff --git a/vm/resolutions.go b/vm/resolutions.go index 9881875eb4..1657301785 100644 --- a/vm/resolutions.go +++ b/vm/resolutions.go @@ -14,23 +14,21 @@ import ( "github.com/ava-labs/avalanchego/trace" "github.com/ava-labs/avalanchego/utils/logging" "github.com/ava-labs/avalanchego/utils/set" - "github.com/ava-labs/avalanchego/x/merkledb" + "github.com/ava-labs/avalanchego/vms/platformvm/warp" "go.uber.org/zap" - "github.com/AnomalyFi/hypersdk/builder" "github.com/AnomalyFi/hypersdk/chain" + "github.com/AnomalyFi/hypersdk/codec" + "github.com/AnomalyFi/hypersdk/crypto/bls" "github.com/AnomalyFi/hypersdk/executor" "github.com/AnomalyFi/hypersdk/fees" - "github.com/AnomalyFi/hypersdk/gossiper" - "github.com/AnomalyFi/hypersdk/workers" + "github.com/AnomalyFi/hypersdk/vilmo" ) var ( - _ chain.VM = (*VM)(nil) - _ gossiper.VM = (*VM)(nil) - _ builder.VM = (*VM)(nil) - _ block.ChainVM = (*VM)(nil) - _ block.StateSyncableVM = (*VM)(nil) + _ chain.VM = (*VM)(nil) + _ block.ChainVM = (*VM)(nil) + _ block.BuildBlockWithContextChainVM = (*VM)(nil) ) func (vm *VM) ChainID() ids.ID { @@ -53,10 +51,6 @@ func (vm *VM) Registry() (chain.ActionRegistry, chain.AuthRegistry) { return vm.actionRegistry, vm.authRegistry } -func (vm *VM) AuthVerifiers() workers.Workers { - return vm.authVerifiers -} - func (vm *VM) Tracer() trace.Tracer { return vm.tracer } @@ -82,11 +76,8 @@ func (vm *VM) IsBootstrapped() bool { return vm.bootstrapped.Get() } -func (vm *VM) State() (merkledb.MerkleDB, error) { - // As soon as synced (before ready), we can safely request data from the db. - if !vm.StateReady() { - return nil, ErrStateMissing - } +func (vm *VM) State() (*vilmo.Vilmo, error) { + // TODO: enable state sync return vm.stateDB, nil } @@ -94,52 +85,161 @@ func (vm *VM) Mempool() chain.Mempool { return vm.mempool } -func (vm *VM) IsRepeat(ctx context.Context, txs []*chain.Transaction, marker set.Bits, stop bool) set.Bits { - _, span := vm.tracer.Start(ctx, "VM.IsRepeat") +func (vm *VM) IsRepeatTx(ctx context.Context, txs []*chain.Transaction, marker set.Bits, stop bool) set.Bits { + _, span := vm.tracer.Start(ctx, "VM.IsRepeatTx") + defer span.End() + + return vm.seenTxs.Contains(txs, marker, stop) +} + +func (vm *VM) IsRepeatChunk(ctx context.Context, certs []*chain.ChunkCertificate, marker set.Bits) set.Bits { + _, span := vm.tracer.Start(ctx, "VM.IsRepeatChunk") defer span.End() - return vm.seen.Contains(txs, marker, stop) + return vm.seenChunks.Contains(certs, marker, false) +} + +func (vm *VM) IsSeenChunk(ctx context.Context, chunkID ids.ID) bool { + return vm.seenChunks.HasID(chunkID) } func (vm *VM) Verified(ctx context.Context, b *chain.StatelessBlock) { ctx, span := vm.tracer.Start(ctx, "VM.Verified") defer span.End() - vm.metrics.txsVerified.Add(float64(len(b.Txs))) vm.verifiedL.Lock() vm.verifiedBlocks[b.ID()] = b vm.verifiedL.Unlock() vm.parsedBlocks.Evict(b.ID()) - vm.mempool.Remove(ctx, b.Txs) - vm.gossiper.BlockVerified(b.Tmstmp) - vm.checkActivity(ctx) - if b.Processed() { - fm := b.FeeManager() - vm.snowCtx.Log.Info( - "verified block", - zap.Stringer("blkID", b.ID()), - zap.Uint64("height", b.Hght), - zap.Int("txs", len(b.Txs)), - zap.Stringer("parent root", b.StateRoot), - zap.Bool("state ready", vm.StateReady()), - zap.Any("unit prices", fm.UnitPrices()), - zap.Any("units consumed", fm.UnitsConsumed()), - ) - } else { - // [b.FeeManager] is not populated if the block - // has not been processed. - vm.snowCtx.Log.Info( - "skipped block verification", - zap.Stringer("blkID", b.ID()), - zap.Uint64("height", b.Hght), - zap.Int("txs", len(b.Txs)), - zap.Stringer("parent root", b.StateRoot), - zap.Bool("state ready", vm.StateReady()), + // We opt to not remove chunks [b.AvailableChunks] from [cm] here because + // we may build on a different parent and we want to maximize the probability + // any cert gets included. If this is not the case, the cert repeat inclusion check + // is fast. +} + +// @todo understand this? +func (vm *VM) processExecutedChunks() { + // Always close [acceptorDone] or we may block shutdown. + defer func() { + close(vm.executorDone) + vm.snowCtx.Log.Info("executor queue shutdown") + }() + + // The VM closes [executedQueue] during shutdown. We wait for all enqueued blocks + // to be processed before returning as a guarantee to listeners (which may + // persist indexed state) instead of just exiting as soon as `vm.stop` is + // closed. + for ew := range vm.executedQueue { + vm.metrics.executedProcessingBacklog.Dec() + if ew.Chunk != nil { + vm.processExecutedChunk(ew.Block, ew.Chunk, ew.Results, ew.InvalidTxs) + vm.snowCtx.Log.Debug( + "chunk async executed", + zap.Uint64("blk", ew.Block.Height), + zap.Stringer("chunkID", ew.Chunk.Chunk), + ) + continue + } + vm.processExecutedBlock(ew.Block) + vm.snowCtx.Log.Debug( + "block async executed", + zap.Uint64("blk", ew.Block.Height), ) } } +// @todo fix this? +func (vm *VM) ExecutedChunk(ctx context.Context, blk *chain.StatefulBlock, chunk *chain.FilteredChunk, results []*chain.Result, invalidTxs []ids.ID) { + ctx, span := vm.tracer.Start(ctx, "VM.ExecutedChunk") + defer span.End() + + // Mark all txs as seen (prevent replay in subsequent blocks) + // + // We do this before Accept to avoid maintaining a set of diffs + // that we need to check for repeats on top of this. + vm.seenTxs.Add(chunk.Txs) + + // Add chunk to backlog for async processing + vm.metrics.executedProcessingBacklog.Inc() + vm.executedQueue <- &executedWrapper{blk, chunk, results, invalidTxs} + + // Record units processed + chunkUnits := fees.Dimensions{} + for _, r := range results { + nextUnits, err := fees.Add(chunkUnits, r.Units) + if err != nil { + vm.Fatal("unable to add executed units", zap.Error(err)) + } + chunkUnits = nextUnits + } + vm.metrics.unitsExecutedBandwidth.Add(float64(chunkUnits[fees.Bandwidth])) + vm.metrics.unitsExecutedCompute.Add(float64(chunkUnits[fees.Compute])) + vm.metrics.unitsExecutedRead.Add(float64(chunkUnits[fees.StorageRead])) + vm.metrics.unitsExecutedAllocate.Add(float64(chunkUnits[fees.StorageAllocate])) + vm.metrics.unitsExecutedWrite.Add(float64(chunkUnits[fees.StorageWrite])) +} + +func (vm *VM) ExecutedBlock(ctx context.Context, blk *chain.StatefulBlock) { + ctx, span := vm.tracer.Start(ctx, "VM.ExecutedBlock") + defer span.End() + + // We interleave results with chunks to ensure things are processed in the write order (if processed independently, we might + // process a block execution before a chunk). + vm.metrics.executedProcessingBacklog.Inc() + vm.executedQueue <- &executedWrapper{Block: blk} +} + +func (vm *VM) processExecutedBlock(blk *chain.StatefulBlock) { + start := time.Now() + defer func() { + vm.metrics.executedBlockProcess.Observe(float64(time.Since(start))) + }() + + // Clear authorization results + vm.metrics.uselessChunkAuth.Add(float64(len(vm.cm.auth.SetMin(blk.Timestamp)))) + + // Update timestamp in mempool + // + // We wait to update the min until here because we want to allow all execution + // to complete and remove valid txs first. + ctx := context.TODO() + t := blk.Timestamp + vm.metrics.mempoolExpired.Add(float64(len(vm.mempool.SetMinTimestamp(ctx, t)))) + vm.metrics.mempoolLen.Set(float64(vm.mempool.Len(ctx))) + vm.metrics.mempoolSize.Set(float64(vm.mempool.Size(ctx))) + + // We need to wait until we may not try to verify the signature of a tx again. + vm.rpcAuthorizedTxs.SetMin(t) + + // Must clear accepted txs before [SetMinTx] or else we will errnoueously + // send [ErrExpired] messages. + if err := vm.webSocketServer.SetMinTx(t); err != nil { + vm.Fatal("unable to set min tx in websocket server", zap.Error(err)) + } +} + +func (vm *VM) processExecutedChunk( + blk *chain.StatefulBlock, + chunk *chain.FilteredChunk, + results []*chain.Result, + invalidTxs []ids.ID, +) { + start := time.Now() + defer func() { + vm.metrics.executedChunkProcess.Observe(float64(time.Since(start))) + }() + + // Remove any executed transactions + ctx := context.TODO() + vm.mempool.Remove(ctx, chunk.Txs) + + // Send notifications as soon as transactions are executed + if err := vm.webSocketServer.ExecuteChunk(blk.Height, chunk, results, invalidTxs); err != nil { + vm.Fatal("unable to execute chunk in websocket server", zap.Error(err)) + } +} + func (vm *VM) Rejected(ctx context.Context, b *chain.StatelessBlock) { ctx, span := vm.tracer.Start(ctx, "VM.Rejected") defer span.End() @@ -169,39 +269,21 @@ func (vm *VM) processAcceptedBlock(b *chain.StatelessBlock) { // // We don't need to worry about dangling messages in listeners because we // don't allow subscription until the node is healthy. - if !b.Processed() { - vm.snowCtx.Log.Info("skipping unprocessed block", zap.Uint64("height", b.Hght)) - return - } + // if !b.Processed() { + // vm.snowCtx.Log.Info("skipping unprocessed block", zap.Uint64("height", b.Hght)) + // return + // } // Update controller if err := vm.c.Accepted(context.TODO(), b); err != nil { vm.Fatal("accepted processing failed", zap.Error(err)) } - // TODO: consider removing this (unused and requires an extra iteration) - for _, tx := range b.Txs { - // Only cache auth for accepted blocks to prevent cache manipulation from RPC submissions - vm.cacheAuth(tx.Auth) - } - - // Update server + // Send notifications as soon as transactions are executed. if err := vm.webSocketServer.AcceptBlock(b); err != nil { vm.Fatal("unable to accept block in websocket server", zap.Error(err)) } - // Must clear accepted txs before [SetMinTx] or else we will errnoueously - // send [ErrExpired] messages. - if err := vm.webSocketServer.SetMinTx(b.Tmstmp); err != nil { - vm.Fatal("unable to set min tx in websocket server", zap.Error(err)) - } - // Update price metrics - feeManager := b.FeeManager() - vm.metrics.bandwidthPrice.Set(float64(feeManager.UnitPrice(fees.Bandwidth))) - vm.metrics.computePrice.Set(float64(feeManager.UnitPrice(fees.Compute))) - vm.metrics.storageReadPrice.Set(float64(feeManager.UnitPrice(fees.StorageRead))) - vm.metrics.storageAllocatePrice.Set(float64(feeManager.UnitPrice(fees.StorageAllocate))) - vm.metrics.storageWritePrice.Set(float64(feeManager.UnitPrice(fees.StorageWrite))) } func (vm *VM) processAcceptedBlocks() { @@ -297,8 +379,17 @@ func (vm *VM) Accepted(ctx context.Context, b *chain.StatelessBlock) { ) } -func (vm *VM) IsValidator(ctx context.Context, nid ids.NodeID) (bool, error) { - return vm.proposerMonitor.IsValidator(ctx, nid) +func (vm *VM) CacheValidators(ctx context.Context, height uint64) { + vm.proposerMonitor.Fetch(ctx, height) +} + +func (vm *VM) AddressPartition(ctx context.Context, epoch uint64, height uint64, addr codec.Address, partition uint8) (ids.NodeID, error) { + return vm.proposerMonitor.AddressPartition(ctx, epoch, height, addr, partition) +} + +func (vm *VM) IsValidator(ctx context.Context, height uint64, nid ids.NodeID) (bool, error) { + ok, _, _, err := vm.proposerMonitor.IsValidator(ctx, height, nid) + return ok, err } func (vm *VM) Proposers(ctx context.Context, diff int, depth int) (set.Set[ids.NodeID], error) { @@ -328,69 +419,51 @@ func (vm *VM) EngineChan() chan<- common.Message { } // Used for integration and load testing -func (vm *VM) Builder() builder.Builder { - return vm.builder -} - -func (vm *VM) Gossiper() gossiper.Gossiper { - return vm.gossiper -} - -func (vm *VM) AcceptedSyncableBlock( - ctx context.Context, - sb *chain.SyncableBlock, -) (block.StateSyncMode, error) { - return vm.stateSyncClient.AcceptedSyncableBlock(ctx, sb) -} - -func (vm *VM) StateReady() bool { - if vm.stateSyncClient == nil { - // Can occur in test - return false - } - return vm.stateSyncClient.StateReady() -} - -func (vm *VM) UpdateSyncTarget(b *chain.StatelessBlock) (bool, error) { - return vm.stateSyncClient.UpdateSyncTarget(b) -} +// func (vm *VM) Builder() builder.Builder { +// return vm.builder +// } -func (vm *VM) GetOngoingSyncStateSummary(ctx context.Context) (block.StateSummary, error) { - return vm.stateSyncClient.GetOngoingSyncStateSummary(ctx) -} - -func (vm *VM) StateSyncEnabled(ctx context.Context) (bool, error) { - return vm.stateSyncClient.StateSyncEnabled(ctx) -} +// func (vm *VM) Gossiper() gossiper.Gossiper { +// return vm.gossiper +// } func (vm *VM) StateManager() chain.StateManager { return vm.c.StateManager() } -func (vm *VM) RecordRootCalculated(t time.Duration) { - vm.metrics.rootCalculated.Observe(float64(t)) +func (vm *VM) RecordWaitAuth(t time.Duration) { + vm.metrics.waitAuth.Observe(float64(t)) } -func (vm *VM) RecordWaitRoot(t time.Duration) { - vm.metrics.waitRoot.Observe(float64(t)) +func (vm *VM) RecordWaitExec(c int) { + vm.metrics.waitExec.Observe(float64(c)) } -func (vm *VM) RecordWaitSignatures(t time.Duration) { - vm.metrics.waitSignatures.Observe(float64(t)) +func (vm *VM) RecordWaitPrecheck(t time.Duration) { + vm.metrics.waitPrecheck.Observe(float64(t)) } -func (vm *VM) RecordStateChanges(c int) { - vm.metrics.stateChanges.Add(float64(c)) +func (vm *VM) RecordWaitCommit(t time.Duration) { + vm.metrics.waitCommit.Observe(float64(t)) } -func (vm *VM) RecordStateOperations(c int) { - vm.metrics.stateOperations.Add(float64(c)) +func (vm *VM) RecordStateChanges(c int) { + vm.metrics.stateChanges.Observe(float64(c)) } func (vm *VM) GetVerifyAuth() bool { return vm.config.GetVerifyAuth() } +func (vm *VM) GetAuthExecutionCores() int { + return vm.config.GetAuthExecutionCores() +} + +// This must be non-nil or the VM won't be able to produce chunks +func (vm *VM) Beneficiary() codec.Address { + return vm.config.GetBeneficiary() +} + func (vm *VM) GetStoreBlockResultsOnDisk() bool { return vm.config.GetStoreBlockResultsOnDisk() } @@ -403,24 +476,20 @@ func (vm *VM) RecordTxsReceived(c int) { vm.metrics.txsReceived.Add(float64(c)) } -func (vm *VM) RecordSeenTxsReceived(c int) { - vm.metrics.seenTxsReceived.Add(float64(c)) -} - -func (vm *VM) RecordBuildCapped() { - vm.metrics.buildCapped.Inc() -} - -func (vm *VM) GetTargetBuildDuration() time.Duration { - return vm.config.GetTargetBuildDuration() +func (vm *VM) GetTargetChunkBuildDuration() time.Duration { + return vm.config.GetTargetChunkBuildDuration() } -func (vm *VM) GetTargetGossipDuration() time.Duration { - return vm.config.GetTargetGossipDuration() -} +// func (vm *VM) GetTargetGossipDuration() time.Duration { +// return vm.config.GetTargetGossipDuration() +// } -func (vm *VM) RecordEmptyBlockBuilt() { - vm.metrics.emptyBlockBuilt.Inc() +func (vm *VM) cacheAuth(auth chain.Auth) { + bv, ok := vm.authEngine[auth.GetTypeID()] + if !ok { + return + } + bv.Cache(auth) } func (vm *VM) GetAuthBatchVerifier(authTypeID uint8, cores int, count int) (chain.AuthBatchVerifier, bool) { @@ -431,14 +500,6 @@ func (vm *VM) GetAuthBatchVerifier(authTypeID uint8, cores int, count int) (chai return bv.GetBatchVerifier(cores, count), ok } -func (vm *VM) cacheAuth(auth chain.Auth) { - bv, ok := vm.authEngine[auth.GetTypeID()] - if !ok { - return - } - bv.Cache(auth) -} - func (vm *VM) RecordBlockVerify(t time.Duration) { vm.metrics.blockVerify.Observe(float64(t)) } @@ -447,30 +508,168 @@ func (vm *VM) RecordBlockAccept(t time.Duration) { vm.metrics.blockAccept.Observe(float64(t)) } -func (vm *VM) RecordClearedMempool() { - vm.metrics.clearedMempool.Inc() +func (vm *VM) RecordBlockExecute(t time.Duration) { + vm.metrics.blockExecute.Observe(float64(t)) +} + +func (vm *VM) RecordRemainingMempool(l int) { + vm.metrics.remainingMempool.Add(float64(l)) } func (vm *VM) UnitPrices(context.Context) (fees.Dimensions, error) { - v, err := vm.stateDB.Get(chain.FeeKey(vm.StateManager().FeeKey())) - if err != nil { - return fees.Dimensions{}, err - } - return fees.NewManager(v).UnitPrices(), nil + // v, err := vm.stateDB.Get(chain.FeeKey(vm.StateManager().FeeKey())) + // if err != nil { + // return fees.Dimensions{}, err + // } + // return fees.NewManager(v).UnitPrices(), nil + return vm.Rules(time.Now().UnixMilli()).GetUnitPrices(), nil +} + +func (vm *VM) GetActionExecutionCores() int { + return vm.config.GetActionExecutionCores() +} + +func (vm *VM) GetExecutorRecorder() executor.Metrics { + return vm.metrics.executorRecorder +} + +func (vm *VM) StartCertStream(context.Context) { + vm.cm.certs.StartStream() +} + +func (vm *VM) StreamCert(ctx context.Context) (*chain.ChunkCertificate, bool) { + return vm.cm.certs.Stream(ctx) +} + +func (vm *VM) FinishCertStream(_ context.Context, certs []*chain.ChunkCertificate) { + vm.cm.certs.FinishStream(certs) +} + +func (vm *VM) RestoreChunkCertificates(ctx context.Context, certs []*chain.ChunkCertificate) { + vm.cm.RestoreChunkCertificates(ctx, certs) +} + +func (vm *VM) Engine() *chain.Engine { + return vm.engine +} + +func (vm *VM) IsIssuedTx(_ context.Context, tx *chain.Transaction) bool { + return vm.issuedTxs.Has(tx) +} + +func (vm *VM) IssueTx(_ context.Context, tx *chain.Transaction) { + vm.issuedTxs.Add([]*chain.Transaction{tx}) +} + +func (vm *VM) Signer() *bls.PublicKey { + return vm.snowCtx.PublicKey +} + +func (vm *VM) Sign(msg *warp.UnsignedMessage) ([]byte, error) { + return vm.snowCtx.WarpSigner.Sign(msg) +} + +func (vm *VM) RequestChunks(block uint64, certs []*chain.ChunkCertificate, chunks chan *chain.Chunk) { + vm.cm.RequestChunks(block, certs, chunks) +} + +func (vm *VM) RecordEngineBacklog(c int) { + vm.metrics.engineBacklog.Add(float64(c)) +} + +func (vm *VM) RecordExecutedChunks(c int) { + vm.metrics.chunksExecuted.Add(float64(c)) +} + +func (vm *VM) RecordWaitRepeat(t time.Duration) { + vm.metrics.waitRepeat.Observe(float64(t)) +} + +func (vm *VM) GetAuthRPCCores() int { + return vm.config.GetAuthRPCCores() +} + +func (vm *VM) GetAuthRPCBacklog() int { + return vm.config.GetAuthRPCBacklog() +} + +func (vm *VM) RecordRPCTxBacklog(c int64) { + vm.metrics.rpcTxBacklog.Set(float64(c)) +} + +func (vm *VM) AddRPCAuthorized(tx *chain.Transaction) { + vm.rpcAuthorizedTxs.Add([]*chain.Transaction{tx}) +} + +func (vm *VM) IsRPCAuthorized(txID ids.ID) bool { + return vm.rpcAuthorizedTxs.HasID(txID) +} + +func (vm *VM) RecordRPCAuthorizedTx() { + vm.metrics.txRPCAuthorized.Inc() +} + +func (vm *VM) RecordBlockVerifyFail() { + vm.metrics.blockVerifyFailed.Inc() +} + +func (vm *VM) RecordWebsocketConnection(c int) { + vm.metrics.websocketConnections.Add(float64(c)) +} + +func (vm *VM) RecordChunkBuildTxDropped() { + vm.metrics.chunkBuildTxsDropped.Inc() +} + +func (vm *VM) RecordRPCTxInvalid() { + vm.metrics.rpcTxInvalid.Inc() +} + +func (vm *VM) RecordBlockBuildCertDropped() { + vm.metrics.blockBuildCertsDropped.Inc() +} + +func (vm *VM) RecordAcceptedEpoch(e uint64) { + vm.metrics.lastAcceptedEpoch.Set(float64(e)) +} + +func (vm *VM) RecordExecutedEpoch(e uint64) { + vm.metrics.lastExecutedEpoch.Set(float64(e)) +} + +func (vm *VM) GetAuthResult(chunkID ids.ID) bool { + // TODO: clean up this invocation + return vm.cm.auth.Wait(chunkID) +} + +func (vm *VM) RecordWaitQueue(t time.Duration) { + vm.metrics.waitQueue.Observe(float64(t)) +} + +func (vm *VM) GetPrecheckCores() int { + return vm.config.GetPrecheckCores() +} + +func (vm *VM) RecordVilmoBatchInit(t time.Duration) { + vm.metrics.appendDBBatchInit.Observe(float64(t)) +} + +func (vm *VM) RecordVilmoBatchInitBytes(b int64) { + vm.metrics.appendDBBatchInitBytes.Observe(float64(b)) } -func (vm *VM) GetTransactionExecutionCores() int { - return vm.config.GetTransactionExecutionCores() +func (vm *VM) RecordVilmoBatchesRewritten() { + vm.metrics.appendDBBatchesRewritten.Inc() } -func (vm *VM) GetStateFetchConcurrency() int { - return vm.config.GetStateFetchConcurrency() +func (vm *VM) RecordVilmoBatchPrepare(t time.Duration) { + vm.metrics.appendDBBatchPrepare.Observe(float64(t)) } -func (vm *VM) GetExecutorBuildRecorder() executor.Metrics { - return vm.metrics.executorBuildRecorder +func (vm *VM) RecordTStateIterate(t time.Duration) { + vm.metrics.tstateIterate.Observe(float64(t)) } -func (vm *VM) GetExecutorVerifyRecorder() executor.Metrics { - return vm.metrics.executorVerifyRecorder +func (vm *VM) RecordVilmoBatchWrite(t time.Duration) { + vm.metrics.appendDBBatchWrite.Observe(float64(t)) } diff --git a/vm/syncervm_client.go b/vm/syncervm_client.go deleted file mode 100644 index a29c54e794..0000000000 --- a/vm/syncervm_client.go +++ /dev/null @@ -1,273 +0,0 @@ -// Copyright (C) 2023, Ava Labs, Inc. All rights reserved. -// See the file LICENSE for licensing terms. - -package vm - -import ( - "context" - "errors" - "sync" - - "github.com/ava-labs/avalanchego/database" - "github.com/ava-labs/avalanchego/snow/engine/snowman/block" - "github.com/prometheus/client_golang/prometheus" - "go.uber.org/zap" - - "github.com/AnomalyFi/hypersdk/chain" - - avametrics "github.com/ava-labs/avalanchego/api/metrics" - avasync "github.com/ava-labs/avalanchego/x/sync" -) - -type stateSyncerClient struct { - vm *VM - gatherer avametrics.MultiGatherer - syncManager *avasync.Manager - - // tracks the sync target so we can update last accepted - // block when sync completes. - target *chain.StatelessBlock - targetUpdated bool - - // State Sync results - init bool - startedSync bool - stateSyncErr error - doneOnce sync.Once - done chan struct{} -} - -// TODO: break out into own package -func (vm *VM) NewStateSyncClient( - gatherer avametrics.MultiGatherer, -) *stateSyncerClient { - return &stateSyncerClient{ - vm: vm, - gatherer: gatherer, - done: make(chan struct{}), - } -} - -func (*stateSyncerClient) StateSyncEnabled(context.Context) (bool, error) { - // We always start the state syncer and may fallback to normal bootstrapping - // if we are close to tip. - // - // There is no way to trigger a full bootstrap from genesis. - return true, nil -} - -func (*stateSyncerClient) GetOngoingSyncStateSummary( - context.Context, -) (block.StateSummary, error) { - // Because the history of MerkleDB change proofs tends to be short, we always - // restart syncing from scratch. - // - // This is unlike other DB implementations where roots are persisted - // indefinitely (and it means we can continue from where we left off). - return nil, database.ErrNotFound -} - -func (s *stateSyncerClient) AcceptedSyncableBlock( - _ context.Context, - sb *chain.SyncableBlock, -) (block.StateSyncMode, error) { - s.init = true - s.vm.snowCtx.Log.Info("accepted syncable block", - zap.Uint64("height", sb.Height()), - zap.Stringer("blockID", sb.ID()), - ) - - // If we did not finish syncing, we must state sync. - syncing, err := s.vm.GetDiskIsSyncing() - if err != nil { - s.vm.snowCtx.Log.Warn("could not determine if syncing", zap.Error(err)) - return block.StateSyncSkipped, err - } - if !syncing && (s.vm.lastAccepted.Hght+s.vm.config.GetStateSyncMinBlocks() > sb.Height()) { - s.vm.snowCtx.Log.Info( - "bypassing state sync", - zap.Uint64("lastAccepted", s.vm.lastAccepted.Hght), - zap.Uint64("syncableHeight", sb.Height()), - ) - s.startedSync = true - - // We trigger [done] immediately so we let the engine know we are - // synced as soon as the [ValidityWindow] worth of txs are verified. - s.doneOnce.Do(func() { - close(s.done) - }) - - // Even when we do normal bootstrapping, we mark syncing as dynamic to - // ensure we fill [vm.seen] before transitioning to normal operation. - // - // If there is no last accepted block above genesis, we will perform normal - // bootstrapping before transitioning into normal operation. - return block.StateSyncDynamic, nil - } - - // When state syncing after restart (whether successful or not), we restart - // from scratch. - // - // MerkleDB will handle clearing any keys on-disk that are no - // longer necessary. - s.target = sb.StatelessBlock - s.vm.snowCtx.Log.Info( - "starting state sync", - zap.Uint64("height", s.target.Hght), - zap.Stringer("summary", sb), - zap.Bool("already syncing", syncing), - ) - s.startedSync = true - - // Initialize metrics for sync client - r := prometheus.NewRegistry() - metrics, err := avasync.NewMetrics("sync_client", r) - if err != nil { - return block.StateSyncSkipped, err - } - if err := s.gatherer.Register("syncer", r); err != nil { - return block.StateSyncSkipped, err - } - syncClient, err := avasync.NewClient(&avasync.ClientConfig{ - BranchFactor: s.vm.genesis.GetStateBranchFactor(), - NetworkClient: s.vm.stateSyncNetworkClient, - Log: s.vm.snowCtx.Log, - Metrics: metrics, - StateSyncNodeIDs: nil, // pull from all - }) - if err != nil { - return block.StateSyncSkipped, err - } - s.syncManager, err = avasync.NewManager(avasync.ManagerConfig{ - BranchFactor: s.vm.genesis.GetStateBranchFactor(), - DB: s.vm.stateDB, - Client: syncClient, - SimultaneousWorkLimit: s.vm.config.GetStateSyncParallelism(), - Log: s.vm.snowCtx.Log, - TargetRoot: sb.StateRoot, - }) - if err != nil { - return block.StateSyncSkipped, err - } - - // Persist that the node has started syncing. - // - // This is necessary since last accepted will be modified without - // the VM having state, so it must resume only in state-sync - // mode if interrupted. - // - // Since the sync will write directly into the state trie, - // the node cannot continue from the previous state once - // it starts state syncing. - if err := s.vm.PutDiskIsSyncing(true); err != nil { - return block.StateSyncSkipped, err - } - - // Update the last accepted to the state target block, - // since we don't want bootstrapping to fetch all the blocks - // from genesis to the sync target. - s.vm.Logger().Debug("marking block as accepted", zap.Uint64("Height", s.target.Hght), zap.Int("len(blk.bytes)", len(s.target.Bytes()))) - s.target.MarkAccepted(context.Background()) - - // Kickoff state syncing from [s.target] - if err := s.syncManager.Start(context.Background()); err != nil { - s.vm.snowCtx.Log.Warn("not starting state syncing", zap.Error(err)) - return block.StateSyncSkipped, err - } - go func() { - // wait for the work to complete on this goroutine - // - // [syncManager] guarantees this will always return so it isn't possible to - // deadlock. - s.stateSyncErr = s.syncManager.Wait(context.Background()) - s.vm.snowCtx.Log.Info("state sync done", zap.Error(s.stateSyncErr)) - if s.stateSyncErr == nil { - // if the sync was successful, update the last accepted pointers. - s.stateSyncErr = s.finishSync() - } - // notify the engine the VM is ready to participate - // in voting and it can verify blocks. - // - // This function will send a message to the VM when it has processed at least - // [ValidityWindow] blocks. - s.doneOnce.Do(func() { - close(s.done) - }) - }() - // TODO: engine will mark VM as ready when we return - // [block.StateSyncDynamic]. This should change in v1.9.11. - return block.StateSyncDynamic, nil -} - -// finishSync is responsible for updating disk and memory pointers -func (s *stateSyncerClient) finishSync() error { - if s.targetUpdated { - // Will look like block on start accepted then last block before beginning - // bootstrapping is accepted. - // - // NOTE: There may be a number of verified but unaccepted blocks above this - // block. - s.target.MarkAccepted(context.Background()) - } - return s.vm.PutDiskIsSyncing(false) -} - -func (s *stateSyncerClient) Started() bool { - return s.startedSync -} - -// ForceDone is used by the [VM] to skip the sync process or to close the -// channel if the sync process never started (i.e. [AcceptedSyncableBlock] will -// never be called) -func (s *stateSyncerClient) ForceDone() { - if s.startedSync { - // If we started sync, we must wait for it to finish - return - } - s.doneOnce.Do(func() { - close(s.done) - }) -} - -// Shutdown can be called to abort an ongoing sync. -func (s *stateSyncerClient) Shutdown() error { - if s.syncManager != nil { - s.syncManager.Close() - <-s.done // wait for goroutine to exit - } - return s.stateSyncErr // will be nil if [syncManager] is nil -} - -// Error returns a non-nil error if one occurred during the sync. -func (s *stateSyncerClient) Error() error { return s.stateSyncErr } - -func (s *stateSyncerClient) StateReady() bool { - select { - case <-s.done: - return true - default: - } - // If we have not yet invoked [AcceptedSyncableBlock] we should return - // false until it has been called or we invoke [ForceDone]. - if !s.init { - return false - } - // Cover the case where initialization failed - return s.syncManager == nil -} - -// UpdateSyncTarget returns a boolean indicating if the root was -// updated and an error if one occurred while updating the root. -func (s *stateSyncerClient) UpdateSyncTarget(b *chain.StatelessBlock) (bool, error) { - err := s.syncManager.UpdateSyncTarget(b.StateRoot) - if errors.Is(err, avasync.ErrAlreadyClosed) { - <-s.done // Wait for goroutine to exit for consistent return values with IsSyncing - return false, nil // Sync finished before update - } - if err != nil { - return false, err // Unexpected error - } - s.target = b // Remember the new target - s.targetUpdated = true // Set [targetUpdated] so we call SetLastAccepted on finish - return true, nil // Sync root target updated successfully -} diff --git a/vm/syncervm_server.go b/vm/syncervm_server.go deleted file mode 100644 index 98646403ff..0000000000 --- a/vm/syncervm_server.go +++ /dev/null @@ -1,52 +0,0 @@ -// Copyright (C) 2023, Ava Labs, Inc. All rights reserved. -// See the file LICENSE for licensing terms. - -package vm - -import ( - "context" - - "github.com/ava-labs/avalanchego/snow/choices" - "github.com/ava-labs/avalanchego/snow/engine/snowman/block" - "go.uber.org/zap" - - "github.com/AnomalyFi/hypersdk/chain" -) - -// GetLastStateSummary returns the latest state summary. -// If no summary is available, [database.ErrNotFound] must be returned. -func (vm *VM) GetLastStateSummary(context.Context) (block.StateSummary, error) { - summary := chain.NewSyncableBlock(vm.LastAcceptedBlock()) - vm.Logger().Info("Serving syncable block at latest height", zap.Stringer("summary", summary)) - return summary, nil -} - -// GetStateSummary implements StateSyncableVM and returns a summary corresponding -// to the provided [height] if the node can serve state sync data for that key. -// If not, [database.ErrNotFound] must be returned. -func (vm *VM) GetStateSummary(ctx context.Context, height uint64) (block.StateSummary, error) { - id, err := vm.GetBlockIDAtHeight(ctx, height) - if err != nil { - return nil, err - } - block, err := vm.GetStatelessBlock(ctx, id) - if err != nil { - return nil, err - } - summary := chain.NewSyncableBlock(block) - vm.Logger().Info("Serving syncable block at requested height", - zap.Uint64("height", height), - zap.Stringer("summary", summary), - ) - return summary, nil -} - -func (vm *VM) ParseStateSummary(ctx context.Context, bytes []byte) (block.StateSummary, error) { - sb, err := chain.ParseBlock(ctx, bytes, choices.Processing, vm) - if err != nil { - return nil, err - } - summary := chain.NewSyncableBlock(sb) - vm.Logger().Info("parsed state summary", zap.Stringer("summary", summary)) - return summary, nil -} diff --git a/vm/verify_context.go b/vm/verify_context.go deleted file mode 100644 index 8915771ac8..0000000000 --- a/vm/verify_context.go +++ /dev/null @@ -1,79 +0,0 @@ -// Copyright (C) 2023, Ava Labs, Inc. All rights reserved. -// See the file LICENSE for licensing terms. - -package vm - -import ( - "context" - "errors" - - "github.com/ava-labs/avalanchego/ids" - "github.com/ava-labs/avalanchego/utils/set" - - "github.com/AnomalyFi/hypersdk/chain" - "github.com/AnomalyFi/hypersdk/state" -) - -var ( - _ chain.VerifyContext = (*AcceptedVerifyContext)(nil) - _ chain.VerifyContext = (*PendingVerifyContext)(nil) -) - -func (vm *VM) GetVerifyContext(ctx context.Context, blockHeight uint64, parent ids.ID) (chain.VerifyContext, error) { - // If [blockHeight] is 0, we throw an error because there is no pre-genesis verification context. - if blockHeight == 0 { - return nil, errors.New("cannot get context of genesis block") - } - - // If the parent block is not yet accepted, we should return the block's processing parent (it may - // or may not be verified yet). - if blockHeight-1 > vm.lastAccepted.Hght { - blk, err := vm.GetStatelessBlock(ctx, parent) - if err != nil { - return nil, err - } - return &PendingVerifyContext{blk}, nil - } - - // If the last accepted block is not yet processed, we can't use the accepted state for the - // verification context. This could happen if state sync finishes with no processing blocks (we - // sync to the post-execution state of the parent of the last accepted block, not the post-execution - // state of the last accepted block). - // - // Invariant: When [View] is called on [vm.lastAccepted], the block will be verified and the accepted - // state will be updated. - if !vm.lastAccepted.Processed() && parent == vm.lastAccepted.ID() { - return &PendingVerifyContext{vm.lastAccepted}, nil - } - - // If the parent block is accepted and processed, we should - // just use the accepted state as the verification context. - return &AcceptedVerifyContext{vm}, nil -} - -type PendingVerifyContext struct { - blk *chain.StatelessBlock -} - -func (p *PendingVerifyContext) View(ctx context.Context, verify bool) (state.View, error) { - return p.blk.View(ctx, verify) -} - -func (p *PendingVerifyContext) IsRepeat(ctx context.Context, oldestAllowed int64, txs []*chain.Transaction, marker set.Bits, stop bool) (set.Bits, error) { - return p.blk.IsRepeat(ctx, oldestAllowed, txs, marker, stop) -} - -type AcceptedVerifyContext struct { - vm *VM -} - -// We disregard [verify] because [GetVerifyContext] ensures -// we will never need to verify a block if [AcceptedVerifyContext] is returned. -func (a *AcceptedVerifyContext) View(context.Context, bool) (state.View, error) { - return a.vm.State() -} - -func (a *AcceptedVerifyContext) IsRepeat(ctx context.Context, _ int64, txs []*chain.Transaction, marker set.Bits, stop bool) (set.Bits, error) { - bits := a.vm.IsRepeat(ctx, txs, marker, stop) - return bits, nil -} diff --git a/vm/vm.go b/vm/vm.go index c533fe32ce..8366ee140d 100644 --- a/vm/vm.go +++ b/vm/vm.go @@ -21,35 +21,44 @@ import ( "github.com/ava-labs/avalanchego/utils/crypto/bls" "github.com/ava-labs/avalanchego/utils/profiler" "github.com/ava-labs/avalanchego/utils/set" + "github.com/ava-labs/avalanchego/utils/wrappers" "github.com/ava-labs/avalanchego/version" - "github.com/ava-labs/avalanchego/x/merkledb" - "github.com/prometheus/client_golang/prometheus" + "github.com/dustin/go-humanize" "go.uber.org/zap" - "github.com/AnomalyFi/hypersdk/builder" "github.com/AnomalyFi/hypersdk/cache" "github.com/AnomalyFi/hypersdk/chain" "github.com/AnomalyFi/hypersdk/emap" - "github.com/AnomalyFi/hypersdk/fees" - "github.com/AnomalyFi/hypersdk/gossiper" + "github.com/AnomalyFi/hypersdk/filedb" "github.com/AnomalyFi/hypersdk/mempool" "github.com/AnomalyFi/hypersdk/network" "github.com/AnomalyFi/hypersdk/rpc" - "github.com/AnomalyFi/hypersdk/state" "github.com/AnomalyFi/hypersdk/trace" "github.com/AnomalyFi/hypersdk/utils" - "github.com/AnomalyFi/hypersdk/workers" + "github.com/AnomalyFi/hypersdk/vilmo" avametrics "github.com/ava-labs/avalanchego/api/metrics" avacache "github.com/ava-labs/avalanchego/cache" + smblock "github.com/ava-labs/avalanchego/snow/engine/snowman/block" avatrace "github.com/ava-labs/avalanchego/trace" avautils "github.com/ava-labs/avalanchego/utils" - avasync "github.com/ava-labs/avalanchego/x/sync" "github.com/ethereum/go-ethereum/ethclient" ethrpc "github.com/ethereum/go-ethereum/rpc" ) +type executedWrapper struct { + Block *chain.StatefulBlock + Chunk *chain.FilteredChunk + Results []*chain.Result + InvalidTxs []ids.ID +} + +type acceptedWrapper struct { + Block *chain.StatelessBlock + FilteredChunks []*chain.FilteredChunk +} + type VM struct { c Controller v *version.Semantic @@ -59,23 +68,37 @@ type VM struct { proposerMonitor *ProposerMonitor baseDB database.Database - config Config - genesis Genesis - builder builder.Builder - gossiper gossiper.Gossiper - rawStateDB database.Database - stateDB merkledb.MerkleDB + config Config + genesis Genesis + + // builder *builder.Builder + // gossiper *gossiper.Gossiper + vmDB database.Database + blobDB *filedb.FileDB + stateDB *vilmo.Vilmo handlers Handlers actionRegistry chain.ActionRegistry authRegistry chain.AuthRegistry authEngine map[uint8]AuthEngine - tracer avatrace.Tracer - mempool *mempool.Mempool[*chain.Transaction] + tracer avatrace.Tracer + + // Handle chunks + cm *ChunkManager + engine *chain.Engine + + // track all issuedTxs (to prevent wasting bandwidth) + // + // we use an emap here to avoid recursing through all previously + // issued chunks when packing a new chunk. + issuedTxs *emap.LEMap[*chain.Transaction] + rpcAuthorizedTxs *emap.LEMap[*chain.Transaction] // used to optimize performance of RPCs + mempool *mempool.Mempool[*chain.Transaction] // track all accepted but still valid txs (replay protection) - seen *emap.EMap[*chain.Transaction] + seenTxs *emap.LEMap[*chain.Transaction] + seenChunks *emap.LEMap[*chain.ChunkCertificate] startSeenTime int64 seenValidityWindowOnce sync.Once seenValidityWindow chan struct{} @@ -93,6 +116,10 @@ type VM struct { acceptedBlocksByID *cache.FIFO[ids.ID, *chain.StatelessBlock] acceptedBlocksByHeight *cache.FIFO[uint64, ids.ID] + // Executed chunk queue + executedQueue chan *executedWrapper + executorDone chan struct{} + // Accepted block queue acceptedQueue chan *chain.StatelessBlock acceptorDone chan struct{} @@ -100,21 +127,12 @@ type VM struct { // Transactions that streaming users are currently subscribed to webSocketServer *rpc.WebSocketServer - // authVerifiers are used to verify signatures in parallel - // with limited parallelism - authVerifiers workers.Workers - bootstrapped avautils.Atomic[bool] genesisBlk *chain.StatelessBlock preferred ids.ID lastAccepted *chain.StatelessBlock toEngine chan<- common.Message - // State Sync client and AppRequest handlers - stateSyncClient *stateSyncerClient - stateSyncNetworkClient avasync.NetworkClient - stateSyncNetworkServer *avasync.NetworkServer - // Network manager routes p2p messages to pre-registered handlers networkManager *network.Manager @@ -149,17 +167,21 @@ func (vm *VM) Initialize( ) error { vm.snowCtx = snowCtx vm.pkBytes = bls.PublicKeyToCompressedBytes(vm.snowCtx.PublicKey) + vm.issuedTxs = emap.NewLEMap[*chain.Transaction]() + vm.rpcAuthorizedTxs = emap.NewLEMap[*chain.Transaction]() // This will be overwritten when we accept the first block (in state sync) or // backfill existing blocks (during normal bootstrapping). vm.startSeenTime = -1 // Init seen for tracking transactions that have been accepted on-chain - vm.seen = emap.NewEMap[*chain.Transaction]() + vm.seenTxs = emap.NewLEMap[*chain.Transaction]() + vm.seenChunks = emap.NewLEMap[*chain.ChunkCertificate]() vm.seenValidityWindow = make(chan struct{}) vm.ready = make(chan struct{}) vm.stop = make(chan struct{}) - vm.L1Head = big.NewInt(0) + vm.L1Head = big.NewInt(0) // TODO: fix? vm.subCh = make(chan chain.ETHBlock) - gatherer := avametrics.NewMultiGatherer() + + gatherer := avametrics.NewPrefixGatherer() if err := vm.snowCtx.Metrics.Register("hypersdk", gatherer); err != nil { return err } @@ -171,14 +193,15 @@ func (vm *VM) Initialize( return err } vm.metrics = metrics + vm.proposerMonitor = NewProposerMonitor(vm) vm.networkManager = network.NewManager(vm.snowCtx.Log, vm.snowCtx.NodeID, appSender) vm.baseDB = baseDB // Always initialize implementation first - vm.config, vm.genesis, vm.builder, vm.gossiper, vm.vmDB, - vm.rawStateDB, vm.handlers, vm.actionRegistry, vm.authRegistry, vm.authEngine, err = vm.c.Initialize( + vm.config, vm.genesis, vm.vmDB, vm.blobDB, + vm.stateDB, vm.handlers, vm.actionRegistry, vm.authRegistry, vm.authEngine, err = vm.c.Initialize( vm, snowCtx, gatherer, @@ -190,6 +213,12 @@ func (vm *VM) Initialize( return fmt.Errorf("implementation initialization failed: %w", err) } + // Initialize chunk manager + chunkHandler, chunkSender := vm.networkManager.Register() + vm.cm = NewChunkManager(vm) + vm.networkManager.SetHandler(chunkHandler, vm.cm) + go vm.cm.Run(chunkSender) + // Setup tracer vm.tracer, err = trace.New(vm.config.GetTraceConfig()) if err != nil { @@ -204,35 +233,6 @@ func (vm *VM) Initialize( go vm.profiler.Dispatch() //nolint:errcheck } - // Instantiate DBs - merkleRegistry := prometheus.NewRegistry() - vm.stateDB, err = merkledb.New(ctx, vm.rawStateDB, merkledb.Config{ - BranchFactor: vm.genesis.GetStateBranchFactor(), - // RootGenConcurrency limits the number of goroutines - // that will be used across all concurrent root generations. - RootGenConcurrency: uint(vm.config.GetRootGenerationCores()), - HistoryLength: uint(vm.config.GetStateHistoryLength()), - ValueNodeCacheSize: uint(vm.config.GetValueNodeCacheSize()), - IntermediateNodeCacheSize: uint(vm.config.GetIntermediateNodeCacheSize()), - IntermediateWriteBufferSize: uint(vm.config.GetStateIntermediateWriteBufferSize()), - IntermediateWriteBatchSize: uint(vm.config.GetStateIntermediateWriteBatchSize()), - Reg: merkleRegistry, - TraceLevel: merkledb.InfoTrace, - Tracer: vm.tracer, - }) - if err != nil { - return err - } - if err := gatherer.Register("state", merkleRegistry); err != nil { - return err - } - - // Setup worker cluster for verifying signatures - // - // If [parallelism] is odd, we assign the extra - // core to signature verification. - vm.authVerifiers = workers.NewParallel(vm.config.GetAuthVerificationCores(), 100) // TODO: make job backlog a const - // Init channels before initializing other structs vm.toEngine = toEngine @@ -289,20 +289,29 @@ func (vm *VM) Initialize( snowCtx.Log.Info("initialized vm from last accepted", zap.Stringer("block", blk.ID())) } else { // Set balances and compute genesis root - sps := state.NewSimpleMutable(vm.stateDB) - if err := vm.genesis.Load(ctx, vm.tracer, sps); err != nil { + batch, err := vm.stateDB.NewBatch() + if err != nil { + return err + } + batch.Prepare() + // Load genesis allocations + if err := vm.genesis.Load(ctx, vm.tracer, batch); err != nil { snowCtx.Log.Error("could not set genesis allocation", zap.Error(err)) return err } - if err := sps.Commit(ctx); err != nil { + + if err := batch.Insert(ctx, chain.HeightKey(vm.StateManager().HeightKey()), binary.BigEndian.AppendUint64(nil, 0)); err != nil { + return err + } + if err := batch.Insert(ctx, chain.TimestampKey(vm.StateManager().TimestampKey()), binary.BigEndian.AppendUint64(nil, 0)); err != nil { return err } - root, err := vm.stateDB.GetMerkleRoot(ctx) + + // Commit genesis block post-execution state and compute root + checksum, err := batch.Write() if err != nil { - snowCtx.Log.Error("could not get merkle root", zap.Error(err)) return err } - snowCtx.Log.Info("genesis state created", zap.Stringer("root", root)) // Attach L1 Head to genesis ethRpcUrl := vm.config.GetETHL1RPC() @@ -318,7 +327,7 @@ func (vm *VM) Initialize( return err } - blk := chain.NewGenesisBlock(root) + blk := chain.NewGenesisBlock(checksum) blk.L1Head = ethBlockHeader.Number.Int64() // Create genesis block @@ -333,35 +342,25 @@ func (vm *VM) Initialize( snowCtx.Log.Error("unable to init genesis block", zap.Error(err)) return err } - - // Update chain metadata - sps = state.NewSimpleMutable(vm.stateDB) - if err := sps.Insert(ctx, chain.HeightKey(vm.StateManager().HeightKey()), binary.BigEndian.AppendUint64(nil, 0)); err != nil { - return err - } - if err := sps.Insert(ctx, chain.TimestampKey(vm.StateManager().TimestampKey()), binary.BigEndian.AppendUint64(nil, 0)); err != nil { - return err - } - genesisRules := vm.c.Rules(0) - feeManager := fees.NewManager(nil) - minUnitPrice := genesisRules.GetMinUnitPrice() - for i := fees.Dimension(0); i < fees.FeeDimensions; i++ { - feeManager.SetUnitPrice(i, minUnitPrice[i]) - snowCtx.Log.Info("set genesis unit price", zap.Int("dimension", int(i)), zap.Uint64("price", feeManager.UnitPrice(i))) - } - if err := sps.Insert(ctx, chain.FeeKey(vm.StateManager().FeeKey()), feeManager.Bytes()); err != nil { - return err - } - - // Commit genesis block post-execution state and compute root - if err := sps.Commit(ctx); err != nil { - return err - } - genesisRoot, err := vm.stateDB.GetMerkleRoot(ctx) - if err != nil { - snowCtx.Log.Error("could not get merkle root", zap.Error(err)) - return err - } + // TODO: fees + // // Update chain metadata + // sps = state.NewSimpleMutable(vm.stateDB) + + // genesisRules := vm.c.Rules(0) + // feeManager := fees.NewManager(nil) + // minUnitPrice := genesisRules.GetMinUnitPrice() + // for i := fees.Dimension(0); i < fees.FeeDimensions; i++ { + // feeManager.SetUnitPrice(i, minUnitPrice[i]) + // snowCtx.Log.Info("set genesis unit price", zap.Int("dimension", int(i)), zap.Uint64("price", feeManager.UnitPrice(i))) + // } + // if err := sps.Insert(ctx, chain.FeeKey(vm.StateManager().FeeKey()), feeManager.Bytes()); err != nil { + // return err + // } + + // // Commit genesis block post-execution state and compute root + // if err := sps.Commit(ctx); err != nil { + // return err + // } // Update last accepted and preferred block vm.genesisBlk = genesisBlk @@ -373,41 +372,17 @@ func (vm *VM) Initialize( vm.preferred, vm.lastAccepted = gBlkID, genesisBlk snowCtx.Log.Info("initialized vm from genesis", zap.Stringer("block", gBlkID), - zap.Stringer("pre-execution root", genesisBlk.StateRoot), - zap.Stringer("post-execution root", genesisRoot), + zap.Stringer("checksum", checksum), ) } - go vm.processAcceptedBlocks() + // TODO: StateSync - // Setup state syncing - stateSyncHandler, stateSyncSender := vm.networkManager.Register() - syncRegistry := prometheus.NewRegistry() - vm.stateSyncNetworkClient, err = avasync.NewNetworkClient( - stateSyncSender, - vm.snowCtx.NodeID, - int64(vm.config.GetStateSyncParallelism()), - vm.Logger(), - "", - syncRegistry, - nil, // TODO: populate minimum version - ) - if err != nil { - return err - } - if err := gatherer.Register("sync", syncRegistry); err != nil { - return err - } - vm.stateSyncClient = vm.NewStateSyncClient(gatherer) - vm.stateSyncNetworkServer = avasync.NewNetworkServer(stateSyncSender, vm.stateDB, vm.Logger()) - vm.networkManager.SetHandler(stateSyncHandler, NewStateSyncHandler(vm)) - - // Setup gossip networking - gossipHandler, gossipSender := vm.networkManager.Register() - vm.networkManager.SetHandler(gossipHandler, NewTxGossipHandler(vm)) + go vm.processExecutedChunks() + go vm.processAcceptedBlocks() - // Startup block builder and gossiper - go vm.builder.Run() - go vm.gossiper.Run(gossipSender) + // setup chain engine + vm.engine = chain.NewEngine(vm, 128) + go vm.engine.Run() go vm.ETHL1HeadSubscribe() @@ -432,43 +407,20 @@ func (vm *VM) Initialize( return nil } -func (vm *VM) checkActivity(ctx context.Context) { - vm.gossiper.Queue(ctx) - vm.builder.Queue(ctx) -} - +// TODO: state sync func (vm *VM) markReady() { // Wait for state syncing to complete select { - case <-vm.stop: - return - case <-vm.stateSyncClient.done: - } - - // We can begin partailly verifying blocks here because - // we have the full state but can't detect duplicate transactions - // because we haven't yet observed a full [ValidityWindow]. - vm.snowCtx.Log.Info("state sync client ready") - - // Wait for a full [ValidityWindow] before - // we are willing to vote on blocks. - select { case <-vm.stop: return case <-vm.seenValidityWindow: } + vm.snowCtx.Log.Info("validity window ready") - if vm.stateSyncClient.Started() { - vm.toEngine <- common.StateSyncDone - } close(vm.ready) // Mark node ready and attempt to build a block. - vm.snowCtx.Log.Info( - "node is now ready", - zap.Bool("synced", vm.stateSyncClient.Started()), - ) - vm.checkActivity(context.TODO()) + vm.snowCtx.Log.Info("node is now ready") } func (vm *VM) isReady() bool { @@ -481,63 +433,61 @@ func (vm *VM) isReady() bool { } } +func (vm *VM) trackChainDataSize() { + t := time.NewTicker(30 * time.Second) + defer t.Stop() + for { + select { + case <-t.C: + start := time.Now() + size := utils.DirectorySize(vm.snowCtx.ChainDataDir) + vm.metrics.chainDataSize.Set(float64(size)) + vm.snowCtx.Log.Info("chainData size", zap.String("size", humanize.Bytes(size)), zap.Duration("t", time.Since(start))) + + keys, aliveBytes, uselessBytes := vm.stateDB.Usage() + vm.metrics.appendDBKeys.Set(float64(keys)) + vm.metrics.appendDBAliveBytes.Set(float64(aliveBytes)) + vm.metrics.appendDBUselessBytes.Set(float64(uselessBytes)) + vm.snowCtx.Log.Info( + "stateDB size", + zap.Int("len", keys), + zap.String("alive", humanize.Bytes(uint64(aliveBytes))), + zap.String("useless", humanize.Bytes(uint64(uselessBytes))), + ) + case <-vm.stop: + return + } + } +} + // TODO: remove? func (vm *VM) BaseDB() database.Database { return vm.baseDB } -func (vm *VM) ReadState(ctx context.Context, keys [][]byte) ([][]byte, []error) { +// ReadState reads the latest executed state +func (vm *VM) ReadState(ctx context.Context, keys []string) ([][]byte, []error) { if !vm.isReady() { return utils.Repeat[[]byte](nil, len(keys)), utils.Repeat(ErrNotReady, len(keys)) } - // Atomic read to ensure consistency - return vm.stateDB.GetValues(ctx, keys) + + return vm.engine.ReadLatestState(ctx, keys) } func (vm *VM) SetState(_ context.Context, state snow.State) error { switch state { - case snow.StateSyncing: - vm.Logger().Info("state sync started") - return nil case snow.Bootstrapping: - // Ensure state sync client marks itself as done if it was never started - syncStarted := vm.stateSyncClient.Started() - if !syncStarted { - // We must check if we finished syncing before starting bootstrapping. - // This should only ever occur if we began a state sync, restarted, and - // were unable to find any acceptable summaries. - syncing, err := vm.GetDiskIsSyncing() - if err != nil { - vm.Logger().Error("could not determine if syncing", zap.Error(err)) - return err - } - if syncing { - vm.Logger().Error("cannot start bootstrapping", zap.Error(ErrStateSyncing)) - // This is a fatal error that will require retrying sync or deleting the - // node database. - return ErrStateSyncing - } - - // If we weren't previously syncing, we force state syncer completion so - // that the node will mark itself as ready. - vm.stateSyncClient.ForceDone() - - // TODO: add a config to FATAL here if could not state sync (likely won't be - // able to recover in networks where no one has the full state, bypass - // still starts sync): https://github.com/AnomalyFi/hypersdk/issues/438 - } - // Backfill seen transactions, if any. This will exit as soon as we reach // a block we no longer have on disk or if we have walked back the full // [ValidityWindow]. vm.backfillSeenTransactions() // Trigger that bootstrapping has started - vm.Logger().Info("bootstrapping started", zap.Bool("state sync started", syncStarted)) + vm.Logger().Info("bootstrapping started") return vm.onBootstrapStarted() case snow.NormalOp: vm.Logger(). - Info("normal operation started", zap.Bool("state sync started", vm.stateSyncClient.Started())) + Info("normal operation started") return vm.onNormalOperationsStarted() default: return snow.ErrUnknownState @@ -553,7 +503,6 @@ func (vm *VM) onBootstrapStarted() error { // ForceReady is used in integration testing func (vm *VM) ForceReady() { // Only works if haven't already started syncing - vm.stateSyncClient.ForceDone() vm.seenValidityWindowOnce.Do(func() { close(vm.seenValidityWindow) }) @@ -561,8 +510,6 @@ func (vm *VM) ForceReady() { // onNormalOperationsStarted marks this VM as bootstrapped func (vm *VM) onNormalOperationsStarted() error { - defer vm.checkActivity(context.TODO()) - if vm.bootstrapped.Get() { return nil } @@ -574,19 +521,18 @@ func (vm *VM) onNormalOperationsStarted() error { func (vm *VM) Shutdown(ctx context.Context) error { close(vm.stop) - // Shutdown state sync client if still running - if err := vm.stateSyncClient.Shutdown(); err != nil { - return err - } + // shutdown engine + vm.engine.Done() + + // process remaining executed chunks queued before shutdown + close(vm.executedQueue) + <-vm.executorDone // Process remaining accepted blocks before shutdown close(vm.acceptedQueue) <-vm.acceptorDone - // Shutdown other async VM mechanisms - vm.builder.Done() - vm.gossiper.Done() - vm.authVerifiers.Stop() + vm.cm.Done() if vm.profiler != nil { vm.profiler.Shutdown() } @@ -601,13 +547,13 @@ func (vm *VM) Shutdown(ctx context.Context) error { if vm.snowCtx == nil { return nil } - if err := vm.vmDB.Close(); err != nil { - return err - } - if err := vm.stateDB.Close(); err != nil { - return err - } - return vm.rawStateDB.Close() + errs := wrappers.Errs{} + errs.Add( + vm.vmDB.Close(), + vm.blobDB.Close(), + vm.stateDB.Close(), + ) + return errs.Err } // implements "block.ChainVM.common.VM" @@ -732,12 +678,22 @@ func (vm *VM) ParseBlock(ctx context.Context, source []byte) (snowman.Block, err // implements "block.ChainVM" func (vm *VM) BuildBlock(ctx context.Context) (snowman.Block, error) { + vm.snowCtx.Log.Warn("building block without context") + return vm.buildBlock(ctx, 0) +} + +// implements "block.BuildBlockWithContextChainVM" +func (vm *VM) BuildBlockWithContext(ctx context.Context, blockContext *smblock.Context) (snowman.Block, error) { + return vm.buildBlock(ctx, blockContext.PChainHeight) +} + +func (vm *VM) buildBlock(ctx context.Context, pChainHeight uint64) (snowman.Block, error) { start := time.Now() defer func() { vm.metrics.blockBuild.Observe(float64(time.Since(start))) }() - ctx, span := vm.tracer.Start(ctx, "VM.BuildBlock") + ctx, span := vm.tracer.Start(ctx, "VM.buildBlock") defer span.End() // If the node isn't ready, we should exit. @@ -749,16 +705,11 @@ func (vm *VM) BuildBlock(ctx context.Context) (snowman.Block, error) { return nil, ErrNotReady } - // Notify builder if we should build again (whether or not we are successful this time) - // - // Note: builder should regulate whether or not it actually decides to build based on state - // of the mempool. - defer vm.checkActivity(ctx) - vm.verifiedL.RLock() processingBlocks := len(vm.verifiedBlocks) vm.verifiedL.RUnlock() if processingBlocks > vm.config.GetProcessingBuildSkip() { + // We specify this separately because we want a lower amount than other VMs vm.snowCtx.Log.Warn("not building block", zap.Error(ErrTooManyProcessing)) return nil, ErrTooManyProcessing } @@ -766,10 +717,10 @@ func (vm *VM) BuildBlock(ctx context.Context) (snowman.Block, error) { // Build block and store as parsed preferredBlk, err := vm.GetStatelessBlock(ctx, vm.preferred) if err != nil { - vm.snowCtx.Log.Warn("unable to get preferred block", zap.Error(err)) + vm.snowCtx.Log.Warn("unable to get preferred block", zap.Stringer("preferred", vm.preferred), zap.Error(err)) return nil, err } - blk, err := chain.BuildBlock(ctx, vm, preferredBlk) + blk, err := chain.BuildBlock(ctx, vm, pChainHeight, preferredBlk) if err != nil { // This is a DEBUG log because BuildBlock may fail before // the min build gap (especially when there are no transactions). @@ -780,6 +731,8 @@ func (vm *VM) BuildBlock(ctx context.Context) (snowman.Block, error) { return blk, nil } +// @todo where should we handle tx gossip, for txs not belonging to the partition? +// current way it handles is in chunkmanager, but it is nice to have a seperate gossiper. func (vm *VM) Submit( ctx context.Context, verifyAuth bool, @@ -796,36 +749,17 @@ func (vm *VM) Submit( return []error{ErrNotReady} } - // Create temporary execution context - blk, err := vm.GetStatelessBlock(ctx, vm.preferred) - if err != nil { - return []error{err} - } - view, err := blk.View(ctx, false) - if err != nil { - // This will error if a block does not yet have processed state. - return []error{err} - } - feeRaw, err := view.GetValue(ctx, chain.FeeKey(vm.StateManager().FeeKey())) - if err != nil { - return []error{err} - } - feeManager := fees.NewManager(feeRaw) - now := time.Now().UnixMilli() - r := vm.c.Rules(now) - nextFeeManager, err := feeManager.ComputeNext(blk.Tmstmp, now, r) - if err != nil { - return []error{err} - } + // TODO: check that tx is in our partition - // Find repeats - oldestAllowed := now - r.GetValidityWindow() - repeats, err := blk.IsRepeat(ctx, oldestAllowed, txs, set.NewBits(), true) - if err != nil { - return []error{err} - } + // Check for duplicates + // + // We don't need to check all seen because we are the exclusive issuer of txs + // for our partition. + repeats := vm.issuedTxs.Contains(txs, set.NewBits(), false) - validTxs := []*chain.Transaction{} + // Perform basic validity checks before storing in mempool + now := time.Now().UnixMilli() + r := vm.Rules(now) for i, tx := range txs { // Check if transaction is a repeat before doing any extra work if repeats.Contains(i) { @@ -842,6 +776,12 @@ func (vm *VM) Submit( continue } + // Perform syntactic verification + if _, err := tx.SyntacticVerify(ctx, vm.StateManager(), r, now); err != nil { + errs = append(errs, err) + continue + } + // Ensure state keys are valid _, err := tx.StateKeys(vm.c.StateManager()) if err != nil { @@ -869,25 +809,23 @@ func (vm *VM) Submit( } } - // PreExecute does not make any changes to state - // - // This may fail if the state we are utilizing is invalidated (if a trie - // view from a different branch is committed underneath it). We prefer this - // instead of putting a lock around all commits. + // TODO: Check that bond is valid // - // Note, [PreExecute] ensures that the pending transaction does not have - // an expiry time further ahead than [ValidityWindow]. This ensures anything - // added to the [Mempool] is immediately executable. - if err := tx.PreExecute(ctx, nextFeeManager, vm.c.StateManager(), r, view, now); err != nil { - errs = append(errs, err) - continue - } + // Outstanding tx limit is maintained in chunk builder + // TODO: add immutable access + // ok, err := vm.StateManager().CanProcess(ctx, tx.Auth.Sponsor(), hutils.Epoch(tx.Base.Timestamp, r.GetEpochDuration()), nil) + // if err != nil { + // errs = append(errs, err) + // continue + // } + // if !ok { + // errs = append(errs, errors.New("sponsor has no valid bond")) + // continue + // } + errs = append(errs, nil) - validTxs = append(validTxs, tx) + vm.cm.HandleTx(ctx, tx) } - vm.mempool.Add(ctx, validTxs) - vm.checkActivity(ctx) - vm.metrics.mempoolSize.Set(float64(vm.mempool.Len(ctx))) return errs } @@ -1039,7 +977,7 @@ func (vm *VM) backfillSeenTransactions() { // Exit early if we don't have any blocks other than genesis (which // contains no transactions) blk := vm.lastAccepted - if blk.Hght == 0 { + if blk.Height() == 0 { vm.snowCtx.Log.Info("no seen transactions to backfill") vm.startSeenTime = 0 vm.seenValidityWindowOnce.Do(func() { @@ -1049,10 +987,11 @@ func (vm *VM) backfillSeenTransactions() { } // Backfill [vm.seen] with lifeline worth of transactions - r := vm.Rules(vm.lastAccepted.Tmstmp) + t := vm.lastAccepted.StatefulBlock.Timestamp + r := vm.Rules(t) oldest := uint64(0) for { - if vm.lastAccepted.Tmstmp-blk.Tmstmp > r.GetValidityWindow() { + if t-blk.StatefulBlock.Timestamp > r.GetValidityWindow() { // We are assured this function won't be running while we accept // a block, so we don't need to protect against closing this channel // twice. @@ -1062,14 +1001,22 @@ func (vm *VM) backfillSeenTransactions() { break } + // Iterate through all filtered chunks in accepted blocks + // // It is ok to add transactions from newest to oldest - vm.seen.Add(blk.Txs) - vm.startSeenTime = blk.Tmstmp - oldest = blk.Hght + for _, filteredChunk := range blk.ExecutedChunks { + chunk, err := vm.GetFilteredChunk(filteredChunk) + if err != nil { + panic(err) + } + vm.seenTxs.Add(chunk.Txs) + } + vm.startSeenTime = blk.StatefulBlock.Timestamp + oldest = blk.Height() // Exit early if next block to fetch is genesis (which contains no // txs) - if blk.Hght <= 1 { + if blk.Height() <= 1 { // If we have walked back from the last accepted block to genesis, then // we can be sure we have all required transactions to start validation. vm.startSeenTime = 0 @@ -1080,11 +1027,11 @@ func (vm *VM) backfillSeenTransactions() { } // Set next blk in lookback - tblk, err := vm.GetStatelessBlock(context.Background(), blk.Prnt) + tblk, err := vm.GetStatelessBlock(context.Background(), blk.Parent()) if err != nil { vm.snowCtx.Log.Info("could not load block, exiting backfill", zap.Uint64("height", blk.Height()-1), - zap.Stringer("blockID", blk.Prnt), + zap.Stringer("blockID", blk.Parent()), zap.Error(err), ) return @@ -1094,7 +1041,7 @@ func (vm *VM) backfillSeenTransactions() { vm.snowCtx.Log.Info( "backfilled seen txs", zap.Uint64("start", oldest), - zap.Uint64("finish", vm.lastAccepted.Hght), + zap.Uint64("finish", vm.lastAccepted.Height()), ) }