Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

go/roothash: Batch history reindex writes #6050

Open
wants to merge 11 commits into
base: master
Choose a base branch
from

Conversation

martintomazic
Copy link
Contributor

@martintomazic martintomazic commented Feb 11, 2025

Closes #6069

What

During history reindex, we now batch 1000 writes at the same time.

Why

As from the benchmarks, this already gives 2-3x performance gain for the single threaded program.

Given its simplicity and the fact, that it will be required by all possible solutions, this is a natural start.

Copy link

netlify bot commented Feb 11, 2025

Deploy Preview for oasisprotocol-oasis-core canceled.

Name Link
🔨 Latest commit c4808e9
🔍 Latest deploy log https://app.netlify.com/sites/oasisprotocol-oasis-core/deploys/67b5e96b1f014300082d8bd8

@martintomazic martintomazic force-pushed the martin/feature/batch-history-reindex-writes branch from 75602c3 to aff6166 Compare February 11, 2025 10:13
//
// Watcher will not be notified about new blocks since this method is only
// meant to be used during reindex.
CommitBatch(blks []*AnnotatedBlock, roundResults []*RoundResults) error
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Do we want to group *AnnotatedBlock and *RoundResults in dedicated struct? Also use it during Commit and in other places. No strong opinion.

@martintomazic martintomazic marked this pull request as ready for review February 11, 2025 10:23
Copy link

codecov bot commented Feb 11, 2025

Codecov Report

Attention: Patch coverage is 44.36090% with 74 lines in your changes missing coverage. Please review.

Project coverage is 64.89%. Comparing base (14f4641) to head (aff6166).
Report is 12 commits behind head on master.

Files with missing lines Patch % Lines
go/consensus/cometbft/roothash/roothash.go 56.84% 34 Missing and 7 partials ⚠️
go/runtime/history/db.go 8.82% 30 Missing and 1 partial ⚠️
go/runtime/history/history.go 50.00% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #6050      +/-   ##
==========================================
- Coverage   65.15%   64.89%   -0.27%     
==========================================
  Files         635      635              
  Lines       64634    64706      +72     
==========================================
- Hits        42113    41989     -124     
- Misses      17597    17815     +218     
+ Partials     4924     4902      -22     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

@martintomazic martintomazic marked this pull request as draft February 11, 2025 11:01
Copy link
Contributor

@peternose peternose left a comment

Choose a reason for hiding this comment

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

I thought that in the end we will have N workers, everyone with a range of heights to process, so that they can e.g read consensus blocks, process blocks, and encode data in parallel . Of course, they can also write to history in batches, if the history supports. Furthermore, they should fetch block results once and not multiple times if we have more runtimes. Will this solution be able to upgrade to this?

@@ -0,0 +1,4 @@
go/roothahs: Optimize runtime history reindex
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
go/roothahs: Optimize runtime history reindex
go/roothash: Optimize runtime history reindex

Also, the following git messages are not in correct format:

  • go/consensus/cometbft/roothash: return 0 value on error
  • go/roothash: Batch writting runtime blocks during reindex

@@ -0,0 +1,4 @@
go/roothahs: Optimize runtime history reindex

During runtime history reindex, we batch 1000 writes at the same time,
Copy link
Contributor

Choose a reason for hiding this comment

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

