Skip to content

Commit 7e6ea1e

Browse files
authored
fix(da): fix polling fallback when ws not available (#3361)
* fix(da): fix polling fallback when ws not available * feedback * fix race * update mocks * fix ctx
1 parent ec9f9bf commit 7e6ea1e

13 files changed

Lines changed: 269 additions & 12 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
## [Unreleased]
1111

12+
## v1.1.4
13+
14+
### Fixed
15+
16+
- DA client falls back to HTTP polling with `Retrieve` when the WebSocket connection fails, instead of trying to use the WS-only `Subscribe` over HTTP. A background goroutine retries WS every 30s so transient outages don't force a permanent downgrade [#3361](https://github.com/evstack/ev-node/pull/3361)
17+
1218
## v1.1.3
1319

1420
### Fixed

apps/evm/server/force_inclusion_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,8 @@ func (m *mockDA) HasForcedInclusionNamespace() bool {
8585
return true
8686
}
8787

88+
func (m *mockDA) SupportsSubscribe() bool { return true }
89+
8890
func (m *mockDA) GetLatestDAHeight(_ context.Context) (uint64, error) {
8991
return 0, nil
9092
}

block/internal/da/async_block_retriever_test.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ func TestAsyncBlockRetriever_SubscriptionDrivenCaching(t *testing.T) {
5353
client := &mocks.MockClient{}
5454
fiNs := datypes.NamespaceFromString("test-fi-ns").Bytes()
5555

56+
client.On("SupportsSubscribe").Return(true)
57+
5658
// Create a subscription channel that delivers one event then blocks.
5759
subCh := make(chan datypes.SubscriptionEvent, 1)
5860
subCh <- datypes.SubscriptionEvent{
@@ -104,6 +106,8 @@ func TestAsyncBlockRetriever_CatchupFillsGaps(t *testing.T) {
104106
client := &mocks.MockClient{}
105107
fiNs := datypes.NamespaceFromString("test-fi-ns").Bytes()
106108

109+
client.On("SupportsSubscribe").Return(true)
110+
107111
// Subscription delivers height 105 (no blobs — just a signal).
108112
subCh := make(chan datypes.SubscriptionEvent, 1)
109113
subCh <- datypes.SubscriptionEvent{Height: 105}
@@ -153,6 +157,8 @@ func TestAsyncBlockRetriever_HeightFromFuture(t *testing.T) {
153157
client := &mocks.MockClient{}
154158
fiNs := datypes.NamespaceFromString("test-fi-ns").Bytes()
155159

160+
client.On("SupportsSubscribe").Return(true)
161+
156162
// Subscription delivers height 100 with no blobs.
157163
subCh := make(chan datypes.SubscriptionEvent)
158164
client.On("Subscribe", mock.Anything, fiNs, mock.Anything).Return((<-chan datypes.SubscriptionEvent)(subCh), nil).Once()
@@ -187,6 +193,8 @@ func TestAsyncBlockRetriever_StopGracefully(t *testing.T) {
187193
client := &mocks.MockClient{}
188194
fiNs := datypes.NamespaceFromString("test-fi-ns").Bytes()
189195

196+
client.On("SupportsSubscribe").Return(true)
197+
190198
blockCh := make(chan datypes.SubscriptionEvent)
191199
client.On("Subscribe", mock.Anything, fiNs, mock.Anything).Return((<-chan datypes.SubscriptionEvent)(blockCh), nil).Maybe()
192200
client.On("Retrieve", mock.Anything, mock.Anything, fiNs).Return(datypes.ResultRetrieve{
@@ -211,6 +219,8 @@ func TestAsyncBlockRetriever_ReconnectOnSubscriptionError(t *testing.T) {
211219
client := &mocks.MockClient{}
212220
fiNs := datypes.NamespaceFromString("test-fi-ns").Bytes()
213221

222+
client.On("SupportsSubscribe").Return(true)
223+
214224
// First subscription closes immediately (simulating error).
215225
closedCh := make(chan datypes.SubscriptionEvent)
216226
close(closedCh)

block/internal/da/client.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ type Config struct {
3232
type client struct {
3333
blobAPI *blobrpc.BlobAPI
3434
headerAPI *blobrpc.HeaderAPI
35+
da *blobrpc.Client // kept to read live IsWebSocket after transport upgrades
3536
logger zerolog.Logger
3637
defaultTimeout time.Duration
3738
namespaceBz []byte
@@ -131,6 +132,7 @@ func NewClient(cfg Config) FullClient {
131132
return &client{
132133
blobAPI: &cfg.DA.Blob,
133134
headerAPI: &cfg.DA.Header,
135+
da: cfg.DA,
134136
logger: cfg.Logger.With().Str("component", "da_client").Logger(),
135137
defaultTimeout: cfg.DefaultTimeout,
136138
namespaceBz: datypes.NamespaceFromString(cfg.Namespace).Bytes(),
@@ -485,6 +487,13 @@ func (c *client) HasForcedInclusionNamespace() bool {
485487
return c.hasForcedNamespace
486488
}
487489

490+
// SupportsSubscribe reports whether the underlying transport supports
491+
// channel-based subscriptions (WebSocket). Reads the live IsWebSocket flag
492+
// from the jsonrpc client so transport upgrades are visible immediately.
493+
func (c *client) SupportsSubscribe() bool {
494+
return c.da != nil && c.da.IsWebSocket.Load()
495+
}
496+
488497
// Subscribe subscribes to blobs in the given namespace via the celestia-node
489498
// Subscribe API. It returns a channel that emits a SubscriptionEvent for every
490499
// DA block containing a matching blob. The channel is closed when ctx is

block/internal/da/interface.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,11 @@ type Client interface {
3131
// GetLatestDAHeight returns the latest height available on the DA layer.
3232
GetLatestDAHeight(ctx context.Context) (uint64, error)
3333

34+
// SupportsSubscribe reports whether the underlying transport supports
35+
// channel-based subscriptions (WebSocket). When false, callers must use
36+
// polling-based retrieval via Retrieve instead.
37+
SupportsSubscribe() bool
38+
3439
// Namespace accessors.
3540
GetHeaderNamespace() []byte
3641
GetDataNamespace() []byte

block/internal/da/subscriber.go

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -122,10 +122,14 @@ func (s *Subscriber) Start(ctx context.Context) error {
122122

123123
ctx, cancel := context.WithCancel(ctx)
124124
s.cancel = cancel
125-
s.wg.Add(2)
126125
s.lifecycleMu.Unlock()
127126

128-
go s.followLoop(ctx)
127+
s.wg.Add(2)
128+
if s.client.SupportsSubscribe() {
129+
go s.followLoop(ctx)
130+
} else {
131+
go s.pollLoop(ctx)
132+
}
129133
go s.catchupLoop(ctx)
130134

131135
return nil
@@ -167,6 +171,72 @@ func (s *Subscriber) signalCatchup() {
167171
}
168172
}
169173

174+
// pollLoop periodically queries the latest DA height and triggers
175+
// catchup when new heights are available. The catchup loop fetches blobs
176+
// via Retrieve (which uses GetAll) so each height is fetched exactly once.
177+
// Periodically checks whether the underlying transport has been upgraded
178+
// to WebSocket and switches to followLoop when that happens.
179+
func (s *Subscriber) pollLoop(ctx context.Context) {
180+
defer s.wg.Done()
181+
182+
s.logger.Info().Msg("starting poll loop")
183+
defer s.logger.Info().Msg("poll loop stopped")
184+
185+
// Do an immediate poll on startup so we don't wait for the first tick.
186+
s.pollDAHeight(ctx)
187+
188+
interval := s.daBlockTime
189+
if interval <= 0 {
190+
interval = 2 * time.Second
191+
}
192+
ticker := time.NewTicker(interval)
193+
defer ticker.Stop()
194+
195+
for {
196+
// If the transport has been upgraded to WS in the background,
197+
// switch to the subscription-based follow loop.
198+
if s.client.SupportsSubscribe() {
199+
s.logger.Info().Msg("WebSocket available, switching from poll to follow loop")
200+
s.wg.Add(1)
201+
go s.followLoop(ctx)
202+
return
203+
}
204+
205+
select {
206+
case <-ctx.Done():
207+
return
208+
case <-ticker.C:
209+
s.pollDAHeight(ctx)
210+
}
211+
}
212+
}
213+
214+
// pollDAHeight queries GetLatestDAHeight and signals catchup when a new
215+
// height is observed. The actual blob retrieval is done by catchupLoop.
216+
func (s *Subscriber) pollDAHeight(ctx context.Context) {
217+
height, err := s.client.GetLatestDAHeight(ctx)
218+
if err != nil {
219+
if ctx.Err() != nil {
220+
return
221+
}
222+
s.logger.Warn().Err(err).Msg("poll: failed to get latest DA height")
223+
return
224+
}
225+
226+
cur := s.highestSeenDAHeight.Load()
227+
if height <= cur {
228+
return
229+
}
230+
231+
s.seenSubscriptionEvent.Store(true)
232+
s.logger.Debug().
233+
Uint64("new_da_height", height).
234+
Uint64("current_highest_seen", cur).
235+
Msg("poll: observed new DA height")
236+
237+
s.updateHighest(height)
238+
}
239+
170240
// followLoop subscribes to DA blob events and keeps highestSeenDAHeight up to date.
171241
func (s *Subscriber) followLoop(ctx context.Context) {
172242
defer s.wg.Done()

block/internal/da/tracing.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,9 @@ func (t *tracedClient) GetForcedInclusionNamespace() []byte {
165165
func (t *tracedClient) HasForcedInclusionNamespace() bool {
166166
return t.inner.HasForcedInclusionNamespace()
167167
}
168+
func (t *tracedClient) SupportsSubscribe() bool {
169+
return t.inner.SupportsSubscribe()
170+
}
168171
func (t *tracedClient) Subscribe(ctx context.Context, namespace []byte, includeTimestamp bool) (<-chan datypes.SubscriptionEvent, error) {
169172
return t.inner.Subscribe(ctx, namespace, includeTimestamp)
170173
}

block/internal/da/tracing_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ func (m *mockFullClient) GetHeaderNamespace() []byte {
7474
func (m *mockFullClient) GetDataNamespace() []byte { return []byte{0x02} }
7575
func (m *mockFullClient) GetForcedInclusionNamespace() []byte { return []byte{0x03} }
7676
func (m *mockFullClient) HasForcedInclusionNamespace() bool { return true }
77+
func (m *mockFullClient) SupportsSubscribe() bool { return true }
7778

7879
// setup a tracer provider + span recorder
7980
func setupDATrace(t *testing.T, inner FullClient) (FullClient, *tracetest.SpanRecorder) {

block/internal/syncing/syncer_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ func makeSignedHeaderBytes(
116116
func setupMockDAClient(tb testing.TB) (da.Client, chan datypes.SubscriptionEvent) {
117117
mockClient := testmocks.NewMockClient(tb)
118118
eventCh := make(chan datypes.SubscriptionEvent, 1)
119+
mockClient.EXPECT().SupportsSubscribe().Return(true).Maybe()
119120
mockClient.EXPECT().Subscribe(mock.Anything, mock.Anything, mock.Anything).Return((<-chan datypes.SubscriptionEvent)(eventCh), nil).Maybe()
120121
return mockClient, eventCh
121122
}

pkg/da/jsonrpc/client.go

Lines changed: 97 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ import (
55
"fmt"
66
"net/http"
77
"strings"
8+
"sync"
9+
"sync/atomic"
10+
"time"
811

912
libshare "github.com/celestiaorg/go-square/v3/share"
1013
"github.com/filecoin-project/go-jsonrpc"
@@ -13,15 +16,30 @@ import (
1316

1417
// Client dials the celestia-node RPC "blob" and "header" namespaces.
1518
type Client struct {
16-
Blob BlobAPI
17-
Header HeaderAPI
18-
closer jsonrpc.ClientCloser
19+
Blob BlobAPI
20+
Header HeaderAPI
21+
IsWebSocket atomic.Bool
22+
23+
mu sync.Mutex
24+
closer jsonrpc.ClientCloser
25+
retryCancel context.CancelFunc // stops the background WS retry loop
1926
}
2027

21-
// Close closes the underlying JSON-RPC connection.
28+
// Close closes the underlying JSON-RPC connection and stops any
29+
// background WebSocket retry loop.
2230
func (c *Client) Close() {
23-
if c != nil && c.closer != nil {
24-
c.closer()
31+
if c == nil {
32+
return
33+
}
34+
c.mu.Lock()
35+
if c.retryCancel != nil {
36+
c.retryCancel()
37+
c.retryCancel = nil
38+
}
39+
closer := c.closer
40+
c.mu.Unlock()
41+
if closer != nil {
42+
closer()
2543
}
2644
}
2745

@@ -72,18 +90,88 @@ func NewClient(ctx context.Context, addr, token string, authHeaderName string) (
7290
// NewWSClient connects to the DA RPC endpoint over WebSocket.
7391
// Automatically converts http:// to ws:// (and https:// to wss://).
7492
// Supports channel-based subscriptions (e.g. Subscribe).
75-
// Note: WebSocket connections are eager — they connect at creation time
76-
// if the initial WS dial fails, falls back to HTTP polling for the entire session.
93+
// WebSocket connections are eager — they connect at creation time.
94+
// If the initial WS dial fails, it falls back to HTTP polling and spawns a
95+
// background goroutine that periodically retries the WS connection. When
96+
// the WS endpoint becomes reachable, the transport is transparently upgraded.
7797
func NewWSClient(ctx context.Context, logger zerolog.Logger, addr, token string, authHeaderName string) (*Client, error) {
7898
client, err := NewClient(ctx, httpToWS(addr), token, authHeaderName)
7999
if err != nil {
80100
logger.Warn().Err(err).Msg("DA websocket connection failed, falling back to DA polling")
81-
return NewClient(ctx, addr, token, authHeaderName)
101+
client, err = NewClient(ctx, addr, token, authHeaderName)
102+
if err != nil {
103+
return nil, err
104+
}
105+
client.IsWebSocket.Store(false)
106+
107+
// Retry WS in the background so transient outages don't force a permanent downgrade.
108+
retryCtx, retryCancel := context.WithCancel(ctx)
109+
client.retryCancel = retryCancel
110+
go client.retryWSLoop(retryCtx, logger, addr, token, authHeaderName)
111+
112+
return client, nil
82113
}
83114

115+
client.IsWebSocket.Store(true)
84116
return client, nil
85117
}
86118

119+
const wsRetryInterval = 30 * time.Second
120+
121+
// retryWSLoop periodically attempts to re-establish a WebSocket connection.
122+
// When successful, it swaps the transport in-place and exits.
123+
func (c *Client) retryWSLoop(ctx context.Context, logger zerolog.Logger, addr, token, authHeaderName string) {
124+
ticker := time.NewTicker(wsRetryInterval)
125+
defer ticker.Stop()
126+
127+
for {
128+
select {
129+
case <-ctx.Done():
130+
return
131+
case <-ticker.C:
132+
if c.tryUpgradeWS(ctx, logger, addr, token, authHeaderName) {
133+
return
134+
}
135+
}
136+
}
137+
}
138+
139+
// tryUpgradeWS attempts to open a WS connection and, if successful, swaps
140+
// the transport internals so subsequent calls use WebSocket. Returns true
141+
// when the upgrade succeeds (or the client is already on WS).
142+
func (c *Client) tryUpgradeWS(ctx context.Context, logger zerolog.Logger, addr, token, authHeaderName string) bool {
143+
wsClient, err := NewClient(ctx, httpToWS(addr), token, authHeaderName)
144+
if err != nil {
145+
return false
146+
}
147+
148+
c.mu.Lock()
149+
defer c.mu.Unlock()
150+
151+
// Another goroutine may have already upgraded.
152+
if c.IsWebSocket.Load() {
153+
wsClient.Close()
154+
return true
155+
}
156+
157+
// Swap function pointers from the new WS client into the active client.
158+
c.Blob.Internal = wsClient.Blob.Internal
159+
c.Header.Internal = wsClient.Header.Internal
160+
161+
// Close the old HTTP connections and wire the new closer.
162+
oldCloser := c.closer
163+
c.closer = func() {
164+
wsClient.closer()
165+
if oldCloser != nil {
166+
oldCloser()
167+
}
168+
}
169+
170+
c.IsWebSocket.Store(true)
171+
logger.Info().Msg("DA websocket connection restored, switching back from HTTP polling")
172+
return true
173+
}
174+
87175
// BlobAPI mirrors celestia-node's blob module (nodebuilder/blob/blob.go).
88176
// jsonrpc.NewClient wires Internal.* to RPC stubs.
89177
type BlobAPI struct {

0 commit comments

Comments
 (0)