Skip to content

Commit e57546b

Browse files
feat: startup offline-gap catch-up (store only at fresh start / long offline)
At startup the node estimates its offline gap from the persisted last-online timestamp: within the sync window it catches up via full-node reconciliation (retrying until a sync peer is found, with the periodic sync loop as safety net); beyond the window, or on fresh start, it falls back to a bounded store resume, waiting for a store peer before consuming retry attempts. The catch-up runs as a background future so node startup no longer blocks up to 90 s on store peers, and shutdown bounds its cancellation wait. StoreResume is also mounted on sync-enabled full nodes so they track last-online. Part of the "Store as a startup-only dependency" experiment (step 10). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent ed06055 commit e57546b

5 files changed

Lines changed: 119 additions & 16 deletions

File tree

logos_delivery/waku/factory/node_factory.nim

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -279,7 +279,10 @@ proc setupProtocols(
279279
return err("failed to set node waku store peer: " & error)
280280
node.peerManager.addServicePeer(storeNode, WakuStoreCodec)
281281

282-
if conf.storeServiceConf.isSome and conf.storeServiceConf.get().resume:
282+
if (conf.storeServiceConf.isSome and conf.storeServiceConf.get().resume) or
283+
conf.storeSyncConf.isSome():
284+
# sync-enabled full nodes track last-online so the startup catch-up can
285+
# choose between neighbour reconciliation and store resume
283286
node.setupStoreResume()
284287

285288
if conf.shardingConf.kind == AutoSharding:

logos_delivery/waku/node/waku_node.nim

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,8 @@ type
136136
wakuKademlia*: WakuKademlia
137137
ports*: BoundPorts
138138
relayReconnectFut*: Future[void]
139+
storeCatchUpFut*: Future[void]
140+
storeSyncRange*: timer.Duration
139141

140142
SubscriptionManager* = ref object of RootObj
141143
node*: WakuNode
@@ -379,6 +381,8 @@ proc mountStoreSync*(
379381

380382
let pubsubTopics = shards.mapIt($RelayShard(clusterId: cluster, shardId: it))
381383

384+
node.storeSyncRange = storeSyncRange.seconds
385+
382386
let recon = ?await SyncReconciliation.new(
383387
pubsubTopics, contentTopics, node.peerManager, node.wakuArchive,
384388
storeSyncRange.seconds, storeSyncInterval.seconds, storeSyncRelayJitter.seconds,
@@ -613,6 +617,66 @@ proc stopProvidersAndListeners*(node: WakuNode) =
613617
RequestContentTopicsHealth.clearProvider(node.brokerCtx)
614618
RequestShardTopicsHealth.clearProvider(node.brokerCtx)
615619

620+
func useSyncCatchUp*(
621+
hasReconciliation: bool,
622+
lastOnline: Timestamp,
623+
now: Timestamp,
624+
syncRange: timer.Duration,
625+
): bool =
626+
## Startup catch-up decision: inside the sync window neighbour
627+
## reconciliation repairs the gap; beyond it (or on fresh start, when no
628+
## last-online timestamp exists) store resume is needed.
629+
hasReconciliation and lastOnline > 0 and now - lastOnline <= syncRange.nanos
630+
631+
proc storeStartupCatchUp(node: WakuNode) {.async.} =
632+
## Store as a startup-only dependency: estimate the offline gap from the
633+
## persisted last-online timestamp and catch up accordingly. Runs in the
634+
## background; node startup must not block on store or sync peers.
635+
let lastOnline = node.wakuStoreResume.getLastOnlineTimestamp().valueOr:
636+
error "catch-up: failed to read last online timestamp", error = error
637+
Timestamp(0)
638+
639+
let now = getNowInNanosecondTime()
640+
641+
if useSyncCatchUp(
642+
not node.wakuStoreReconciliation.isNil(), lastOnline, now, node.storeSyncRange
643+
):
644+
info "offline gap within sync window, catching up via reconciliation",
645+
gapSeconds = (now - lastOnline) div 1_000_000_000
646+
# sleep first: the switch may not be started yet and discovery may still
647+
# be warming up; retry failed attempts, the periodic sync loop remains
648+
# the safety net if none succeeds within the budget
649+
for _ in 0 ..< 24:
650+
await sleepAsync(5.seconds)
651+
if node.peerManager.selectPeer(WakuReconciliationCodec).isNone():
652+
continue
653+
(await node.wakuStoreReconciliation.storeSynchronization()).isOkOr:
654+
error "startup reconciliation attempt failed", error = error
655+
continue
656+
return
657+
warn "startup reconciliation did not complete; periodic sync remains the safety net"
658+
else:
659+
info "offline gap beyond sync window or fresh start, using store resume",
660+
gapSeconds = (now - lastOnline) div 1_000_000_000
661+
# don't burn resume tries against an empty peer store: wait (bounded)
662+
# for a store peer to be discovered first
663+
var peerWait = 24
664+
var tries = 3
665+
while tries > 0:
666+
if node.peerManager.selectPeer(WakuStoreCodec).isNone():
667+
peerWait -= 1
668+
if peerWait <= 0:
669+
warn "no store peer found for startup resume"
670+
return
671+
await sleepAsync(5.seconds)
672+
continue
673+
(await node.wakuStoreResume.autoStoreResume()).isOkOr:
674+
tries -= 1
675+
error "store resume failed", triesLeft = tries, error = $error
676+
await sleepAsync(30.seconds)
677+
continue
678+
break
679+
616680
proc start*(node: WakuNode) {.async.} =
617681
## Starts a created Waku Node and
618682
## all its mounted protocols.
@@ -626,7 +690,9 @@ proc start*(node: WakuNode) {.async.} =
626690
zeroPortPresent = true
627691

628692
if not node.wakuStoreResume.isNil():
629-
await node.wakuStoreResume.start()
693+
# catch up in the background: startup must not block on store peers
694+
node.storeCatchUpFut = node.storeStartupCatchUp()
695+
node.wakuStoreResume.startPeriodicOnly()
630696

631697
if not node.wakuRendezvousClient.isNil():
632698
await node.wakuRendezvousClient.start()
@@ -699,6 +765,12 @@ proc stop*(node: WakuNode) {.async.} =
699765
if not node.wakuArchive.isNil():
700766
await node.wakuArchive.stopWait()
701767

768+
if not node.storeCatchUpFut.isNil():
769+
# in-flight store queries wrap awaits in results.catch, which can swallow
770+
# the one cancellation delivery; bound shutdown instead of waiting for
771+
# their natural completion
772+
discard await node.storeCatchUpFut.cancelAndWait().withTimeout(5.seconds)
773+
702774
if not node.wakuStoreResume.isNil():
703775
await node.wakuStoreResume.stopWait()
704776

logos_delivery/waku/waku_store/resume.nim

Lines changed: 4 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -197,19 +197,10 @@ proc periodicSetLastOnline(self: StoreResume) {.async.} =
197197
self.setLastOnlineTimestamp(ts).isOkOr:
198198
error "failed to set last online timestamp", error, time = ts
199199

200-
proc start*(self: StoreResume) {.async.} =
201-
# start resume process, will try thrice.
202-
var tries = 3
203-
while tries > 0:
204-
(await self.autoStoreResume()).isOkOr:
205-
tries -= 1
206-
error "store resume failed", triesLeft = tries, error = $error
207-
await sleepAsync(30.seconds)
208-
continue
209-
210-
break
211-
212-
# starting periodic storage of last online timestamp
200+
proc startPeriodicOnly*(self: StoreResume) =
201+
## Starts only the periodic last-online bookkeeping; the catch-up decision
202+
## (neighbour sync within the window vs store resume beyond it) is made by
203+
## the node's startup catch-up.
213204
self.handle = self.periodicSetLastOnline()
214205

215206
proc stopWait*(self: StoreResume) {.async.} =

tests/waku_store_sync/test_all.nim

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
11
{.used.}
22

3-
import ./test_protocol, ./test_storage, ./test_codec, ./test_full_node_sync
3+
import
4+
./test_protocol,
5+
./test_storage,
6+
./test_codec,
7+
./test_full_node_sync,
8+
./test_startup_catchup
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
{.used.}
2+
3+
import testutils/unittests, chronos
4+
import
5+
../../logos_delivery/waku/node/waku_node, ../../logos_delivery/waku/waku_core/time
6+
7+
suite "Startup catch-up decision (store as a startup-only dependency)":
8+
const SyncRange = 1800.seconds # 30 min window
9+
let now = getNowInNanosecondTime()
10+
11+
test "gap within the sync window uses reconciliation":
12+
let lastOnline = now - 300 * 1_000_000_000'i64 # 5 min ago
13+
14+
check useSyncCatchUp(true, lastOnline, now, SyncRange)
15+
16+
test "gap at the window boundary uses reconciliation":
17+
let lastOnline = now - 1800 * 1_000_000_000'i64
18+
19+
check useSyncCatchUp(true, lastOnline, now, SyncRange)
20+
21+
test "gap beyond the sync window falls back to store resume":
22+
let lastOnline = now - 2700 * 1_000_000_000'i64 # 45 min ago
23+
24+
check not useSyncCatchUp(true, lastOnline, now, SyncRange)
25+
26+
test "fresh start (no last-online record) falls back to store resume":
27+
check not useSyncCatchUp(true, Timestamp(0), now, SyncRange)
28+
29+
test "without reconciliation mounted always falls back to store resume":
30+
let lastOnline = now - 300 * 1_000_000_000'i64
31+
32+
check not useSyncCatchUp(false, lastOnline, now, SyncRange)

0 commit comments

Comments
 (0)