I would be more general and remove values 1000 and 2x from the description. I would only say that blocks and results are written in batches.

) (uint64, error) {
sc.logger.Debug("reindexing batch",
"runtime_id", runtimeID,
"batch_start", start,
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
"batch_start", start,
"start", start,

var results *cmtrpctypes.ResultBlockResults
results, err = sc.backend.GetBlockResults(sc.ctx, height)
results, err := sc.backend.GetBlockResults(sc.ctx, height)
Copy link
Contributor

Choose a reason for hiding this comment

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

Which context is the right one, ctx or sc.ctx?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

My understanding is they are the same. I have improved this in 14e21df to always pass context explicitly. Technically WatchBlocks and WatchEvents methods still use sc.Ctx and simply ignore the context they receive (so kinda misleading for the caller :/).

This is existing code so would prefer to tackle this problem elsewhere unless trivial. Any suggestions what to do in such case? Probably we want to combine two contexts and propagate correct error, or stop passing it if find it unnecessarily.

Copy link
Contributor

Choose a reason for hiding this comment

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

Any suggestions what to do in such case?

Never store context in a struct, and if possible, when making changes to the existing code fix functions so that context is the first argument.

@@ -25,6 +25,13 @@ type BlockHistory interface {
// Must be called in order, sorted by round.
Commit(blk *AnnotatedBlock, roundResults *RoundResults, notify bool) error

// CommitBatch commits annotated blocks and corresponding round results,
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
// CommitBatch commits annotated blocks and corresponding round results,
// CommitBatch commits annotated blocks and corresponding round results

// into runtime history.
//
// Watcher will not be notified about new blocks since this method is only
// meant to be used during reindex.
Copy link
Contributor

Choose a reason for hiding this comment

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

This method is public, so I can use it wherever I want, not only during reindex. Fix the comment.

@@ -214,6 +214,59 @@ func (d *DB) commit(blk *roothash.AnnotatedBlock, roundResults *roothash.RoundRe
})
}

func (d *DB) commitBatch(blks []*roothash.AnnotatedBlock, roundResults []*roothash.RoundResults) error {
if len(blks) != len(roundResults) {
return fmt.Errorf("runtime/history: got %d annotated blocks and %d roundResults, but should be equal", len(blks), len(roundResults))
Copy link
Contributor

Choose a reason for hiding this comment

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

Too long.

//
// Watcher will not be notified about new blocks since this method is only
// meant to be used during reindex.
CommitBatch(blks []*AnnotatedBlock, roundResults []*RoundResults) error
Copy link
Contributor

Choose a reason for hiding this comment

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

Are we planning to call CommitBatch also in parallel? Will this work? I think that currently we cannot call Commit in parallel, or can we?
Must blocks be in order, sorted by round?
Must this be called in order, sorted by round?
What happens if I call Commit, CommitBatch, Commit, ... Will things still work?
Why don't we notify if needed?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

You can call it in parallel see benchmarks. Notice that parallelization of consensus blocks without batching (see benchmarks), only improved performance by 2x. This is the performance gain we get with batching only (still running things sequentially). It is for this reason that I would prefer to get in batching as a separate/independent feature.

Must blocks be in order, sorted by round?
Must this be called in order, sorted by round?

I should elaborate these requirements in the docstring. Thanks for pointing.

What happens if I call Commit, CommitBatch, Commit, ... Will things still work?

It should work, I am writting a test for this rn :).

Why don't we notify if needed?

Because this will be only used during reindex and we don't notify during it. If someone will later need this functionality I suggest it is added at that point (should be trivial). Agree that comment should probably changed to something simpler/with less assumptions:

// This method does not notify watchers.

Copy link
Contributor

Choose a reason for hiding this comment

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

You can call it in parallel

No you can't, because the meta will be incorrect.

It should work, I am writting a test for this rn :).

I would say not, because Commit has extra code, which CommitBatch doesn't.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

No you can't, because the meta will be incorrect.

You are right. I think we should extend the batching to support that when/if we start calling it in parallel.

I would say not, because Commit has extra code, which CommitBatch doesn't.

You are right, we are forgetting to notify pruner. Will fix this. As for the notify I still think we don't have to expose this functionality. See existing Commit docstring/promise:

	// Commit commits an annotated block into history. If notify is set to true,
	// the watchers will be notified about the new block. Disable notify when
	// doing reindexing.

Copy link
Contributor Author

@martintomazic martintomazic Feb 11, 2025

Choose a reason for hiding this comment

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

I think we should extend the batching to support that when/if we start calling it in parallel.

As desribed in Possible_solutions/blockers/2, this should be fixed, by refactoring block history to operate in two modes; reindexing and reindexed. During reindexing, metadata will be ingnored, and we won't notify watchers expect for the latest round of reindex (as we do rn).

Technically, you can already safely call Commit and CommitBatch in parallel, however you will receive an error (which you should handle) in most cases, due to metadata mismatch.

Alternative, as discussed on the slack is to try to parallelize reading batch part (update) only (as suggested by @ptrus), and skip this complex refactor altogether, provided we will be happy with performance.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I would say not, because Commit has extra code, which CommitBatch doesn't.

I have fixed this following your suggestion in private: where Commit becomes a helper for CommitBatch of single entry. I have added separate commit for that.

Prior to that I have also extended TestWatchBlocks corner cases to capture existing behaviour: see commit.

Docstrings have also been reworked for both Commit and CommitBatch.

Finally, as discussed in private we will only fetch consensus state in parallel. The writing part will be written sequentially (in batches).

Copy link
Contributor

Choose a reason for hiding this comment

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

I would say not, because Commit has extra code, which CommitBatch doesn't.

I have fixed this following your suggestion in private: where Commit becomes a helper for CommitBatch of single entry. I have added separate commit for that.

Prior to that I have also extended TestWatchBlocks corner cases to capture existing behaviour: see commit.

Docstrings have also been reworked for both Commit and CommitBatch.

You don't need to describe these changes in detail. I will see that in the code. Just extra work for you and for reviewers.

Finally, as discussed in private we will only fetch consensus state in parallel. The writing part will be written sequentially (in batches).

This comment is fine, to let other reviewers know what we discussed.

return nil
}

