Repository navigation
reader-node: firecore-owned reader cursor (--reader-node-cursor) - #309
matthewdarwin wants to merge 3 commits into
Conversation
With --reader-node-cursor the reader node keeps reader-cursor.json in its
working directory: the last block stored in the one-block store with every
block the reader printed before it stored too, its LIB, and the reader's own
value from "FIRE CURSOR <block_num> <block_id> <value>" lines. Blocks are
queued in the order they were read and the cursor moves over the stored part
of the queue without a gap, once each upload returns; one goroutine replaces
the file atomically and syncs it.
The {cursor-file}, {resume-block-num}, {resume-block-id}, {resume-lib-num},
{resume-cursor} and {resume-cursor-block-num} templates of
--reader-node-arguments are read from the file each time the reader starts
(Superviser.SetArgumentsFunc), so a reader restarts from what was stored rather
than from what it printed. A reader that records a block as done once printed
loses the blocks still in firecore's pipeline when both are killed together.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…el/reader-cursor example --reader-node-cursor no longer refuses to start on a chain whose superviser can't resolve its arguments again unless --reader-node-arguments uses a cursor template: the cursor file is still kept, and a reader that doesn't use it runs as before. devel/reader-cursor: a fake chain, a reader in two modes (one that can print any block again, one whose state only moves forward) with the old and the new way of resuming, a merged-blocks checker, and scripts that SIGKILL firecore and the reader together and check the merged blocks. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
Not completely against the feature but I wouldn't say it convinces me neither. We have seen in the past that writing a cursor each block without proper care could lead to long backprocessing due to sync writes blocking stdout I/O for example. I'm far from being convinced on new added Maybe we introduce instead a "sync" mode with a write ahead log instead for example, that would avoid all this cursoring needs. Maybe we add a mode automatically resume to "earliest" missing one blocks on bootstrap, that would be the second part that auto-recover poller which is less "controversial" if I can say it like that. |
|
Converting to draft until we settle on the correct path forward. Is what you are trying to achieve is auto-recovering poller nodes? |
|
@maoueh Thanks for reviewing. Claude as usual was focused on fixing the specific problem of making the cursor file survive a specific failure path, rather than looking holistically at "auto-recover poller nodes", but yes, "auto-recover poller nodes" is a better solution. eg figure out what block the merger has last merged and then start there... there are a couple of things that would be improved with such a feature. Although in the specific case I was working on, the "poller" can't rewind, so it would need to stop (or have some workflow) that rolls back to a previous snapshots. (necessary workflow is operator responsibility, not a firehose one... firehose just need to provide the correct signals that can trigger the right behaviour... eg merger is at X, you're at Y, there is a gap, do something about it). The big feature that would be helpful is if the --start-block for the reader could say and know where to start from. Especially good for spinning up a new poller node in k8s Also great for the remote reader too as a fallback to another provider (our firehose is lagging, start reading from other provider starting from last merged block) Happy to work on alternate solutions if helps or if y'all take the leadership such features. Let me know how I can best help. |
|
Here different strategies I see:
In both case poller is modified to restart from last written block. This use side-channel to move the "write" cursor on the poller side avoiding completely the need to modify Firehose protocol. |
This adds
--reader-node-cursor: the reader node keeps the reader's cursor and gives it back to the reader when it starts. A reader then restarts from what firecore stored, not from what it printed.This is the follow-up announced in streamingfast/bstream#88, which fixed how the live hub behaves once a block is missing. This PR stops the block from going missing.
The problem
A reader that records a block as done as soon as it prints it can lose blocks for good. Between the
FIRE BLOCKline and the one-block file, the block sits in firecore's in-memory pipeline:--reader-node-blocks-chan-capacity);When firecore and the reader die together (a crash, an OOM kill of the container, SIGKILL), everything in that pipeline is lost, while the reader's own record says it was printed. On restart the reader continues after it. The merger then can't link past the gap ("too many consecutive unlinkable blocks", forever), and the live hub stops with "cannot link block after reconnection, restart required".
blockpollerbehaves this way:fire()writes the block to stdout, thensaveStatewritescursor.json. On restartinitStatemarks the last fired block as fired, and the poller fetches the next one. Pollers built on it are exposed in the same way. A stateful reader that commits its own state independently of firecore is exposed too, and can't produce the lost blocks again without restoring an older snapshot.In a synthetic test (below), the window was up to about 15,000 blocks while catching up: at a SIGKILL the reader had printed up to block 15,462 and firecore had merged up to 699.
What this adds
The cursor file
With
--reader-node-cursor, the reader node keepsreader-cursor.jsonin--reader-node-working-dir:{ "block": {"num": 4605, "id": "53b6…6403"}, "lib_num": 4595, "value": {"block": {"num": 4605, "id": "53b6…6403"}, "value": "bmV4dD00NjA2"} }blockis the last block in the one-block store such that every block printed before it is stored too.PushLocalFilereturns. Uploads complete in any order.lib_numis that block's LIB.valueis the reader's own value (FIRE CURSOR, below) for the last stored block that had one.How it's written: atomically (tmp file, fsync, rename, fsync of the directory). One goroutine writes it, always with the latest cursor, because uploads complete faster than a file can be synced. The archiver closes the tracker after its final upload pass, which writes the last cursor.
What it isn't: it is local to the reader node, like a poller's
cursor.json, and is not derived from the block stores. Several readers can work on different ranges with shared stores, and the stores can't tell whose blocks are whose. With no cursor file, the reader starts where it is configured to, as today.Templates
New
--reader-node-argumentstemplates are read from the cursor file each time the reader starts. The superviser takes an arguments function (SetArgumentsFunc), called before each start.{cursor-file}{resume-block-num},{resume-block-id}{resume-lib-num}{resume-cursor},{resume-cursor-block-num}Each is empty when there is no cursor yet:
--resume-block={resume-block-num}becomes--resume-block=.FIRE CURSORA reader may print this after a block. firecore keeps the value with that block and writes it to the cursor file once the block is stored. The value is whatever the reader wants back on restart: a page token for an RPC poller, or a hash of a stateful reader's state, so it can check the state it rebuilt.
FIREline. A reader that never prints it gets a cursor without a value.ConsoleReader.readLine, the text path ofconsoleline.Splitter.Enabling it before the reader supports it
--reader-node-cursorchanges nothing for a reader that doesn't know about it, as long as--reader-node-argumentsdoesn't use the new templates:reader-cursor.jsonanyway, which already tells an operator how far firecore stored, compared with the reader's own marker;FIRE CURSORlines, the cursor has novalue, and nothing waits on the reader.The cost is one small bookkeeping entry per block until its upload completes, plus the file rewritten as uploads complete. A failed write is logged and changes nothing else.
Two cases do change behaviour:
--resume-block={resume-block-num}for such a reader makes it reject the flag and not start. That shows at the first start.superviser.Superviser). If the arguments use a cursor template, firecore refuses to start, because the values would go stale after the first restart. Without templates, the flag is accepted and only the file is kept.One more: if
reader-cursor.jsonexists but can't be read, the reader doesn't start, so a bad cursor is never silently ignored. The file is replaced atomically, so it can't be left half-written.What readers do with it
A reader that can produce any block again (an RPC poller) restarts from
{resume-lib-num}+ 1. The blocks above the LIB are printed again, and the one-block store, the merger and the hub ignore them as already known.A reader whose state only moves forward (it indexes the chain into its own database) has two rules:
{cursor-file}shows N or later. It reads the file while it runs.{resume-block-num}, it refuses to start rather than skip blocks, because those blocks can't be produced again without an older snapshot.After an ordinary crash such a reader indexes again, silently, up to the cursor, and prints from the next block.
Follow-up: pollers
Readers have to opt in, so this PR alone protects no existing chain.
blockpolleris the place to start, because most RPC readers are built on it (firehose-solana, firehose-ethereum's RPC poller, firehose-bitcoin, and the Starknet, Stellar, Tron and Injective readers, among others).A follow-up PR would change
blockpollerto:fetch rpc {first-streamable-block} --resume-lib={resume-lib-num}. When it's set, the poller starts at the LIB + 1 and ignorescursor.json, asIgnoreCursor()does now. The fork state the poller keeps incursor.jsonisn't needed after a restart: everything above the LIB is printed again, and firecore ignores what it already has.FIRE CURSORfor a poller with position state beyond the block number.Each chain's poller then:
blockpoller;--reader-node-argumentsto--reader-node-cursorplus--resume-lib={resume-lib-num}, keeping{first-streamable-block}for a fresh start.Readers that sit on a node, such as instrumented geth, would not use this feature: the node keeps its own position in its database.
Documentation to update
Not in this PR. In streamingfast/firehose-docs:
references/firehose-protocol.md: addFIRE CURSORto the message types.FIRE SIGNAL, which dummy-blockchain prints, isn't documented there either.references/cli/reader-node.md: the--reader-node-cursorflag, the six templates and that they are read on every start, andreader-cursor.jsonin the working directory.integrate-new-chains/integration-overview.md(RPC Poller) andfirehose-starter.md: resume from the reader cursor, not the reader's own state.firehose-setup/poller pages (injective, stellar, starknet, tron, avalanche): their--reader-node-argumentsexamples, once each poller supports it.architecture/components/reader.md,architecture/data-flow.mdandarchitecture/components/merger.md.Elsewhere:
blockpollerhas no README. Its resume behaviour is documented only in code, so the poller follow-up is a good moment for a package doc.INTEGRATION.mdcould show the flag once dummy-blockchain uses it. dummy-blockchain is a node, not a poller, and needs no change.Testing
Test code in this PR
go test ./...:node-manager/mindreader/cursor_test.go: the cursor tracker;consolereader_test.go:FIRE CURSORparsing;cmd/apps/reader_node_test.go: the templates and detecting them.devel/reader-cursor/, a runnable example. Its scripts start firecore and take minutes, so they are run by hand rather than bygo test.fakechain/: the synthetic chain.fakereader/: a reference reader, using both the old and the new way of resuming.checkmerged/: a merged-blocks checker.kill-test.sh,restore-test.sh: the scripts.README.md: how to run them, with the expected results.Results
Unit tests:
Close; values for blocks never queued.FIRE CURSORparsing.go test ./...passes;node-manager/mindreaderalso passes with-race.Kill tests,
devel/reader-cursor/. A synthetic chain is read by a reference reader underfirecore start reader-node merger, with local stores. The scripts build firecore and the example from the checkout, and the README explains how to run them.{resume-lib-num}+ 1{cursor-file}shows stored{resume-block-num}{resume-cursor}, and continues to 12,399 with no gapNot covered
file://one-block store doesn't fsync its files, so it can lose stored blocks older than the cursor. Object stores are durable once the upload returns.Reviewing
node-manager/mindreader/cursor.go, about 300 lines. The rest is wiring:ConsoleReader.readCursorparsesFIRE CURSOR;--reader-node-cursorare incmd/apps/reader_node.go;Superviser.SetArgumentsFuncre-resolves the arguments on each start.FIRE CURSORlines that no handler receives.go test ./...go test -race ./node-manager/mindreader/devel/reader-cursor/kill-test.sh rewind legacyand thenrewind cursor, about 2 minutes each. The first must end with the merger stuck, the second with no gap. Thestatefulruns andrestore-test.shcover a reader whose state only moves forward.FIRE CURSORformat and the file's JSON fields become an interface for every reader, so they're worth agreeing on before merge.Next steps
If this direction looks right, follow-up PRs can be provided:
blockpollerchange above;Changes to the names or the file format before merge are welcome too.