Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
fb26c17
fix : remove build() method
guha-rahul Dec 10, 2025
4353bb0
fix : add shuffling to cache
guha-rahul Dec 10, 2025
27b506f
fix : reverting async shuffling refactor
guha-rahul Dec 10, 2025
bbdf692
chore: update comments
guha-rahul Dec 11, 2025
97a1372
Remove ShufflingCache dependency from state-transition package
guha-rahul Jan 6, 2026
ec2983f
proposer lookahead comments
guha-rahul Jan 6, 2026
03cb629
remove metrics
guha-rahul Jan 6, 2026
c3dbaaa
add metrics
guha-rahul Jan 13, 2026
9ae55bc
make set private and remove getSync
guha-rahul Jan 13, 2026
f6a425b
add back processState
guha-rahul Jan 13, 2026
58d82a2
deduplicate computeEpochShuffling and make nextShuffling non-optional
guha-rahul Jan 13, 2026
382a664
avoid expensive cache computation when shuffling cache
guha-rahul Jan 13, 2026
a06d62f
cache population
guha-rahul Jan 14, 2026
57e6099
fix: add back ShufflingGetter
guha-rahul Jan 14, 2026
6a7d5fe
Merge remote-tracking branch 'upstream/unstable' into refactor_shiffl…
guha-rahul Jan 14, 2026
14a28e9
remove nextShufflingDecisionRoot and fix type error
guha-rahul Jan 14, 2026
bc30b36
fix merge error
guha-rahul Jan 15, 2026
4828555
fix metrics
guha-rahul Jan 15, 2026
7f81ffc
use set in brackets
guha-rahul Jan 15, 2026
2eb4ee0
cache shufflings on checkpoint event for regen states and fix test
guha-rahul Jan 15, 2026
f81861d
fix: cache all shufflings via processState on checkpoint event
guha-rahul Jan 16, 2026
f3aff93
remove null check
guha-rahul Jan 16, 2026
62bce64
use calculateDecisionRoot
guha-rahul Jan 16, 2026
3dc07b8
use setImmediate to defer processing
guha-rahul Jan 17, 2026
0681f48
pnpm lint
guha-rahul Jan 17, 2026
eb05b91
use callInNextEventLoop instead of setImmediate
guha-rahul Jan 19, 2026
f75cd15
chore: fix parse-path build error
matthewkeil Jan 19, 2026
e24b5f3
remove shufflingCalculationTime metric
guha-rahul Jan 20, 2026
aa860b5
Merge branch 'refactor_shiffling_cache' of https://github.com/guha-ra…
guha-rahul Jan 20, 2026
c2f3801
Revert "chore: fix parse-path build error"
matthewkeil Jan 22, 2026
86b0359
remove the unused metrics
guha-rahul Jan 22, 2026
9aad853
Merge branch 'refactor_shiffling_cache' of https://github.com/guha-ra…
guha-rahul Jan 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions packages/beacon-node/src/chain/blocks/importBlock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,13 @@ export async function importBlock(
// Some block event handlers require state being in state cache so need to do this before emitting EventType.block
this.regen.processState(blockRootHex, postState);

const parentEpoch = computeEpochAtSlot(parentBlockSlot);
if (parentEpoch < blockEpoch && postState.epochCtx.nextShuffling !== null) {
// current epoch and previous epoch are likely cached in previous states
this.shufflingCache.set(postState.epochCtx.nextShuffling, postState.epochCtx.nextDecisionRoot);
this.logger.verbose("Processed shuffling for next epoch", {parentEpoch, blockEpoch, slot: blockSlot});
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Originally this happened right before the epoch work. I think it should be ok doing it early here but for consistency sake can we move this down to just before the line if (blockSlot % SLOTS_PER_EPOCH === 0) { in case there are error cases that will cause us to exit the import early. We only cache a limited number of shufflings and will be better to not cache ones from imports that fail.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

agree, should stay where it was originally


this.metrics?.importBlock.bySource.inc({source: source.source});
this.logger.verbose("Added block to forkchoice and state cache", {slot: blockSlot, root: blockRootHex});

Expand Down
8 changes: 5 additions & 3 deletions packages/beacon-node/src/chain/chain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -306,7 +306,7 @@ export class BeaconChain implements IBeaconChain {
});
this._earliestAvailableSlot = cachedState.slot;

this.shufflingCache = cachedState.epochCtx.shufflingCache = new ShufflingCache(metrics, logger, this.opts, [
this.shufflingCache = new ShufflingCache(metrics, logger, this.opts, [
{
shuffling: cachedState.epochCtx.previousShuffling,
decisionRoot: cachedState.epochCtx.previousDecisionRoot,
Expand Down Expand Up @@ -1004,8 +1004,10 @@ export class BeaconChain implements IBeaconChain {
state = await this.regen.getState(attHeadBlock.stateRoot, regenCaller);
}

// should always be the current epoch of the active context so no need to await a result from the ShufflingCache
return state.epochCtx.getShufflingAtEpoch(attEpoch);
// Get shuffling from the regenerated state and populate the cache
const shuffling = state.epochCtx.getShufflingAtEpoch(attEpoch);
this.shufflingCache.set(shuffling, shufflingDependentRoot);
return shuffling;
}

/**
Expand Down
7 changes: 1 addition & 6 deletions packages/beacon-node/src/chain/prepareNextSlot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,12 +118,7 @@ export class PrepareNextSlotScheduler {
// the slot 0 of next epoch will likely use this Previous Root Checkpoint state for state transition so we transfer cache here
// the resulting state with cache will be cached in Checkpoint State Cache which is used for the upcoming block processing
// for other slots dontTransferCached=true because we don't run state transition on this state
//
// Shuffling calculation will be done asynchronously when passing asyncShufflingCalculation=true. Shuffling will be queued in
// beforeProcessEpoch and should theoretically be ready immediately after the synchronous epoch transition finished and the
// event loop is free. In long periods of non-finality too many forks will cause the shufflingCache to throw an error for
// too many queued shufflings so only run async during normal epoch transition. See issue ChainSafe/lodestar#7244
{dontTransferCache: !isEpochTransition, asyncShufflingCalculation: true},
{dontTransferCache: !isEpochTransition},
RegenCaller.precomputeEpoch
);

Expand Down
4 changes: 0 additions & 4 deletions packages/beacon-node/src/chain/regen/interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,6 @@ export enum RegenFnName {

export type StateRegenerationOpts = {
dontTransferCache: boolean;
/**
* Do not queue shuffling calculation async. Forces sync JIT calculation in afterProcessEpoch if not passed as `true`
*/
asyncShufflingCalculation?: boolean;
};

export interface IStateRegenerator extends IStateRegeneratorInternal {
Expand Down
68 changes: 11 additions & 57 deletions packages/beacon-node/src/chain/shufflingCache.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,4 @@
import {
BeaconStateAllForks,
EpochShuffling,
IShufflingCache,
ShufflingBuildProps,
computeEpochShuffling,
computeEpochShufflingAsync,
} from "@lodestar/state-transition";
import {EpochShuffling} from "@lodestar/state-transition";
import {Epoch, RootHex} from "@lodestar/types";
import {LodestarError, Logger, MapDef, pruneSetToMax} from "@lodestar/utils";
import {Metrics} from "../metrics/metrics.js";
Expand Down Expand Up @@ -53,7 +46,7 @@ export type ShufflingCacheOpts = {
* - if a shuffling is not available (which does not happen with default chain option of maxSkipSlots = 32), track a promise to make sure we don't compute the same shuffling twice
* - skip computing shuffling when loading state bytes from disk
*/
export class ShufflingCache implements IShufflingCache {
export class ShufflingCache {
/** LRU cache implemented as a map, pruned every time we add an item */
private readonly itemsByDecisionRootByEpoch: MapDef<Epoch, Map<RootHex, CacheItem>> = new MapDef(
() => new Map<RootHex, CacheItem>()
Expand Down Expand Up @@ -136,67 +129,28 @@ export class ShufflingCache implements IShufflingCache {
}

/**
* Gets a cached shuffling via the epoch and decision root. If the shuffling is not
* available it will build it synchronously and return the shuffling.
*
* NOTE: If a shuffling is already queued and not calculated it will build and resolve
* the promise but the already queued build will happen at some later time
* Gets a cached shuffling synchronously via the epoch and decision root.
* Returns null if not found in cache.
*/
getSync<T extends ShufflingBuildProps | undefined>(
epoch: Epoch,
decisionRoot: RootHex,
buildProps?: T
): T extends ShufflingBuildProps ? EpochShuffling : EpochShuffling | null {
getSync(epoch: Epoch, decisionRoot: RootHex): EpochShuffling | null {
const cacheItem = this.itemsByDecisionRootByEpoch.getOrDefault(epoch).get(decisionRoot);
if (!cacheItem) {
this.metrics?.shufflingCache.miss.inc();
} else if (isShufflingCacheItem(cacheItem)) {
return null;
}
if (isShufflingCacheItem(cacheItem)) {
this.metrics?.shufflingCache.hit.inc();
return cacheItem.shuffling;
} else if (buildProps) {
// TODO: (@matthewkeil) This should possible log a warning??
this.metrics?.shufflingCache.shufflingPromiseNotResolvedAndThrownAway.inc();
} else {
this.metrics?.shufflingCache.shufflingPromiseNotResolved.inc();
}

let shuffling: EpochShuffling | null = null;
if (buildProps) {
const timer = this.metrics?.shufflingCache.shufflingCalculationTime.startTimer({source: "getSync"});
shuffling = computeEpochShuffling(buildProps.state, buildProps.activeIndices, epoch);
timer?.();
this.set(shuffling, decisionRoot);
}
return shuffling as T extends ShufflingBuildProps ? EpochShuffling : EpochShuffling | null;
}

/**
* Queue asynchronous build for an EpochShuffling, triggered from state-transition
*/
build(epoch: number, decisionRoot: string, state: BeaconStateAllForks, activeIndices: Uint32Array): void {
this.insertPromise(epoch, decisionRoot);
/**
* TODO: (@matthewkeil) This will get replaced by a proper build queue and a worker to do calculations
* on a NICE thread
*/
const timer = this.metrics?.shufflingCache.shufflingCalculationTime.startTimer({source: "build"});
computeEpochShufflingAsync(state, activeIndices, epoch)
.then((shuffling) => {
this.set(shuffling, decisionRoot);
})
.catch((err) =>
this.logger?.error(`error building shuffling for epoch ${epoch} at decisionRoot ${decisionRoot}`, {}, err)
)
.finally(() => {
timer?.();
});
this.metrics?.shufflingCache.shufflingPromiseNotResolved.inc();
return null;
}

/**
* Add an EpochShuffling to the ShufflingCache. If a promise for the shuffling is present it will
* resolve the promise with the built shuffling
*/
private set(shuffling: EpochShuffling, decisionRoot: string): void {
set(shuffling: EpochShuffling, decisionRoot: string): void {
const shufflingAtEpoch = this.itemsByDecisionRootByEpoch.getOrDefault(shuffling.epoch);
// if a pending shuffling promise exists, resolve it
const cacheItem = shufflingAtEpoch.get(decisionRoot);
Expand Down
14 changes: 0 additions & 14 deletions packages/beacon-node/src/metrics/metrics/lodestar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1312,29 +1312,15 @@ export function createLodestarMetrics(
name: "lodestar_shuffling_cache_recalculated_shuffling_count",
help: "Count of shuffling that were build multiple times",
}),
shufflingPromiseNotResolvedAndThrownAway: register.gauge({
name: "lodestar_shuffling_cache_promise_not_resolved_and_thrown_away_count",
help: "Count of shuffling cache promises that were discarded and the shuffling was built synchronously",
}),
shufflingPromiseNotResolved: register.gauge({
name: "lodestar_shuffling_cache_promise_not_resolved_count",
help: "Count of shuffling cache promises that were requested before the promise was resolved",
}),
nextShufflingNotOnEpochCache: register.gauge({
name: "lodestar_shuffling_cache_next_shuffling_not_on_epoch_cache",
help: "The next shuffling was not on the epoch cache before the epoch transition",
}),
shufflingPromiseResolutionTime: register.histogram({
name: "lodestar_shuffling_cache_promise_resolution_time_seconds",
help: "Time from promise insertion until promise resolution when shuffling was ready in seconds",
buckets: [0.5, 1, 1.5, 2],
}),
shufflingCalculationTime: register.histogram<{source: "build" | "getSync"}>({
name: "lodestar_shuffling_cache_shuffling_calculation_time_seconds",
help: "Run time of shuffling calculation",
buckets: [0.5, 0.75, 1, 1.25, 1.5],
labelNames: ["source"],
}),
},

seenCache: {
Expand Down
4 changes: 2 additions & 2 deletions packages/beacon-node/test/unit/chain/shufflingCache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ describe("ShufflingCache", () => {
shufflingCache.insertPromise(currentEpoch, "0x00");
expect(await shufflingCache.get(currentEpoch, currentDecisionRoot)).toEqual(state.epochCtx.currentShuffling);
// insert shuffling at other epochs does prune the cache
shufflingCache["set"](state.epochCtx.previousShuffling, state.epochCtx.previousDecisionRoot);
shufflingCache.set(state.epochCtx.previousShuffling, state.epochCtx.previousDecisionRoot);
// the current shuffling is not available anymore
expect(await shufflingCache.get(currentEpoch, currentDecisionRoot)).toBeNull();
});
Expand All @@ -40,7 +40,7 @@ describe("ShufflingCache", () => {
shufflingCache.insertPromise(previousEpoch, previousDecisionRoot);
const shufflingRequest0 = shufflingCache.get(previousEpoch, previousDecisionRoot);
const shufflingRequest1 = shufflingCache.get(previousEpoch, previousDecisionRoot);
shufflingCache["set"](state.epochCtx.previousShuffling, previousDecisionRoot);
shufflingCache.set(state.epochCtx.previousShuffling, previousDecisionRoot);
expect(await shufflingRequest0).toEqual(state.epochCtx.previousShuffling);
expect(await shufflingRequest1).toEqual(state.epochCtx.previousShuffling);
});
Expand Down
Loading