Chain: gate operator-daemon startup on controller::is_synced()#522
Merged
Conversation
…sync Every test cluster's boot window logged error-level lines from batch_operator (3060002/3060003): plugin_startup eagerly read sysio.chains before a cold-booting daemon node's local replay reached the deploy blocks. The underwriter already solved this privately with an accepted_block-signal gate plus an inner executor re-post. Promote the predicate - and only the predicate - to chain_plugin: - chain_plugin::is_synced(): LIB-recency predicate (fork_db root block time within chain_apis::default_sync_recency_ms of wall-clock now), computed on demand, never cached. The pure building blocks live in sync_gate.hpp (moved from the underwriter's sync_detail.hpp) with unit tests in test_sync_gate.cpp. - No new signal: the predicate is a level, and the wake-up a gated consumer needs already exists as the channels::irreversible_block appbase channel - a LIB advance is the only event that can turn the predicate true, and channel deliveries are posted to the application executor, so callbacks run on the main thread after the triggering block fully commits (the post-commit contract the underwriter previously re-implemented with its inner post). - underwriter_plugin: the gate subscription moves from the raw controller accepted_block signal to the channel; the private predicate, sync_recency_ms, and the inner re-post are deleted. - batch_operator_plugin: the startup body (outpost discovery, cron_service sizing/creation, epoch_tick, relay jobs) moves verbatim into impl::run_deferred_startup, gated the same way - the boot-window read errors disappear and cron sizing sees the real outpost count instead of always hitting the MIN_CRON_THREADS floor. - Single gated path, no already-synced fast path: operator daemons boot with genesis-stale LIB in every deployment topology (they are never co-hosted with a producer); a node that somehow is synced at startup is released by the next LIB advance. - Uniform fail-fast: a terminal deferred-startup failure logs and quits the node in both daemons - an operator daemon that is not performing its duties must be supervisor-visible, not hidden behind a running process. The underwriter's bounded preflight retry grace still absorbs transient failures; the escape_policy split (abort vs contain, previously selected implicitly by restart timing) is deleted, with underwriter_detail::is_terminal_failure classifying the terminal states in one place. - Both daemons warn when read-mode != irreversible - the gate and their local table reads serve the irreversible state.
…de in operator daemons - controller::is_synced(now, recency_window) + wall-clock is_synced(): the LIB-recency predicate moves from chain_plugin (sync_gate.hpp, deleted) onto the controller, beside the fork_db accessors it reads - thread-safe like them. default_sync_recency_ms becomes a controller class constant; chain_plugin::is_synced() is deleted and consumers call chain().is_synced(). - Tests move to unittests/controller_sync_tests.cpp and now drive the real controller code path: the boundary cases feed is_synced(now, window) instants offset from the tester chain's actual last-irreversible block time, so every comparison is deterministic without a wall clock. - read-mode = irreversible is now REQUIRED by batch_operator_plugin and underwriter_plugin (FC_ASSERT at plugin_initialize when the plugin is enabled) instead of warned about: the sync gate measures LIB recency and every local table read the daemons perform serves the irreversible view; any other read mode would act on state that can still fork out.
…n is_synced doc The channel handle's destructor already unsubscribes at plugin destruction, posted channel deliveries are drained without executing between shutdown_plugins() and destroy_plugins(), and the gate slots check shutting_down first - so the explicit unsubscribe() calls in plugin_shutdown were dead weight (net_plugin's transaction_ack handle follows the same convention). The slot-side one-shot unsubscribe stays.
…ivity Unify the gate-slot capture on the impl pointer, simplify the read-mode assert message to producer_plugin's precedent (dropping batch_operator's now-unused magic_enum include), replace the -1 sentinel in the waiting log with n/a, and document the bad_alloc passthrough on batch_operator's contained startup path. The "never co-hosted with a producer" comments are now statements of configuration fact rather than topology convention: these daemons require read-mode = irreversible and producer_plugin rejects producer-names under irreversible read-mode, so the comments cite that enforcement.
This was referenced Jul 16, 2026
…startup A LIB advance is both the gate's wake-up and the daemons' work supply: everything the operator daemons act on is newly-finalized state, so a finality stall that spans startup (the classic lost-wakeup case) is one with nothing to relay or underwrite - the first finalized block re-arms the gate and creates the first actionable state at the same instant.
huangminghuang
approved these changes
Jul 16, 2026
huangminghuang
left a comment
Contributor
There was a problem hiding this comment.
Re-reviewed at f17feb8. The sync-gate restart race is a narrow, self-recovering operational edge case; the documented tradeoff is acceptable and non-blocking. No blocking findings.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Replaces #520 — same goal, reduced mechanism.
Problem
Every test cluster's boot window logged error-class lines from batch_operator (Account/Contract Query Exception 3060002/3060003): plugin_startup eagerly read
sysio.chainsbefore a cold-booting operator node's local replay reached the deploy blocks. The underwriter already solved this privately with anaccepted_block-signal gate plus an inner executor re-post.Change
controller::is_synced(now, recency_window)plus a wall-clockis_synced(): the LIB-recency predicate (fork_db root block time withincontroller::default_sync_recency_msof now), computed on demand, never cached. Lives beside the fork_db accessors it reads and is thread-safe like them. LIB rather than head deliberately: operator daemons run read-mode = irreversible, so the irreversible state is what their table reads serve.channels::irreversible_blockappbase channel — a LIB advance is the only event that can turn the predicate true, and channel deliveries are posted to the application executor, so callbacks run on the main thread after the triggering block fully commits (the post-commit contract the underwriter previously re-implemented with its inner post). Steady-state cost is zero: the daemons are subscribed only while gated, andchannel::publishskips posting with no subscribers.accepted_blocksignal to the channel; the private predicate,sync_recency_ms, and the inner re-post are deleted. Single gated path — there is deliberately no already-synced fast path: operator daemons boot with genesis-stale LIB in every topology, and a node that somehow is synced at startup is released by the next LIB advance.impl::run_deferred_startupbehind the same gate — the boot-window read errors disappear, and cron sizing sees the real outpost count instead of always hitting theMIN_CRON_THREADSfloor.underwriter_detail::is_terminal_failureclassifying the terminal states in one place.Behavior deltas vs #520
preflight_failed/wiring_failedgate payloads over/v1/underwriter/*indefinitely; that surface now exists during the retry grace and the shutdown window only. Monitoring that polls a failed-but-alive underwriter will see process exit instead.Considered and deliberately not replaced
Other in-sync checks in the tree measure different things and stay as they are: producer_plugin's max-irreversible-block-age production gate (needs the raw age for its log/API surface and deliberately treats a snapshot-started producer as synced so it can revive a chain),
block_state::is_recent's 30s head-recency vote gate (gating votes on LIB recency would deadlock finality recovery — votes are what create finality), producer_plugin's stale-production wake (head-based so producers can revive a stale chain), net_plugin's peer-relative sync_manager, and the vote-recency production pause.Tests
unittests/controller_sync_tests.cpp: the boundary cases driveis_synced(now, window)with instants offset from the tester chain's actual last-irreversible block time (window edge, past-window, zero window, clock-skew-ahead, replay gap), so every comparison exercises the real controller path deterministically; wall-clock cases cover the convenience overload. The underwriter's migrated predicate cases were removed with its private predicate; its gate-payload cases remain, plus a pinning test foris_terminal_failure.