Data Flow
This document describes multiple data flows between system components using its event-driven architecture.
Block Stream Publish API Flow
** Overview should cover block items flow, merkle tree building, verification, persistence, and notification. **
Detailed Steps:
gRPC publisher client sends a stream of Block Items to a Block Node.
Block Node server receives the stream via the
BlockStreamPublishServiceimplemented by theStreamPublisherPlugin.The
StreamPublisherPluginpublishes the stream to theBlockMessagingFacility.This plugin publishes Notifications when backfill is needed.
The
BlockMessagingFacilitybroadcasts Block Items to all registered plugins (handlers). Each handler runs on a pool of threads managed by theBlockMessagingFacility, reducing context switching for efficient scalability.VerificationServicePluginprocess Block Items building the block merkle tree and verifying it upon receipt of a block proof.This plugin publishes a
VerificationNotificationupon successful verification.
BlockFilesRecentPluginlistens for aVerificationNotificationand persists a verified block accordingly. It also publishes aPersistedNotificationupon completion.StreamPublisherPlugin responds to a notifications asynchronously and sends responses back to the gRPC client
on a successful
PersistedNotificationit sends an acknowledgment.on a failed
VerificationNotificationor failedPersistedNotificationit sends a failure message.
Note: Each plugin processes items independently making use of thread isolation
Block Access API Flow
The BlockAccessServicePlugin exposes a unary gRPC endpoint that retrieves a single stored block by number. A client sends one BlockRequest; the Block Node responds with the raw block bytes or an error code - no streaming involved.
Detailed Steps:
A gRPC client sends a
getBlockunary request toBlockAccessServicePlugin. The request specifies either an explicitblock_numberor setsretrieve_latesttotrue.The plugin resolves the target block number: an explicit
block_number ≥ 0is used directly;retrieve_latest = true(orblock_number = -1) resolves to the maximum block number returned byHistoricalBlockFacility.availableBlocks().max(). An unrecognised combination returnsINVALID_REQUEST.The plugin calls
availableBlocks().contains(N)to confirm the resolved block number is within the current stored range. If not, it returnsNOT_AVAILABLEwithout touching storage.The plugin calls
blockProvider.block(N), which opens aBlockAccessorpointing at the requested block.HistoricalBlockFacilitydispatches internally to whichever storage tier currently holds that block:BlockFileRecentPlugin(backed by fast NVMe, holds recent and live-stream blocks) for newer blocks, orBlockFileHistoricPlugin(backed by bulk HDD, holds the compressed historic archive) for deep history. These two storage plugins are what the core team has built so far; the plugin interface allows operators and third parties to build additional storage backends. Responses include allBlockProofitems attached to the block. Recipients must verify these proofs independently — a block served by a Block Node should not be assumed verified simply because it was stored there.The plugin calls
accessor.blockUnparsed()to retrieve the block as raw bytes (BlockUnparsed). This deliberately avoids proto deserialization for two reasons: performance (large blocks do not need to be re-parsed to be forwarded), and forward compatibility (the block's proto encoding is returned as-is regardless of any version differences between the publisher and the subscriber).The plugin returns
BlockResponse(SUCCESS, block). If the accessor returns null despite the block being in range (storage fault), it returnsNOT_FOUND.
Block Stream Subscription API Flow
The SubscriberServicePlugin exposes a server-streaming gRPC endpoint. A subscriber sends one SubscribeStreamRequest; the Block Node streams any number of SubscribeStreamResponse messages back until the requested range is exhausted or the stream is closed.
Detailed Steps:
A subscriber sends a
subscribeBlockStreamserver-streaming request specifyingstart_block_numberandend_block_number. Both are unsigned 64-bit integers; the sentinel valueuint64_max(stored asUNKNOWN_BLOCK_NUMBER = -1Lin Java) signals "first available" for start and "indefinite" for end.SubscriberServicePluginbuilds a server-streaming pipeline and delegates incoming requests toSubscribeBlockStreamHandler, which assigns a monotonically increasingclientIdto the connection, constructs aSessionContext, and submits aBlockStreamSubscriberSession(Callable) to a virtual-threadExecutorCompletionService.The handler waits on
sessionReadyLatchuntil the session initialises. The session validates the request and determines one of four streaming modes:Live-only — both
startandendareuint64_max: attach immediately to the live block queue and stream from the newest block published.First-available-to-end —
start = uint64_max, explicitend = M: serve from the earliest stored block through block M.From-start-indefinitely — explicit
start = N,end = uint64_max: serve history from block N, then transition to live.Bounded-range — explicit
start = N, explicitend = M(M ≥ N): serve blocks N through M and close.
Historical phase (modes 2, 3, and 4): the session reads blocks sequentially from
HistoricalBlockFacility. Each block is serialized as one or moreBlockItemSetmessages (block_items) followed by a singleend_of_blockresponse carrying the block number.Live phase (modes 1 and 3): the session registers a
LiveBlockHandler(implementingNoBackPressureBlockItemHandler) onBlockMessagingFacility. New blocks published byStreamPublisherPlugintravel throughBlockMessagingFacility's LMAX Disruptor ring buffer and are forwarded to the subscriber pipeline in real time. The handler is "no back-pressure" because slow subscribers are switched between "historical" and "live" as needed rather than blocking the publish path.When all requested blocks are delivered (or the node shuts down), the session sends
status(SUCCESS)and the gRPC stream closes. Active sessions are tracked in the handler'sopenSessionsmap and deregistered on completion.Reconnect: the Block Node itself is stateless with respect to subscriber position. On disconnect, the client is responsible for tracking the last received block number and resubscribing from
last_received + 1infrom-start-indefinitelyorbounded-rangemode.
Backfilling Flow
The BackfillPlugin guarantees block stream continuity by detecting gaps in the local stored block range and filling them by fetching the missing blocks from peer Block Nodes.
Detailed Steps:
BackfillPluginruns two complementary gap-detection triggers. A periodic autonomous scanner runsdetectAndScheduleGaps()on ascheduleAtFixedRateloop (interval:backfillConfiguration.scanInterval()). In addition, when thestream-publisherplugin broadcasts aNewestBlockKnownToNetworkNotification(a newer block seen on the network), the plugin callshandleNewestBlockKnownToNetwork()to trigger a live-tail check immediately without waiting for the next scheduled scan.Gap detection: the plugin queries its current stored block ranges, then uses
BackfillFetcherto callserverStatus()on each configured peer and determine the maximum available block across the network (theendCap).GapDetector.findTypedGaps()computes the delta between the local stored ranges and the target range, classifying each gap as:HISTORICAL — blocks older than the current live stream head that are missing from local storage.
LIVE_TAIL — recent blocks that should be arriving from the live stream but have not yet been persisted.
Gaps are submitted to one of two independent schedulers:
historicalScheduler(backed byhistoricalExecutor) handles HISTORICAL gaps;liveTailScheduler(backed byliveTailExecutor) handles LIVE_TAIL gaps. Keeping them separate ensures historical catch-up work never blocks live-tail synchronization.For each gap, a
BackfillRunnerselects the best peer usingPriorityHealthBasedStrategy, which scores candidates on four factors in order: (a) earliest available block number, (b) configured operator priority, (c) health score derived from recent failure count and latency, (d) random tie-breaker.BackfillFetcherfetches the gap range from the selected peer by callingsubscribeBlockStream(start = N, end = M)— the same bounded-range mode used by regular subscribers. The peer streams blocks back in chunks;BackfillPersistenceAwaiterprovides backpressure so the fetcher does not outrun the local persistence pipeline.Fetch success: on successful completion
BackfillFetcherrecords the result in itshealthMap(SourceHealthrecord), resetting the failure counter and accumulating latency. Each fetched block is then dispatched toBlockMessagingFacilityviasendBackfilledBlockNotification.VerificationServicePluginverifies it andBlockFileRecentPluginpersists it — mirroring the publish path. When thePersistedNotificationarrives tagged with a backfill source, the plugin decrementsbackfill_pending_blocksand incrementsbackfill_blocks_backfilled.Fetch failure: on error or timeout
BackfillFetcherincrements the failure count and sets an exponential backoff window inhealthMapfor that peer.BackfillRunnerretries with the next best peer (up tomaxRetries), rerunning thePriorityHealthBasedStrategyselection with the updated health scores so that the failing peer is deprioritized automatically.
Note: The two schedulers use independent thread pools so that a slow historical catch-up does not starve the live-tail scheduler, which is time-sensitive.
Last updated