From c11f9816e6f403327296d94013047d1aae93addd Mon Sep 17 00:00:00 2001 From: Randall Date: Tue, 8 Sep 2026 18:53:52 -0700 Subject: [PATCH 1/2] companion: wait for relay sync by progress instead of a fixed 30s deadline Long epochs mean long bootstrap chains. The literal 30-second handover deadline cut the relay off mid-chain above roughly 15,000 blocks, so the producer restarted with a tip outside its own forecast horizon. - replace the fixed deadline with RelaySyncWaiter, which keeps waiting while the relay tip advances and gives up only when the tip stalls - add yano.relay.sync.stall.timeout.seconds (default 30, the old deadline) and yano.relay.sync.max.wait.seconds (default 0, no ceiling) - print relay sync progress every five seconds during long backfills - document the wait in the node modes page and the CLI properties file --- .../cli/config/application.properties | 7 + .../localcluster/yano/RelaySyncWaiter.java | 134 +++++++++++++++++ .../yano/YanoCompanionService.java | 68 ++++----- .../yano/RelaySyncWaiterTest.java | 140 ++++++++++++++++++ docs/pages/yano-node-modes.mdx | 19 +++ 5 files changed, 334 insertions(+), 34 deletions(-) create mode 100644 applications/cli/src/main/java/com/bloxbean/cardano/yacicli/localcluster/yano/RelaySyncWaiter.java create mode 100644 applications/cli/src/test/java/com/bloxbean/cardano/yacicli/localcluster/yano/RelaySyncWaiterTest.java diff --git a/applications/cli/config/application.properties b/applications/cli/config/application.properties index 3c7dffb6..00d18171 100644 --- a/applications/cli/config/application.properties +++ b/applications/cli/config/application.properties @@ -28,6 +28,13 @@ bp.create.enabled=true #yano.server.port=14447 #yano.http.port=6060 +## Companion mode: how long the handover waits for the Haskell relay to copy Yano's bootstrap chain. +## The wait continues while the relay tip keeps advancing. It gives up when the tip has not moved for +## the stall timeout, or when the optional overall ceiling is reached (0 = no ceiling). +## A long epoch means a long backfill (roughly 550 blocks/s), so a fixed deadline would cut it off. +#yano.relay.sync.stall.timeout.seconds=30 +#yano.relay.sync.max.wait.seconds=0 + ###################################################### #To configure an external database for Yaci Store (Indexer), diff --git a/applications/cli/src/main/java/com/bloxbean/cardano/yacicli/localcluster/yano/RelaySyncWaiter.java b/applications/cli/src/main/java/com/bloxbean/cardano/yacicli/localcluster/yano/RelaySyncWaiter.java new file mode 100644 index 00000000..477f0eba --- /dev/null +++ b/applications/cli/src/main/java/com/bloxbean/cardano/yacicli/localcluster/yano/RelaySyncWaiter.java @@ -0,0 +1,134 @@ +package com.bloxbean.cardano.yacicli.localcluster.yano; + +import com.bloxbean.cardano.yaci.core.protocol.chainsync.messages.Point; +import com.bloxbean.cardano.yacicli.common.Tuple; + +import java.util.function.Consumer; +import java.util.function.LongSupplier; + +/** + * Waits for the Haskell relay to copy Yano's bootstrap chain during the companion handover. + *

