auto resubscribe a corrosion subscription if an error occurs

This commit is contained in:
Pavel Sviderski
2024-10-03 16:13:03 +10:00
parent 840943b3b0
commit 1bcb8836ef
2 changed files with 287 additions and 106 deletions
+118 -7
View File
@@ -27,9 +27,9 @@ const (
// APIClient is a client for the Corrosion API. // APIClient is a client for the Corrosion API.
type APIClient struct { type APIClient struct {
baseURL *url.URL baseURL *url.URL
client *http.Client client *http.Client
newResubsribeBackoff func() backoff.BackOff newResubBackoff func() backoff.BackOff
} }
// NewAPIClient creates a new Corrosion API client. The client retries on network errors using an exponential backoff // NewAPIClient creates a new Corrosion API client. The client retries on network errors using an exponential backoff
@@ -65,7 +65,7 @@ func NewAPIClient(addr netip.AddrPort, opts ...APIClientOption) (*APIClient, err
}, },
}, },
}, },
newResubsribeBackoff: func() backoff.BackOff { newResubBackoff: func() backoff.BackOff {
return backoff.NewExponentialBackOff( return backoff.NewExponentialBackOff(
backoff.WithInitialInterval(100*time.Millisecond), backoff.WithInitialInterval(100*time.Millisecond),
backoff.WithMaxInterval(1*time.Second), backoff.WithMaxInterval(1*time.Second),
@@ -88,10 +88,11 @@ func WithHTTP2Client(client *http.Client) APIClientOption {
} }
} }
// WithResubscribeBackoff sets the backoff policy for resubscribing to a query. // WithResubscribeBackoff sets the backoff policy for resubscribing to a query if an error occurs.
// Pass nil to disable resubscribing.
func WithResubscribeBackoff(newBackoff func() backoff.BackOff) APIClientOption { func WithResubscribeBackoff(newBackoff func() backoff.BackOff) APIClientOption {
return func(c *APIClient) { return func(c *APIClient) {
c.newResubsribeBackoff = newBackoff c.newResubBackoff = newBackoff
} }
} }
@@ -108,7 +109,7 @@ func (rt *RetryRoundTripper) RoundTrip(req *http.Request) (*http.Response, error
var opErr *net.OpError var opErr *net.OpError
if errors.As(err, &opErr) { if errors.As(err, &opErr) {
// Not certain, but I expect operational errors should generally be retryable. // Not certain, but I expect operational errors should generally be retryable.
slog.Debug("Retrying corrosion API request due to network error", "error", err) slog.Debug("Retrying corrosion API request due to network error.", "error", err)
return nil, err return nil, err
} }
// Don't retry on other errors. // Don't retry on other errors.
@@ -120,3 +121,113 @@ func (rt *RetryRoundTripper) RoundTrip(req *http.Request) (*http.Response, error
boff := backoff.WithContext(rt.NewBackoff(), req.Context()) boff := backoff.WithContext(rt.NewBackoff(), req.Context())
return backoff.RetryWithData(roundTrip, boff) return backoff.RetryWithData(roundTrip, boff)
} }
type RetrySubscription struct {
ctx context.Context
cancel context.CancelFunc
client *APIClient
sub *Subscription
changes chan *ChangeEvent
lastChangeID uint64
err error
backoff backoff.BackOff
}
func NewRetrySubscription(ctx context.Context, client *APIClient, sub *Subscription, boff backoff.BackOff) *RetrySubscription {
ctx, cancel := context.WithCancel(ctx)
if boff == nil {
boff = backoff.NewExponentialBackOff(
backoff.WithInitialInterval(100*time.Millisecond),
backoff.WithMaxInterval(1*time.Second),
backoff.WithMaxElapsedTime(60*time.Second),
)
}
return &RetrySubscription{
ctx: ctx,
cancel: cancel,
client: client,
sub: sub,
backoff: backoff.WithContext(boff, ctx),
}
}
func (rs *RetrySubscription) ID() string {
return rs.sub.ID()
}
func (rs *RetrySubscription) Changes() (<-chan *ChangeEvent, error) {
if rs.changes != nil {
return rs.changes, nil
}
// Ensure the rows are consumed if they have been requested.
changes, err := rs.sub.Changes()
if err != nil {
return nil, err
}
rs.lastChangeID = rs.sub.lastChangeID
go func() {
defer close(rs.changes)
var change *ChangeEvent
for {
select {
case change = <-changes:
case <-rs.ctx.Done():
return
}
if change != nil {
select {
case rs.changes <- change:
case <-rs.ctx.Done():
return
}
rs.lastChangeID = change.ChangeID
continue
}
// The underlying subscription has been closed due to an error or context cancellation.
// Return if the context is done or try to resubscribe otherwise.
if rs.ctx.Err() != nil {
return
}
if err = rs.resubscribe(); err != nil {
// resubscribe returns a permanent error after unsuccessful retries.
rs.err = fmt.Errorf("resubscribe to query: %w", err)
return
}
changes, err = rs.sub.Changes()
if err != nil {
// Unexpected error but report it anyway.
rs.err = err
return
}
}
}()
return rs.changes, nil
}
func (rs *RetrySubscription) resubscribe() error {
return backoff.Retry(func() error {
slog.Debug("Resubscribing to Corrosion query.", "id", rs.ID(), "from_change", rs.lastChangeID)
sub, err := rs.client.ResubscribeContext(rs.ctx, rs.ID(), rs.lastChangeID)
if err != nil {
return err
}
rs.sub = sub
return nil
}, rs.backoff)
}
func (rs *RetrySubscription) Err() error {
return rs.err
}
func (rs *RetrySubscription) Close() {
rs.cancel()
}
+169 -99
View File
@@ -6,71 +6,13 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"github.com/cenkalti/backoff/v4"
"io" "io"
"log/slog"
"net/http" "net/http"
"strconv"
) )
// SubscribeContext creates a subscription to receive updates for a desired SQL query. If skipRows is false,
// Subscription.Rows must be consumed before Subscription.Changes can be called. If skipRows is true, Subscription.Rows
// will be nil.
func (c *APIClient) SubscribeContext(
ctx context.Context, query string, args []any, skipRows bool,
) (*Subscription, error) {
statement := Statement{
Query: query,
Params: args,
}
body, err := json.Marshal(statement)
if err != nil {
return nil, fmt.Errorf("marshal query: %w", err)
}
subURL := c.baseURL.JoinPath("/v1/subscriptions")
if skipRows {
q := subURL.Query()
q.Set("skip_rows", "true")
subURL.RawQuery = q.Encode()
}
req, err := http.NewRequestWithContext(ctx, "POST", subURL.String(), bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := c.client.Do(req)
if err != nil {
return nil, fmt.Errorf("send request: %w", err)
}
if resp.StatusCode != http.StatusOK {
respBody, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
return nil, fmt.Errorf("read response body: %w", err)
}
return nil, fmt.Errorf("unexpected status code %d: %s", resp.StatusCode, respBody)
}
id := resp.Header.Get("corro-query-id")
if id == "" {
resp.Body.Close()
return nil, errors.New("missing corro-query-id header in response")
}
if skipRows {
return newSubscription(ctx, id, nil, resp.Body, nil), nil
}
rows, err := newRows(ctx, resp.Body, false)
if err != nil {
resp.Body.Close()
return nil, fmt.Errorf("parse query response: %w", err)
}
return newSubscription(ctx, id, rows, rows.body, rows.decoder), nil
}
type ChangeType string type ChangeType string
var ( var (
@@ -138,25 +80,32 @@ type Subscription struct {
rows *Rows rows *Rows
body io.ReadCloser body io.ReadCloser
decoder *json.Decoder decoder *json.Decoder
resubscribe func(ctx context.Context, fromChange uint64) (*Subscription, error)
changes chan *ChangeEvent changes chan *ChangeEvent
lastChangeID uint64 lastChangeID uint64
err error err error
} }
func newSubscription( func newSubscription(
ctx context.Context, id string, rows *Rows, body io.ReadCloser, decoder *json.Decoder, ctx context.Context,
id string,
rows *Rows,
body io.ReadCloser,
decoder *json.Decoder,
resubscribe func(ctx context.Context, fromChange uint64) (*Subscription, error),
) *Subscription { ) *Subscription {
ctx, cancel := context.WithCancel(ctx) ctx, cancel := context.WithCancel(ctx)
if decoder == nil { if decoder == nil {
decoder = json.NewDecoder(body) decoder = json.NewDecoder(body)
} }
return &Subscription{ return &Subscription{
id: id, ctx: ctx,
rows: rows, cancel: cancel,
ctx: ctx, id: id,
cancel: cancel, rows: rows,
body: body, body: body,
decoder: decoder, decoder: decoder,
resubscribe: resubscribe,
} }
} }
@@ -193,52 +142,70 @@ func (s *Subscription) Changes() (<-chan *ChangeEvent, error) {
<-s.ctx.Done() <-s.ctx.Done()
s.body.Close() s.body.Close()
}() }()
go s.handleChangeEvents()
go func() { return s.changes, nil
defer s.cancel() }
defer close(s.changes)
for { func (s *Subscription) handleChangeEvents() {
select { defer s.cancel()
case <-s.ctx.Done(): defer close(s.changes)
return
default:
}
var e QueryEvent for {
if err := s.decoder.Decode(&e); err != nil { select {
// Do not report an error that occurred due to context cancellation. case <-s.ctx.Done():
if s.ctx.Err() == nil { return
s.err = fmt.Errorf("decode query event: %w", err) default:
} }
return
}
if e.Error != nil { var e QueryEvent
s.err = fmt.Errorf("query error: %s", *e.Error) var err error
return if err = s.decoder.Decode(&e); err != nil {
} // Do not report an error that occurred due to context cancellation, just return.
if e.Change == nil { if s.ctx.Err() != nil {
s.err = fmt.Errorf("expected change event, got: %+v", e)
return return
} }
err = fmt.Errorf("decode query event: %w", err)
} else if e.Error != nil {
err = fmt.Errorf("query error: %s", *e.Error)
} else if e.Change == nil {
err = fmt.Errorf("expected change event, got: %+v", e)
} else if s.lastChangeID != 0 && e.Change.ChangeID != s.lastChangeID+1 {
// If skipRows is true, the last change ID is unknown. // If skipRows is true, the last change ID is unknown.
if s.lastChangeID != 0 && e.Change.ChangeID != s.lastChangeID+1 { err = fmt.Errorf("missed a change: expected change ID %d, got %d",
s.err = fmt.Errorf("missed a change: expected change ID %d, got %d", s.lastChangeID+1, e.Change.ChangeID)
s.lastChangeID+1, e.Change.ChangeID) }
return
}
if err == nil {
s.lastChangeID = e.Change.ChangeID s.lastChangeID = e.Change.ChangeID
select { select {
case s.changes <- e.Change: case s.changes <- e.Change:
case <-s.ctx.Done(): case <-s.ctx.Done():
return return
} }
} } else {
}() // Report the error if resubscribing is disabled.
if s.resubscribe == nil {
s.err = err
return
}
return s.changes, nil slog.Info("Resubscribing to Corrosion query due to an error.",
"error", err, "id", s.id, "from_change", s.lastChangeID)
sub, sErr := s.resubscribe(s.ctx, s.lastChangeID)
if sErr != nil {
// resubscribe returns a permanent error after unsuccessful retries.
s.err = fmt.Errorf("resubscribe to query with backoff: %w", sErr)
return
}
// Reset the subscription to the new one.
s.rows = nil
s.body = sub.body
s.decoder = sub.decoder
// Do not close the sub to not close the body.
sub.cancel()
}
}
} }
// Err returns the error, if any, that was encountered during fetching changes. // Err returns the error, if any, that was encountered during fetching changes.
@@ -251,3 +218,106 @@ func (s *Subscription) Close() error {
s.cancel() s.cancel()
return s.body.Close() return s.body.Close()
} }
// SubscribeContext creates a subscription to receive updates for a desired SQL query. If skipRows is false,
// Subscription.Rows must be consumed before Subscription.Changes can be called. If skipRows is true, Subscription.Rows
// will return nil.
func (c *APIClient) SubscribeContext(
ctx context.Context, query string, args []any, skipRows bool,
) (*Subscription, error) {
statement := Statement{
Query: query,
Params: args,
}
body, err := json.Marshal(statement)
if err != nil {
return nil, fmt.Errorf("marshal query: %w", err)
}
subURL := c.baseURL.JoinPath("/v1/subscriptions")
if skipRows {
q := subURL.Query()
q.Set("skip_rows", "true")
subURL.RawQuery = q.Encode()
}
req, err := http.NewRequestWithContext(ctx, "POST", subURL.String(), bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := c.client.Do(req)
if err != nil {
return nil, fmt.Errorf("send request: %w", err)
}
if resp.StatusCode != http.StatusOK {
respBody, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
return nil, fmt.Errorf("read response body: %w", err)
}
return nil, fmt.Errorf("unexpected status code %d: %s", resp.StatusCode, respBody)
}
id := resp.Header.Get("corro-query-id")
if id == "" {
resp.Body.Close()
return nil, errors.New("missing corro-query-id header in response")
}
if skipRows {
return newSubscription(ctx, id, nil, resp.Body, nil, c.resubscribeWithBackoffFn(id)), nil
}
rows, err := newRows(ctx, resp.Body, false)
if err != nil {
resp.Body.Close()
return nil, fmt.Errorf("parse query response: %w", err)
}
return newSubscription(ctx, id, rows, rows.body, rows.decoder, c.resubscribeWithBackoffFn(id)), nil
}
func (c *APIClient) resubscribeWithBackoffFn(id string) func(context.Context, uint64) (*Subscription, error) {
if c.newResubBackoff == nil {
return nil
}
return func(ctx context.Context, fromChange uint64) (*Subscription, error) {
return backoff.RetryWithData(func() (*Subscription, error) {
slog.Debug("Retrying to resubscribe to Corrosion query.", "id", id, "from_change", fromChange)
return c.ResubscribeContext(ctx, id, fromChange)
}, c.newResubBackoff())
}
}
func (c *APIClient) ResubscribeContext(ctx context.Context, id string, fromChange uint64) (*Subscription, error) {
subURL := c.baseURL.JoinPath("/v1/subscriptions", id)
q := subURL.Query()
q.Set("from", strconv.FormatUint(fromChange, 10))
subURL.RawQuery = q.Encode()
req, err := http.NewRequestWithContext(ctx, "GET", subURL.String(), nil)
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := c.client.Do(req)
if err != nil {
return nil, fmt.Errorf("send request: %w", err)
}
if resp.StatusCode != http.StatusOK {
respBody, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
return nil, fmt.Errorf("read response body: %w", err)
}
return nil, fmt.Errorf("unexpected status code %d: %s", resp.StatusCode, respBody)
}
return newSubscription(ctx, id, nil, resp.Body, nil, c.resubscribeWithBackoffFn(id)), nil
}