Skip to content

Chain: gate operator-daemon startup on controller::is_synced()#522

Merged
heifner merged 5 commits into
masterfrom
fix/chain-plugin-sync-gate-channel
Jul 16, 2026
Merged

Chain: gate operator-daemon startup on controller::is_synced()#522
heifner merged 5 commits into
masterfrom
fix/chain-plugin-sync-gate-channel

Conversation

@heifner

@heifner heifner commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

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.chains before a cold-booting operator 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.

Change

  • controller::is_synced(now, recency_window) plus a wall-clock is_synced(): the LIB-recency predicate (fork_db root block time within controller::default_sync_recency_ms of 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.
  • 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). Steady-state cost is zero: the daemons are subscribed only while gated, and channel::publish skips posting with no subscribers.
  • 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. 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.
  • batch_operator_plugin: the startup body (outpost discovery, cron_service sizing/creation, epoch_tick, relay jobs) moves verbatim into impl::run_deferred_startup behind the same gate — the boot-window read errors disappear, and cron sizing sees the real outpost count instead of always hitting the MIN_CRON_THREADS floor.
  • 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 is supervisor-visible instead of hidden behind a running process. The underwriter's bounded preflight retry grace still absorbs transients; 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.
  • read-mode = irreversible is required by both daemons (FC_ASSERT at plugin_initialize when the plugin is enabled). Together with producer_plugin's inverse assert (no producer-name under irreversible read-mode), producer/daemon co-hosting is now impossible by configuration.

Behavior deltas vs #520

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 drive is_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 for is_terminal_failure.

heifner added 4 commits July 16, 2026 07:07
…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.
Comment thread plugins/batch_operator_plugin/src/batch_operator_plugin.cpp
…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.
@heifner
heifner requested a review from huangminghuang July 16, 2026 14:19

@huangminghuang huangminghuang left a comment

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.

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.

@heifner
heifner merged commit cdeaf6a into master Jul 16, 2026
13 checks passed
@heifner
heifner deleted the fix/chain-plugin-sync-gate-channel branch July 16, 2026 15:43
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.

2 participants