+ * The wait is progress-based. It keeps polling while the relay's tip is still advancing and gives up + * only when the tip has not moved for {@code stallTimeoutMs}, or when the optional overall ceiling + * {@code maxWaitMs} is reached ({@code 0} = no ceiling). A fixed deadline cut the relay off mid-chain + * on long epochs: Yano was stopped, and the producer restarted with a tip already outside its own + * forecast horizon, so the chain was frozen from its first minute. + */ +final class RelaySyncWaiter { + static final long POLL_INTERVAL_MS = 1_000L; + static final long PROGRESS_REPORT_INTERVAL_MS = 5_000L; + + /** Reads the relay's tip: block height and point. Returns {@code null} when the relay is not reachable yet. */ + interface TipReader { + Tuple readTip(); + } + + /** Sleeps for the given number of milliseconds. */ + interface Sleeper { + void sleep(long millis) throws InterruptedException; + } + + /** + * Result of one wait. + * + * @param synced true when the relay's tip reached the target epoch + * @param epoch epoch of the last tip seen, or -1 when no tip was ever read + * @param slot slot of the last tip seen, or -1 + * @param height block height of the last tip seen, or -1 + * @param reason why the wait ended when {@code synced} is false; {@code null} otherwise + */ + record Outcome(boolean synced, long epoch, long slot, long height, String reason) { + } + + private final long stallTimeoutMs; + private final long maxWaitMs; + private final LongSupplier clock; + private final Sleeper sleeper; + + RelaySyncWaiter(long stallTimeoutMs, long maxWaitMs) { + this(stallTimeoutMs, maxWaitMs, System::currentTimeMillis, Thread::sleep); + } + + RelaySyncWaiter(long stallTimeoutMs, long maxWaitMs, LongSupplier clock, Sleeper sleeper) { + if (stallTimeoutMs <= 0) { + throw new IllegalArgumentException("stallTimeoutMs must be positive, got " + stallTimeoutMs); + } + if (maxWaitMs < 0) { + throw new IllegalArgumentException("maxWaitMs must be zero (no ceiling) or positive, got " + maxWaitMs); + } + this.stallTimeoutMs = stallTimeoutMs; + this.maxWaitMs = maxWaitMs; + this.clock = clock; + this.sleeper = sleeper; + } + + /** + * Poll the relay's tip until it reaches {@code targetEpoch}, the tip stalls, or the ceiling is hit. + * + * @param targetEpoch epoch the relay must reach (tip slot / epochLength >= targetEpoch) + * @param epochLength slots per epoch; must be positive + * @param tipReader reads the relay's current tip + * @param progress receives a progress line about every {@link #PROGRESS_REPORT_INTERVAL_MS} + */ + Outcome await(long targetEpoch, long epochLength, TipReader tipReader, Consumer progress) + throws InterruptedException { + if (epochLength <= 0) { + return new Outcome(false, -1, -1, -1, "epoch length is not known"); + } + + final long startedAt = clock.getAsLong(); + long lastProgressAt = startedAt; + long lastReportAt = startedAt; + long lastReportHeight = -1; + long slot = -1; + long height = -1; + long epoch = -1; + + while (true) { + Tuple tip = tipReader.readTip(); + long now = clock.getAsLong(); + + if (tip != null && tip._2 != null) { + long tipSlot = tip._2.getSlot(); + long tipHeight = tip._1 != null ? tip._1 : -1; + if (tipSlot > slot || tipHeight > height) { + lastProgressAt = now; + } + slot = tipSlot; + height = tipHeight; + epoch = slot / epochLength; + + if (epoch >= targetEpoch) { + return new Outcome(true, epoch, slot, height, null); + } + + if (now - lastReportAt >= PROGRESS_REPORT_INTERVAL_MS) { + long elapsedMs = Math.max(1, now - lastReportAt); + long blocksSinceReport = lastReportHeight >= 0 && height >= 0 ? height - lastReportHeight : 0; + long blocksPerSecond = blocksSinceReport * 1000 / elapsedMs; + progress.accept(String.format( + "Relay sync in progress: epoch %d of %d, slot %d, height %d (%d blocks/s)", + epoch, targetEpoch, slot, height, blocksPerSecond)); + lastReportAt = now; + lastReportHeight = height; + } + } + + long sinceProgress = now - lastProgressAt; + if (sinceProgress >= stallTimeoutMs) { + String where = slot >= 0 ? " at slot " + slot + " (epoch " + epoch + ")" : " before any tip was read"; + return new Outcome(false, epoch, slot, height, + "relay tip did not advance for " + (sinceProgress / 1000) + "s" + where); + } + if (maxWaitMs > 0 && now - startedAt >= maxWaitMs) { + return new Outcome(false, epoch, slot, height, + "overall wait ceiling of " + (maxWaitMs / 1000) + "s reached"); + } + + long untilStall = stallTimeoutMs - sinceProgress; + long untilCeiling = maxWaitMs > 0 ? maxWaitMs - (now - startedAt) : Long.MAX_VALUE; + sleeper.sleep(Math.max(1, Math.min(POLL_INTERVAL_MS, Math.min(untilStall, untilCeiling)))); + } + } +} diff --git a/applications/cli/src/main/java/com/bloxbean/cardano/yacicli/localcluster/yano/YanoCompanionService.java b/applications/cli/src/main/java/com/bloxbean/cardano/yacicli/localcluster/yano/YanoCompanionService.java index db5fd92a..9d0910d6 100644 --- a/applications/cli/src/main/java/com/bloxbean/cardano/yacicli/localcluster/yano/YanoCompanionService.java +++ b/applications/cli/src/main/java/com/bloxbean/cardano/yacicli/localcluster/yano/YanoCompanionService.java @@ -16,6 +16,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import com.bloxbean.cardano.client.crypto.Blake2bUtil; @@ -58,6 +59,14 @@ public class YanoCompanionService { private final ClusterInfoService clusterInfoService; private final ObjectMapper objectMapper = new ObjectMapper(); + // Relay sync wait during handover. Stall timeout: give up when the relay tip stops advancing for this + // long. Max wait: optional overall ceiling, 0 = none. Both in seconds. + @Value("${yano.relay.sync.stall.timeout.seconds:30}") + private long relaySyncStallTimeoutSeconds = 30; + + @Value("${yano.relay.sync.max.wait.seconds:0}") + private long relaySyncMaxWaitSeconds = 0; + /** * Run the full companion bootstrap sequence before the Haskell node starts. * Returns true if bootstrap succeeded and Haskell node should proceed. @@ -253,43 +262,34 @@ public void performHandover(ClusterInfo clusterInfo, Path clusterFolder, Consume Thread.sleep(1000); } - // Wait for Haskell relay to sync past Yano's shifted bootstrap region (~3 epochs). - // Poll the relay's tip every ~1s and exit early once epoch >= BOOTSTRAP_EPOCH_SHIFT. - // Soft 30s deadline — getTip itself can block up to ~10s on a stuck socket, so - // wall-clock worst case is ~40s; on healthy runs this exits in ~10-20s. + // Wait for the Haskell relay to sync past Yano's shifted bootstrap region (~3 epochs). + // The wait is progress-based: it continues while the relay tip keeps advancing and gives up + // only when the tip stalls for yano.relay.sync.stall.timeout.seconds (default 30s) or the + // optional ceiling yano.relay.sync.max.wait.seconds is reached. A long epoch means a long + // backfill (about 550 blocks/s measured), so a fixed deadline would cut the relay off mid-chain. long epochLength = clusterInfo.getEpochLength(); - writer.accept(info("Waiting up to 30s for relay to sync past epoch %d...", BOOTSTRAP_EPOCH_SHIFT)); + String ceiling = relaySyncMaxWaitSeconds > 0 ? ", at most " + relaySyncMaxWaitSeconds + "s in total" : ""; + writer.accept(info("Waiting for relay to sync past epoch %d (giving up if the tip stalls for %ds%s)...", + BOOTSTRAP_EPOCH_SHIFT, relaySyncStallTimeoutSeconds, ceiling)); - final long deadlineMs = System.currentTimeMillis() + 30_000L; final ClusterUtilService clusterUtilService = clusterUtilServiceProvider.getObject(); - boolean synced = false; - - while (true) { - long remaining = deadlineMs - System.currentTimeMillis(); - if (remaining <= 0) break; - - // ClusterUtilService.getTip prints "Find tip error ..." directly via static - // ConsoleWriter on exception (bypassing the consumer); transient noise during - // the first one or two polls after the socket appears is expected. - Tuple tip = clusterUtilService.getTip(msg -> {}); - if (tip != null && tip._2 != null && epochLength > 0) { - long slot = tip._2.getSlot(); - long epoch = slot / epochLength; - if (epoch >= BOOTSTRAP_EPOCH_SHIFT) { - writer.accept(success("Relay synced to epoch " + epoch - + " (slot " + slot + ", height " + tip._1 + ")")); - synced = true; - break; - } - } - - remaining = deadlineMs - System.currentTimeMillis(); - if (remaining <= 0) break; - Thread.sleep(Math.min(1000L, remaining)); - } - if (!synced) { - writer.accept(warn("Relay sync did not reach epoch " - + BOOTSTRAP_EPOCH_SHIFT + " within 30s. Proceeding anyway.")); + // ClusterUtilService.getTip prints "Find tip error ..." directly via static + // ConsoleWriter on exception (bypassing the consumer); transient noise during + // the first one or two polls after the socket appears is expected. + RelaySyncWaiter waiter = new RelaySyncWaiter(relaySyncStallTimeoutSeconds * 1000L, + relaySyncMaxWaitSeconds * 1000L); + RelaySyncWaiter.Outcome outcome = waiter.await(BOOTSTRAP_EPOCH_SHIFT, epochLength, + () -> clusterUtilService.getTip(msg -> {}), + msg -> writer.accept(info(msg))); + + if (outcome.synced()) { + writer.accept(success("Relay synced to epoch " + outcome.epoch() + + " (slot " + outcome.slot() + ", height " + outcome.height() + ")")); + } else { + writer.accept(warn("Relay sync did not reach epoch " + BOOTSTRAP_EPOCH_SHIFT + + ": " + outcome.reason() + ". Proceeding anyway.")); + writer.accept(warn("If the Haskell node forges no blocks after the restart, its tip is outside " + + "its forecast horizon; recreate the devnet, or raise yano.relay.sync.stall.timeout.seconds.")); } // Stop Yano — relay has synced the chain to its db diff --git a/applications/cli/src/test/java/com/bloxbean/cardano/yacicli/localcluster/yano/RelaySyncWaiterTest.java b/applications/cli/src/test/java/com/bloxbean/cardano/yacicli/localcluster/yano/RelaySyncWaiterTest.java new file mode 100644 index 00000000..2af8db1c --- /dev/null +++ b/applications/cli/src/test/java/com/bloxbean/cardano/yacicli/localcluster/yano/RelaySyncWaiterTest.java @@ -0,0 +1,140 @@ +package com.bloxbean.cardano.yacicli.localcluster.yano; + +import com.bloxbean.cardano.yaci.core.protocol.chainsync.messages.Point; +import com.bloxbean.cardano.yacicli.common.Tuple; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class RelaySyncWaiterTest { + private static final long EPOCH_LENGTH = 600; + private static final long TARGET_EPOCH = 3; + + /** Fake clock: sleeping advances time, and each tip read costs a little time too. */ + private static final class FakeTime { + long now = 1_000_000L; + final List sleeps = new ArrayList<>(); + + long read() { + return now; + } + + void sleep(long millis) { + sleeps.add(millis); + now += millis; + } + } + + private static Tuple tip(long height, long slot) { + return new Tuple<>(height, new Point(slot, "00")); + } + + /** Replays scripted tips in order; the last one repeats forever. */ + private static RelaySyncWaiter.TipReader script(List> tips) { + Iterator> it = tips.iterator(); + Tuple[] last = new Tuple[1]; + return () -> { + if (it.hasNext()) { + last[0] = it.next(); + } + return last[0]; + }; + } + + @Test + void returnsSyncedAsSoonAsTipReachesTargetEpoch() throws Exception { + FakeTime time = new FakeTime(); + RelaySyncWaiter waiter = new RelaySyncWaiter(30_000, 0, time::read, time::sleep); + List progress = new ArrayList<>(); + + RelaySyncWaiter.Outcome outcome = waiter.await(TARGET_EPOCH, EPOCH_LENGTH, + script(List.of(tip(10, 10), tip(700, 700), tip(1900, 1900))), progress::add); + + assertThat(outcome.synced()).isTrue(); + assertThat(outcome.epoch()).isEqualTo(3); + assertThat(outcome.slot()).isEqualTo(1900); + assertThat(outcome.height()).isEqualTo(1900); + assertThat(outcome.reason()).isNull(); + assertThat(time.sleeps).hasSize(2); + } + + @Test + void keepsWaitingBeyondStallTimeoutWhileTipAdvances() throws Exception { + FakeTime time = new FakeTime(); + // 30s stall timeout, but the relay needs 120 polls (2 minutes) to reach the target. + RelaySyncWaiter waiter = new RelaySyncWaiter(30_000, 0, time::read, time::sleep); + List> tips = new ArrayList<>(); + for (long i = 1; i <= 120; i++) { + tips.add(tip(i * 15, i * 15)); + } + List progress = new ArrayList<>(); + + RelaySyncWaiter.Outcome outcome = waiter.await(TARGET_EPOCH, EPOCH_LENGTH, script(tips), progress::add); + + assertThat(outcome.synced()).isTrue(); + assertThat(outcome.slot()).isEqualTo(1800); + assertThat(time.now - 1_000_000L).isGreaterThan(100_000L); + assertThat(progress).isNotEmpty(); + assertThat(progress.get(0)).startsWith("Relay sync in progress: epoch 0 of 3"); + assertThat(progress.get(0)).contains("blocks/s"); + } + + @Test + void givesUpWhenTipStopsAdvancing() throws Exception { + FakeTime time = new FakeTime(); + RelaySyncWaiter waiter = new RelaySyncWaiter(30_000, 0, time::read, time::sleep); + + RelaySyncWaiter.Outcome outcome = waiter.await(TARGET_EPOCH, EPOCH_LENGTH, + script(List.of(tip(100, 100), tip(200, 200))), msg -> {}); + + assertThat(outcome.synced()).isFalse(); + assertThat(outcome.slot()).isEqualTo(200); + assertThat(outcome.reason()).contains("did not advance for 30s").contains("slot 200"); + // one poll with progress, then 30s of stalled polls + assertThat(time.now - 1_000_000L).isEqualTo(31_000L); + } + + @Test + void givesUpWhenNoTipIsEverRead() throws Exception { + FakeTime time = new FakeTime(); + RelaySyncWaiter waiter = new RelaySyncWaiter(5_000, 0, time::read, time::sleep); + + RelaySyncWaiter.Outcome outcome = waiter.await(TARGET_EPOCH, EPOCH_LENGTH, () -> null, msg -> {}); + + assertThat(outcome.synced()).isFalse(); + assertThat(outcome.slot()).isEqualTo(-1); + assertThat(outcome.reason()).contains("before any tip was read"); + } + + @Test + void ceilingEndsTheWaitEvenWhileTipAdvances() throws Exception { + FakeTime time = new FakeTime(); + RelaySyncWaiter waiter = new RelaySyncWaiter(30_000, 10_000, time::read, time::sleep); + List> tips = new ArrayList<>(); + for (long i = 1; i <= 100; i++) { + tips.add(tip(i, i)); + } + + RelaySyncWaiter.Outcome outcome = waiter.await(TARGET_EPOCH, EPOCH_LENGTH, script(tips), msg -> {}); + + assertThat(outcome.synced()).isFalse(); + assertThat(outcome.reason()).contains("ceiling of 10s"); + assertThat(time.now - 1_000_000L).isEqualTo(10_000L); + } + + @Test + void unknownEpochLengthCannotBeWaitedFor() throws Exception { + FakeTime time = new FakeTime(); + RelaySyncWaiter waiter = new RelaySyncWaiter(30_000, 0, time::read, time::sleep); + + RelaySyncWaiter.Outcome outcome = waiter.await(TARGET_EPOCH, 0, () -> tip(1, 1), msg -> {}); + + assertThat(outcome.synced()).isFalse(); + assertThat(outcome.reason()).contains("epoch length"); + assertThat(time.sleeps).isEmpty(); + } +} diff --git a/docs/pages/yano-node-modes.mdx b/docs/pages/yano-node-modes.mdx index ea3e009d..0d4a5a31 100644 --- a/docs/pages/yano-node-modes.mdx +++ b/docs/pages/yano-node-modes.mdx @@ -38,6 +38,25 @@ This avoids requiring users to wait for two epoch transitions before updated pro Use companion mode when you want normal Haskell-node behavior, including node socket access, but want DevKit to prepare the chain quickly for PV11 protocol parameters. +### Relay sync wait during handover + +After Yano catches up to wall-clock time, the Haskell node starts as a relay and copies Yano's chain before it +takes over as block producer. The handover waits while the relay's tip keeps advancing. It gives up when the tip +has not moved for the stall timeout, or when the optional overall ceiling is reached. The wait is not a fixed +deadline: a long epoch means a long bootstrap chain (about 550 blocks per second are copied), and the producer +must own the whole chain before Yano stops. + +The two settings live in `config/application.properties` (the CLI's own properties, not `node.properties`): + +```properties +# Give up when the relay tip stops advancing for this long. Default 30. +yano.relay.sync.stall.timeout.seconds=30 +# Optional overall ceiling for the wait. Default 0 = no ceiling. +yano.relay.sync.max.wait.seconds=0 +``` + +Progress is printed about every five seconds while the relay is syncing. + ## Yano-only Mode In `yano-only` mode, Yano runs as the devnet node. There is no Haskell node handover. From f7cd3fcfc3732fab25ae840a38465e2956151d3d Mon Sep 17 00:00:00 2001 From: Satya Date: Fri, 25 Sep 2026 18:52:08 +0800 Subject: [PATCH 2/2] companion: fall back to defaults for unusable relay sync wait settings A stall timeout of zero or less, or a negative ceiling, made the RelaySyncWaiter constructor throw inside performHandover. The handover then skipped stopping Yano and restoring the topology, and the Haskell producer restarted next to a Yano that was still producing. Both values are now sanitized with a warning: the stall timeout falls back to 30s, a negative ceiling means no ceiling, and oversized values are capped so the millisecond conversion cannot overflow. The first progress line also measures its rate from the first tip read instead of always reporting 0 blocks/s. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../localcluster/yano/RelaySyncWaiter.java | 19 ++++++++ .../yano/YanoCompanionService.java | 19 ++++++-- .../yano/RelaySyncWaiterTest.java | 45 +++++++++++++++++++ 3 files changed, 79 insertions(+), 4 deletions(-) diff --git a/applications/cli/src/main/java/com/bloxbean/cardano/yacicli/localcluster/yano/RelaySyncWaiter.java b/applications/cli/src/main/java/com/bloxbean/cardano/yacicli/localcluster/yano/RelaySyncWaiter.java index 477f0eba..7fefd49c 100644 --- a/applications/cli/src/main/java/com/bloxbean/cardano/yacicli/localcluster/yano/RelaySyncWaiter.java +++ b/applications/cli/src/main/java/com/bloxbean/cardano/yacicli/localcluster/yano/RelaySyncWaiter.java @@ -18,6 +18,19 @@ final class RelaySyncWaiter { static final long POLL_INTERVAL_MS = 1_000L; static final long PROGRESS_REPORT_INTERVAL_MS = 5_000L; + static final long DEFAULT_STALL_TIMEOUT_SECONDS = 30; + // Largest value in seconds that still converts to milliseconds without overflowing + private static final long MAX_SECONDS = Long.MAX_VALUE / 1000; + + /** Stall timeout to use for a configured value: a positive value as given, anything else the default. */ + static long stallTimeoutSeconds(long configured) { + return configured > 0 ? Math.min(configured, MAX_SECONDS) : DEFAULT_STALL_TIMEOUT_SECONDS; + } + + /** Overall ceiling to use for a configured value: zero (no ceiling) or a positive value as given, negative is none. */ + static long maxWaitSeconds(long configured) { + return Math.min(Math.max(0, configured), MAX_SECONDS); + } /** Reads the relay's tip: block height and point. Returns {@code null} when the relay is not reachable yet. */ interface TipReader { @@ -99,6 +112,12 @@ Outcome await(long targetEpoch, long epochLength, TipReader tipReader, Consumer< height = tipHeight; epoch = slot / epochLength; + if (lastReportHeight < 0 && height >= 0) { + // First tip read: the baseline the first reported rate is measured from + lastReportHeight = height; + lastReportAt = now; + } + if (epoch >= targetEpoch) { return new Outcome(true, epoch, slot, height, null); } diff --git a/applications/cli/src/main/java/com/bloxbean/cardano/yacicli/localcluster/yano/YanoCompanionService.java b/applications/cli/src/main/java/com/bloxbean/cardano/yacicli/localcluster/yano/YanoCompanionService.java index 9d0910d6..4dc5e92f 100644 --- a/applications/cli/src/main/java/com/bloxbean/cardano/yacicli/localcluster/yano/YanoCompanionService.java +++ b/applications/cli/src/main/java/com/bloxbean/cardano/yacicli/localcluster/yano/YanoCompanionService.java @@ -268,16 +268,27 @@ public void performHandover(ClusterInfo clusterInfo, Path clusterFolder, Consume // optional ceiling yano.relay.sync.max.wait.seconds is reached. A long epoch means a long // backfill (about 550 blocks/s measured), so a fixed deadline would cut the relay off mid-chain. long epochLength = clusterInfo.getEpochLength(); - String ceiling = relaySyncMaxWaitSeconds > 0 ? ", at most " + relaySyncMaxWaitSeconds + "s in total" : ""; + + // An unusable value must not throw here: the handover would then skip stopping Yano and restoring the + // topology, and the Haskell producer would restart next to a Yano that is still producing. + long stallTimeoutSeconds = RelaySyncWaiter.stallTimeoutSeconds(relaySyncStallTimeoutSeconds); + if (stallTimeoutSeconds != relaySyncStallTimeoutSeconds) + writer.accept(warn("yano.relay.sync.stall.timeout.seconds must be positive, got %d. Using %d.", + relaySyncStallTimeoutSeconds, stallTimeoutSeconds)); + long maxWaitSeconds = RelaySyncWaiter.maxWaitSeconds(relaySyncMaxWaitSeconds); + if (maxWaitSeconds != relaySyncMaxWaitSeconds) + writer.accept(warn("yano.relay.sync.max.wait.seconds must be 0 (no ceiling) or positive, got %d. Using %d.", + relaySyncMaxWaitSeconds, maxWaitSeconds)); + + String ceiling = maxWaitSeconds > 0 ? ", at most " + maxWaitSeconds + "s in total" : ""; writer.accept(info("Waiting for relay to sync past epoch %d (giving up if the tip stalls for %ds%s)...", - BOOTSTRAP_EPOCH_SHIFT, relaySyncStallTimeoutSeconds, ceiling)); + BOOTSTRAP_EPOCH_SHIFT, stallTimeoutSeconds, ceiling)); final ClusterUtilService clusterUtilService = clusterUtilServiceProvider.getObject(); // ClusterUtilService.getTip prints "Find tip error ..." directly via static // ConsoleWriter on exception (bypassing the consumer); transient noise during // the first one or two polls after the socket appears is expected. - RelaySyncWaiter waiter = new RelaySyncWaiter(relaySyncStallTimeoutSeconds * 1000L, - relaySyncMaxWaitSeconds * 1000L); + RelaySyncWaiter waiter = new RelaySyncWaiter(stallTimeoutSeconds * 1000L, maxWaitSeconds * 1000L); RelaySyncWaiter.Outcome outcome = waiter.await(BOOTSTRAP_EPOCH_SHIFT, epochLength, () -> clusterUtilService.getTip(msg -> {}), msg -> writer.accept(info(msg))); diff --git a/applications/cli/src/test/java/com/bloxbean/cardano/yacicli/localcluster/yano/RelaySyncWaiterTest.java b/applications/cli/src/test/java/com/bloxbean/cardano/yacicli/localcluster/yano/RelaySyncWaiterTest.java index 2af8db1c..71ec9122 100644 --- a/applications/cli/src/test/java/com/bloxbean/cardano/yacicli/localcluster/yano/RelaySyncWaiterTest.java +++ b/applications/cli/src/test/java/com/bloxbean/cardano/yacicli/localcluster/yano/RelaySyncWaiterTest.java @@ -137,4 +137,49 @@ void unknownEpochLengthCannotBeWaitedFor() throws Exception { assertThat(outcome.reason()).contains("epoch length"); assertThat(time.sleeps).isEmpty(); } + + @Test + void firstProgressReportMeasuresTheRateFromTheFirstTip() throws Exception { + FakeTime time = new FakeTime(); + RelaySyncWaiter waiter = new RelaySyncWaiter(30_000, 0, time::read, time::sleep); + // One poll per second, 100 blocks further each time + List> tips = new ArrayList<>(); + for (long i = 1; i <= 20; i++) { + tips.add(tip(i * 100, i * 100)); + } + List progress = new ArrayList<>(); + + waiter.await(TARGET_EPOCH, EPOCH_LENGTH, script(tips), progress::add); + + assertThat(progress).isNotEmpty(); + assertThat(progress).allSatisfy(line -> assertThat(line).endsWith("(100 blocks/s)")); + } + + @Test + void unusableStallTimeoutFallsBackToTheDefault() { + assertThat(RelaySyncWaiter.stallTimeoutSeconds(45)).isEqualTo(45); + assertThat(RelaySyncWaiter.stallTimeoutSeconds(0)).isEqualTo(RelaySyncWaiter.DEFAULT_STALL_TIMEOUT_SECONDS); + assertThat(RelaySyncWaiter.stallTimeoutSeconds(-5)).isEqualTo(RelaySyncWaiter.DEFAULT_STALL_TIMEOUT_SECONDS); + // Converts to milliseconds without overflowing into a negative timeout + assertThat(RelaySyncWaiter.stallTimeoutSeconds(Long.MAX_VALUE) * 1000L).isPositive(); + } + + @Test + void negativeMaxWaitMeansNoCeiling() { + assertThat(RelaySyncWaiter.maxWaitSeconds(600)).isEqualTo(600); + assertThat(RelaySyncWaiter.maxWaitSeconds(0)).isZero(); + assertThat(RelaySyncWaiter.maxWaitSeconds(-1)).isZero(); + assertThat(RelaySyncWaiter.maxWaitSeconds(Long.MAX_VALUE) * 1000L).isPositive(); + } + + @Test + void sanitizedSettingsAlwaysBuildAWaiter() { + // The handover builds the waiter from sanitized values, so a bad setting can no longer throw there + for (long stall : new long[]{-1, 0, 1, 30, Long.MAX_VALUE}) { + for (long max : new long[]{-1, 0, 60, Long.MAX_VALUE}) { + new RelaySyncWaiter(RelaySyncWaiter.stallTimeoutSeconds(stall) * 1000L, + RelaySyncWaiter.maxWaitSeconds(max) * 1000L); + } + } + } }