chain_plugin: first-class chain-sync gate — is_synced() + synced() signal; gate operator-daemon startup#520
chain_plugin: first-class chain-sync gate — is_synced() + synced() signal; gate operator-daemon startup#520jglanz wants to merge 1 commit into
Conversation
…gnal; gate operator-daemon startup Promote the underwriter's private "defer startup until the node is synced" machinery to a first-class chain_plugin API consumed by both operator-daemon plugins, eliminating the 3-11 boot-window `table read threw sysio.chains --- Account/Contract Query Exception (3060002/3060003)` errors every fresh operator node logged while replaying past the deploy blocks. - chain_plugin: new `is_synced(recency)` LIB-recency predicate and a `synced()` `boost::signals2::signal<void()>&` accessor (the codebase-standard signal idiom). The signal fires on each stale->synced transition, emitted from the executor queue AFTER the triggering block commits (never mid block-application, where table reads see an incomplete view), edge-detected in the existing accepted_block hook and guarded by is_replaying(). Pure building blocks live in the new sync_gate.hpp (block_time_is_recent, lib_time_is_recent, sync_transition), unit-testable without appbase. - underwriter_plugin: sheds its private copy (sync_recency_ms, block_time_is_recent, chain_is_synced, and the per-block re-check + inner executor post) and consumes chain_plug->synced(); behavior unchanged. - batch_operator_plugin: gains the gate. Its startup body (outpost discovery -> private cron_service -> epoch_tick -> relay jobs) moves into run_deferred_startup(), run inline when already synced (abort-on-escape) or from the synced() slot via a contained wrapper (an escape from a posted task would quit the node). Tests: new plugins/chain_plugin/test/test_sync_gate.cpp (predicate, edge-detector, and tester-backed lib_time_is_recent cases); the migrated predicate cases removed from test_underwriter_sync.cpp. Also regenerates stale reference data (deep-mind.log + consensus_blockchain/*) so `unit_test` is 1507/1507 -- pre-existing action-merkle/block-id drift vs the committed data, reproduced with this change stashed out; regenerated per wire-sysio/CLAUDE.md. Verified: all six unit binaries green (unit_test 1507/1507), full 13-flow suite 13/13 with boot-window 3060002/3060003 = 0 everywhere. Co-Authored-By: Claude <noreply@anthropic.com> Change-Id: Ic6cb59add9f6e2dc34d410386d6a9ef851fd549a
|
Superseded by #522 — same goal, reduced mechanism, incorporating the review discussion: the predicate lands as controller::is_synced() beside the fork_db accessors it reads, and the daemons gate on the existing irreversible_block channel instead of a new signal (channel deliveries are already posted post-commit, which was the signal's load-bearing contract). #522 also folds in the follow-ups from review: single gated path (no already-synced fast path — the daemons can never be synced at plugin_startup in our topology), uniform fail-fast on terminal deferred-startup failure, required read-mode = irreversible, and no reference-data regen (it is based past #519, which is what this branch conflicts with). The one-file follow-up stacked here (#521) is restacked onto #522. |
Problem
Every test cluster's boot window logged 3–11 FATAL-class lines per run:
Mechanism (forensically verified on preserved clusters): operator daemon nodes start ~2 min after the depot contracts deploy — the harness ordering is already correct — but each daemon node is a fresh nodeop replaying from genesis, and
batch_operator_plugin::plugin_startupeagerly readsysio.chains~0.7 s post-spawn, before the node's local replay reached the deploy blocks. One-shot per daemon, self-resolving at the first synced tick (+13–14 s), but every boot paid the error-log noise.The
underwriter_pluginalready solved this for itself with a private sync gate (defer startup until the last-irreversible block time is within 5 s of wall-clock, observed viaaccepted_block).Change
Promote that machinery to a first-class chain_plugin API, consumed by both operator-daemon plugins:
chain_plugin::is_synced(recency = 5s)— LIB-recency predicate (fork_db_root().block_time()withinrecencyof now). LIB, not head, deliberately: operator daemons runread-mode = irreversible, so this is the state their table reads actually serve.chain_plugin::synced()→boost::signals2::signal<void()>&— the codebase-standard signal shape (accessor returning the impl-owned signal, like the controller'saccepted_block()andbatch_operator_plugin::debugging_opp_envelope()). Contract:is_replaying()-guarded), not once per process — a once-per-process latch that fires with no consumer connected while the chain then stalls would leave a later subscriber waiting forever. One-shot consumers disconnect inside their slot.sync_gate.hpp(new,sysio::chain_apis) — the pure building blocks:default_sync_recency_ms,block_time_is_recent(moved verbatim from the underwriter'ssync_detail.hpp),lib_time_is_recent, and thesync_transitionedge detector. Header-only and unit-testable without appbase.Consumers (identical two-branch idiom —
is_synced()fast path inline, else connect):underwriter_pluginsheds its private copy (net-negative diff):sync_recency_ms,block_time_is_recent,chain_is_synced(), and the per-block re-check + inner executor post all deleted; the gate connection now targetschain_plug->synced(). Behavior unchanged.batch_operator_plugingains the gate: the startup body (outpost discovery → private cron_service sizing/creation → epoch_tick → relay jobs) moved verbatim intoimpl::run_deferred_startup(), run inline when already synced (escape aborts node startup, as today) or from thesynced()slot via a contained wrapper (an escape from a posted task would tear down the whole node). Bonus: cron thread sizing now sees the real outpost count instead of always hitting theMIN_CRON_THREADSfloor on cold boots.No harness (wire-tools-ts) changes — the phase ordering was already correct.
Tests
plugins/chain_plugin/test/test_sync_gate.cpp(sync_gate_tests): the 7 recency-predicate cases migrated fromtest_underwriter_sync.cpp, 4 puresync_transitionedge cases (initial-stale, fires-once, no-refire, re-arm), and 2 tester-backedlib_time_is_recentcases (stale genesis chain → false; block time jumped to now + finalized → true).test_underwriter_sync.cppkeeps its gate-payload cases; the migrated predicate cases were removed.Verification
Unit sweep (all green):
test_chain_plugin(incl.sync_gate_tests— 13 cases),test_underwriter_plugin,test_batch_operator_plugin,plugin_test,unit_test -- --sys-vm(1507/1507),test_fcReference-data regeneration (bundled).
deep_mind_testsandsavanna_misc_tests/verify_block_compatibititywere already failing on pristinemasterat this local build (action-merkle / block-id drift between the committed reference data and the current contract build — reproduced byte-identically with this change stashed out, so unrelated to the plugin change). Regenerated perwire-sysio/CLAUDE.md(--save-dmlog,--save-blockchain):unittests/deep-mind/deep-mind.log+unittests/test-data/consensus_blockchain/{blocks.log,id,snapshot}. With them,unit_testis 1507/1507.Live validation — full 13-flow suite against the rebuilt nodeop (fresh cluster per flow, canonical runner + heartbeat monitor, 4 concurrent): 13/13 SUCCEEDED (flow-operator-collateral-deposit, flow-batch-operator-termination, flow-batch-operator-slashing, flow-swap-{with-underwriting,to-wire,from-wire,variance-revert,non-native-tokens,private-reserves}, flow-reserve-lifecycle, flow-node-owner-nft, flow-emissions-soak, flow-yield-distribution). Every one: boot-window
3060002/3060003count = 0 (baselines 3–11), every operator daemon armed (waiting for chain sync) then released (chain synced — starting deferred startup).Boot-window assertions:
flow-operator-collateral-deposit(baseline fatal=4) andflow-batch-operator-termination(9-op, baseline fatal=9): heartbeat fatal=0 through the boot window; each batch daemon logswaiting for chain sync before outpost discovery→chain synced — starting deferred startup; one chain_pluginnode is syncedilog per node.flow-swap-with-underwriting: underwriter behavior unchanged (its slot now runs via chain_plugin's posted emission instead of its own inner post — cosmetic log-ordering delta only).Risks
shutting_down; connections reset in each consumer'splugin_shutdown, which runs before chain_plugin's.is_synced()fast path plus re-arming edge semantics (no dead-latch case).initial_timestamp ≈ now→ inline fast path, zero added latency.is_replaying()-guarded).Pre-existing signatures NOT addressed here (out of scope)
Two error-level log signatures appear in some flow runs; both are pre-existing and independent of this change (all 13 flows still exit 0):
msgch::bootstrap→3090004 missing authority of sysio.msgch.batch_operator_plugin::do_poll_epoch_statepushesmsgch::bootstrapat epoch 0 signed with the operator'sactiveauthority, but the action'srequire_auth(get_self())requiressysio.msgch's own authority — so the push fails unconditionally at the auth check (never reachingcheck(epoch == 0)), regardless of timing. The initial advance is driven by the system/harness (which holdssysio.msgch's keys), so it is benign; this gate does not change it. A follow-up removes the operator-side push (the advance is a system responsibility under the contract's authority model).MockYieldEmitter: externalEpochRef not monotonic. A single ETH-side revert inflow-yield-distribution's emission scenario — a wire-ethereum contract path, unrelated to depot plugin startup, non-fatal to the flow.🤖 Generated with Claude Code