return d.db.Update(func(tx *badger.Txn) error {
Copy link
Contributor

Choose a reason for hiding this comment

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

Transactions should always be fast and small. Therefore, you should improve this by moving outside of the tx data encoding, data verification, and calculation of max/min.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Fixed here eae4b93, please let me know if you see simpler way of doing it?

Not sure it would have much impact though since badger uses (serializable) snapshot isolation, so reads are actually never blocked by writes (?). We will not support writing in parallel, due to metadata mismatch (and writing part not being a bottleneck).

We should probably still pre-process (pre-validate) it to follow best practices and not to make assumptions how code will be used in the future?

sc.ctx is the same as ctx received by DeliverEvent or
DeliverCommand, that triggers reindex or normal event
processing.

See go/consensus/cometbft/full package (full.go and
services.go file) to confirm.
This method was only used for testing, and is redundant since
Commit correctly updates LastConsensusHeight.
Additional defensive check, that prevents committing blocks
at the same height is added. This ensures we receive at most
one runtime block per consensus height. We want to use
same defensive check withing a batch (when we implement it).

This should be safe since the only method that modifies last
consensus height is Commit, which is only called from
processFinalizedEvent (reindex or normal event
processing).
reindexBlocks is only called from processFinalizedEvents.
In case of an error, lastRound is ignored. To avoid confusion,
we always return 0 value in case of an error.
@martintomazic martintomazic force-pushed the martin/feature/batch-history-reindex-writes branch from aff6166 to 306869a Compare February 18, 2025 23:49
We add two more scenarios:
1. Local storage with notify set to false.
2. No local storage with notify set to false.
Commit is now only a special case of CommitBatch.
@martintomazic martintomazic force-pushed the martin/feature/batch-history-reindex-writes branch from 306869a to 783354e Compare February 19, 2025 10:25
Comment on lines +492 to +501
results, err := sc.backend.GetBlockResults(ctx, height)
if err != nil {
// XXX: could soft-fail first few heights in case more heights were
// pruned right after the GetLastRetainedVersion query.
logger.Error("failed to get cometbft block results",
sc.logger.Error("failed to get cometbft block results",
"err", err,
"height", height,
)
return lastRound, fmt.Errorf("failed to get cometbft block results: %w", err)
return 0, fmt.Errorf("failed to get cometbft block results: %w", err)
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I was thinking, if we have prunning set to only retain few blocks (possibly one), or the time interval is very small, could batching always receive an error? Let's say on my machine to process a batch it takes 200-300ms. Would it be better to defensively check what is the last retained round again if we receive an error, and simply update the latest height if it has changed?

cc @ptrus

Copy link
Member

Choose a reason for hiding this comment

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

I think the minimum interval is 1 second currently, and in practice it's likely much more (e.g. in minutes).

Likely not problematic in practice, so I don't really have strong opinions on how to handle it.

@@ -387,7 +390,7 @@ func (sc *serviceClient) getRuntimeNotifiers(id common.Namespace) *runtimeBroker
return notifiers
}

func (sc *serviceClient) reindexBlocks(currentHeight int64, bh api.BlockHistory) (uint64, error) {
func (sc *serviceClient) reindexBlocks(ctx context.Context, currentHeight int64, bh api.BlockHistory) (uint64, error) {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

As discussed with @ptrus it might be a good idea to harden existing history reindex e2e test, by possibly adding a query for every round that we expect it should be reindexed.

Finally also in long term e2e tests we are also querying old random blocks && restarting nodes in between possibly triggering reindex?

Copy link
Member

Choose a reason for hiding this comment

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

Maybe create a follow-up issue for this. I think the simplest way would be running a TestClientScenario which queries (runs GetKeyValueTx) queries all the old blocks, at the end of the existing history reindex e2e test.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Agree, #6070 created to track this.

@martintomazic martintomazic marked this pull request as ready for review February 19, 2025 12:24
const crashPointBlockBeforeIndex = "roothash.before_index"
const (
crashPointBlockBeforeIndex = "roothash.before_index"
batchSize = 1000
Copy link
Member

Choose a reason for hiding this comment

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

Maybe

Suggested change
batchSize = 1000
reindexWriteBatchSize = 1000

to make it a bit more descriptive.

if err != nil {
return 0, fmt.Errorf("failed to commit batch to history keeper: %w", err)
}
if last != api.RoundInvalid { // non-empty batch
Copy link
Member

Choose a reason for hiding this comment

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

I believe reindex returns api.RoundInvalid and no error, only when there was no runtime rounds finalized in the contained batch of heights, lets update the comment to better reflect this:
Something like

Suggested change
if last != api.RoundInvalid { // non-empty batch
if last != api.RoundInvalid {
// New rounds indexed.

default:
return lastRound, fmt.Errorf("failed to get latest block: %w", err)
}
// Do not notify watchers when during history reindex.
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
// Do not notify watchers when during history reindex.
// Do not notify watchers during history reindex.

// CommitBatch calls is valid as long as order is respected.
//
// Returns an error if we have already committed a block at higher or equal
// round (and or height).
Commit(blk *AnnotatedBlock, roundResults *RoundResults, notify bool) error
Copy link
Member

Choose a reason for hiding this comment

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

Commit is now only used when not doing history reindex right? I believe in that case we always want to notify? Is there a case where notify would be false with the new code?
If not, we should maybe just remove the argument and always notify.

ConsensusCheckpoint(height int64) error
// Returns an error if we have already committed block at higher or equal
// round (and or height) than the first item in a batch.
CommitBatch(blks []*AnnotatedBlock, roundResults []*RoundResults, notify bool) error
Copy link
Member

Choose a reason for hiding this comment

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

Is CommitBatch ever needed with notify being true? I would expect not, so maybe lets remove the argument and never notify from CommitBatch?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Makes sense also for the Commit part.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done here 089fd7d. If we like I will squash it.

cc @peternose ?

Copy link
Contributor

Choose a reason for hiding this comment

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

I guess so, since we need to review from the begging anyway.

Copy link
Contributor Author

@martintomazic martintomazic Feb 20, 2025

Choose a reason for hiding this comment

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

I think I have found a minor issue inside master related to this.

Context:
We always notify blocks when processFinalizedEvent succeeds. Either normal event processing or successful history reindex. The first time this happens is after first reindex, which also marks common committee node as initialized (see).

Rn, after first reindex, there is always a second reindex. Finally in case of any error during normal event processing, this simply marks a block history for reindex.

Problem:
Client committee node waits for initialization to be finished (see). Afterwards, it subscribes to runtime history WatchBlocks (see), thinking it will receive all blocks from this point on. Since we don't notify during reindex, in extremely unlikely scenario, SubmitTx may be called during subsequent reindex, meaning client worker may never receive blocks for the corresponding transaction, thus never handling SubmitTx properly.

** Observe we have two different WatchBlocks methods: 1. and 2. Common committee node is subscribed to first, common client committee node to second.

Solution:

  • Do only one initial reindex, repeat until n blocks from latest height.
    • If later normal events processing fails, all subsequent reindexes should notify about the blocks that happen in between...
  • Alternatively handle this in client committee worker, by e.g. fetching all missing blocks manually (not best).

If you agree this is problematic I would prefer to tackle this as a follow-up given this is existing (separate issue) that we have. We may want to preserve notify in the CommitBatch if you think first bullet is the right solution?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Actually this is not a problem, since if you submit transaction in the middle of n-th reindex (unlikely), this is already past and transaction can only be processed when you catch-up, so you are guaranteed to receive this block after-all...?

resVals := make([][]byte, batchSize)

// Preprocess first block.
if batchSize == 0 {
Copy link
Member

Choose a reason for hiding this comment

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

It makes more sense to do this check above, right after the existing length checks, before doing any array initializations.

}

first := blks[0]
if first.Block == nil {
Copy link
Member

Choose a reason for hiding this comment

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

It's reasonable to expect that roothash.AnnotatedBlock always has Block field present (unless the API docs explicitly mention it could be null), and panicking in case it does not is probably desired (since something major must be wrong, likely a developer error), so no need for this check.

Same for below in the loop.

We don't check this in other cases, e.g.:

return hrt.Query(ctx, annBlk.Block, lb, epoch, maxMessages, method, args)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Makes sense. It should be resolved now.

Copy link
Contributor

Choose a reason for hiding this comment

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

It's reasonable to expect that roothash.AnnotatedBlock always has Block field present (unless the API docs explicitly mention it could be null)

Yes, this is the best practice 👍 A field cannot be nil, unless explicitly stated. And if it can be nil, try to change the code (if possible) so that it can't be.

Comment on lines +492 to +501
results, err := sc.backend.GetBlockResults(ctx, height)
if err != nil {
// XXX: could soft-fail first few heights in case more heights were
// pruned right after the GetLastRetainedVersion query.
logger.Error("failed to get cometbft block results",
sc.logger.Error("failed to get cometbft block results",
"err", err,
"height", height,
)
return lastRound, fmt.Errorf("failed to get cometbft block results: %w", err)
return 0, fmt.Errorf("failed to get cometbft block results: %w", err)
Copy link
Member

Choose a reason for hiding this comment

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

I think the minimum interval is 1 second currently, and in practice it's likely much more (e.g. in minutes).

Likely not problematic in practice, so I don't really have strong opinions on how to handle it.

@martintomazic martintomazic force-pushed the martin/feature/batch-history-reindex-writes branch from 783354e to c4808e9 Compare February 19, 2025 14:23
@@ -164,15 +164,15 @@ func (d *DB) commit(blk *roothash.AnnotatedBlock, roundResults *roothash.RoundRe
)
}

if blk.Height < meta.LastConsensusHeight {
return fmt.Errorf("runtime/history: commit at lower consensus height (current: %d wanted: %d)",
if blk.Height <= meta.LastConsensusHeight && meta.LastConsensusHeight != 0 {
Copy link
Contributor

Choose a reason for hiding this comment

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

This won't work if we have multiple runtime blocks per one consensus block in the future. So why adding this limitation?

I would put these two ifs under another if meta.LastConsensusHeight != 0 {.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I was trying to prevent receiving multiple rounds for the same height in batch. I see your point that this limits future extension, albeit I should probably still validate for this rn? cc @ptrus

Copy link
Contributor

Choose a reason for hiding this comment

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

No, I think that previous implementation took into account that multiple blocks can be published on the same height. And now you are throwing that away. In general, I would stay that we shouldn't worry about consensus block heights at all, but didn't think this through. In theory, there could be a runtime which publishes runtime block 2 in height 1 and block 1 in height 2.

if err != nil {
return 0, fmt.Errorf("failed to fetch roothash finalized round: %w", err)
}
err = bh.Commit(annBlk, roundResults, false)
Copy link
Contributor

Choose a reason for hiding this comment

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

Now, we don't log sc.logger.Debug("commit block", for every commit.

func (d *DB) commit(blk *roothash.AnnotatedBlock, roundResults *roothash.RoundResults) error {
func (d *DB) commitBatch(blks []*roothash.AnnotatedBlock, results []*roothash.RoundResults) error {
if len(blks) != len(results) {
return fmt.Errorf("%d blocks != %d round results", len(blks), len(results))
Copy link
Contributor

Choose a reason for hiding this comment

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

This message could be improved, so that error reads like meaningful sentence and not an equation.

// the watchers will be notified about the new block. Disable notify when
// doing reindexing.
// Commit commits an annotated block with corresponding round results
// into block history.
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
// into block history.

//
// Must be called in order, sorted by round.
// If notify is set to true, the watchers will be notified about the new block,
// provided node has no local storage.
Copy link
Contributor

Choose a reason for hiding this comment

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

What is node, what is local storage from API point of view? Those two things should be unknwon.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

We have existing references to this:

// If node has local storage this includes waiting for the round to be synced into storage.

I see your point though.

Copy link
Contributor

@peternose peternose Feb 20, 2025

Choose a reason for hiding this comment

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

If I checked correctly, there is no function in the API of the history which uses or mentions nodes or local storage. So giving a comment for a special use case which we use, is not the best.

},
}

err = history.CommitBatch(nil, nil, true)
Copy link
Contributor

Choose a reason for hiding this comment

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

This could be wrapped in a subtest so that the name tells you what you are testing.

[]*roothash.RoundResults{results1},
true,
)
require.Error(err, "CommitBatch should fail when slices don't have equal size")
Copy link
Contributor

Choose a reason for hiding this comment

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

It is nice to test the happy path first, unless that complicates things a lot.

return nil
}

// Transaction should be fast, therefore we pre-process encoding and validation.
Copy link
Contributor

Choose a reason for hiding this comment

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

You can create a new commit for this, so that we can revert if needed. Can you test speed with and without these changes, just to be sure we don't slower things.

Would this be also ok?

func (d *DB) commitBatch(blks []*roothash.AnnotatedBlock, results []*roothash.RoundResults) error {
	if len(blks) != len(results) {
		return fmt.Errorf("%d blocks != %d round results", len(blks), len(results))
	}

	batchSize := len(blks)
	if batchSize == 0 {
		return nil
	}

	// Transaction should be fast, therefore we pre-process encoding and validation.
	blkKeys := make([][]byte, batchSize)
	blkVals := make([][]byte, batchSize)
	resKeys := make([][]byte, batchSize)
	resVals := make([][]byte, batchSize)

	var lastHeight int64
	var lastRound uint64

	for i := 0; i < batchSize; i++ {
		if rtID := blks[i].Block.Header.Namespace; !rtID.Equal(&d.runtimeID) {
			return fmt.Errorf("runtime mismatch, want %s, got %s)", d.runtimeID, rtID)
		}

		height := blks[i].Height
		if height <= lastHeight {
			return fmt.Errorf("out-of-order block (height: %d came after %d)", height, lastHeight)
		}

		round := blks[i].Block.Header.Round
		if round <= lastRound {
			return fmt.Errorf("out-of-order block (round: %d came after %d)", round, lastRound)
		}

		lastHeight = height
		lastRound = round

		blkKeys[i], blkVals[i] = blockKeyFmt.Encode(round), cbor.Marshal(blks[i])
		resKeys[i], resVals[i] = roundResultsKeyFmt.Encode(round), cbor.Marshal(results[i])
	}

	// Commit batch transaction.
	return d.db.Update(func(tx *badger.Txn) error {
		meta, err := d.queryGetMetadata(tx)
		if err != nil {
			return err
		}

		if meta.LastConsensusHeight != 0 {
			if firstHeight := blks[0].Height; firstHeight <= meta.LastConsensusHeight {
				return fmt.Errorf("commit at lower or equal consensus height (current: %d wanted: %d)",
					meta.LastConsensusHeight,
					firstHeight,
				)
			}

			if firstRound := blks[0].Block.Header.Round; firstRound <= meta.LastRound {
				return fmt.Errorf("commit at lower or equal round (current: %d wanted: %d)",
					meta.LastRound,
					firstRound,
				)
			}
		}

		for i := 0; i < batchSize; i++ {
			if err = tx.Set(blkKeys[i], blkVals[i]); err != nil {
				return err
			}
			if err = tx.Set(resKeys[i], resVals[i]); err != nil {
				return err
			}
		}

		meta.LastRound = lastRound
		meta.LastConsensusHeight = lastHeight

		return tx.Set(metadataKeyFmt.Encode(), cbor.Marshal(meta))
	})
}

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Will bench this tmr, albeit I don't expect major slowdowns here.

I have already benchmarked sorting every batch, before committing, since later when we will fetch data in parallel we will have to sort items first. Luckily it was negligible.

Copy link
Contributor

Choose a reason for hiding this comment

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

I tested this locally with artificial data and it looked like optimization (similar to the one above) was slower. Have no idea why. So I would test this before committing. But in general, it should still hold that locks should be released as fast as possible.

@@ -505,7 +505,8 @@ func (sc *serviceClient) reindexBlocks(ctx context.Context, currentHeight int64,
if err != nil {
return 0, fmt.Errorf("failed to fetch roothash finalized round: %w", err)
}
err = bh.Commit(annBlk, roundResults, false)
// Do not notify watchers during history reindex.
Copy link
Contributor

Choose a reason for hiding this comment

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

This comment is not the best.

// Within a batch, blocks should be sorted by round. Any sequence of Commit
// and CommitBatch is valid as long as order is respected.
//
// Watchers will not be automatically notified about blocks in batch.
Copy link
Contributor

Choose a reason for hiding this comment

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

This is very strange. How will watcher react if someone calls Commit, CommitBatch, and Commit. Where are the missing blocks? We should find a neater solution as once database is available for use/watching, all blocks should be reported.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Agree. I have outlined this problem here with possible solutions: #6050 (comment).

Given this is existing issue we have in master** I suggest to handle this in a follow-up or agree that existing code should be first refactored (possibly to first bullet point under solution section in this comment)?

Copy link
Contributor

@peternose peternose Feb 20, 2025

Choose a reason for hiding this comment

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

Yes, this was not handled well before. I don't have a solution for this, but if you have one, it would be nice to implement it here. Thinking out loud, we could implement init phase where we could commit without notifications, we could enable/disable notifications, notify heights instead of blocks, ... not sure what is the best. But if I call this API and get height 1 and afterwards height 1001, I have no idea how my code should react.

Btw, do we ever check that rounds in the batch are sequential, we only check that the next round is higher, right?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Batch history reindex writes
3 participants