Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 14 additions & 6 deletions internal/impl/salesforce/input_salesforce_cdc.go
Original file line number Diff line number Diff line change
Expand Up @@ -727,6 +727,10 @@ func (e *salesforceCDCInputExecutor) emitSnapshot(
return fmt.Errorf("track snapshot checkpoint: %w", err)
}

// The ack error is deliberately ignored: nacks are replayed by
// auto_replay_nacks (the default), and disabling that is a documented
// opt-in to DROP rejected messages, so the checkpoint must advance past
// them rather than pin the tracker.
ackFn := func(ackCtx context.Context, _ error) error {
resolved := resolveFn()
if resolved == nil {
Expand All @@ -740,11 +744,11 @@ func (e *salesforceCDCInputExecutor) emitSnapshot(
e.stateMu.Lock()
e.state.SnapshotComplete = acked.SnapshotComplete
e.state.RestCursor = acked.RestCursor
err := e.saveStateLocked(ackCtx)
persistErr := e.saveStateLocked(ackCtx)
e.stateMu.Unlock()

if err != nil {
return fmt.Errorf("persist snapshot checkpoint: %w", err)
if persistErr != nil {
return fmt.Errorf("persist snapshot checkpoint: %w", persistErr)
}
return nil
}
Expand Down Expand Up @@ -900,6 +904,10 @@ func (e *salesforceCDCInputExecutor) flushTopic(
return fmt.Errorf("track checkpoint for %s: %w", topic, err)
}

// The ack error is deliberately ignored: nacks are replayed by
// auto_replay_nacks (the default), and disabling that is a documented
// opt-in to DROP rejected messages, so the checkpoint must advance past
// them rather than pin the tracker.
ackFn := func(ackCtx context.Context, _ error) error {
resolved := resolveFn()
if resolved == nil {
Expand All @@ -912,11 +920,11 @@ func (e *salesforceCDCInputExecutor) flushTopic(

e.stateMu.Lock()
e.state.Topics[topic] = acked
err := e.saveStateLocked(ackCtx)
persistErr := e.saveStateLocked(ackCtx)
e.stateMu.Unlock()

if err != nil {
return fmt.Errorf("persist checkpoint: %w", err)
if persistErr != nil {
return fmt.Errorf("persist checkpoint: %w", persistErr)
}
return nil
}
Expand Down
126 changes: 105 additions & 21 deletions internal/impl/salesforce/salesforcegrpc/subscription.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
package salesforcegrpc

import (
"bytes"
"context"
"errors"
"fmt"
Expand All @@ -30,6 +31,14 @@ import (
// provides safety margin without meaningfully slowing startup.
const subscribeSettleDelay = 5 * time.Second

// maxConsecutiveDecodeFailures bounds redelivery attempts for an event whose
// schema fetch or Avro decode keeps failing at the same replay position. Each
// failure reconnects and redelivers the batch (transient schema-fetch errors
// heal that way); once the same position has failed this many times in a row
// the payload is treated as permanently undecodable and the stream fails
// loudly instead of redelivering the batch prefix forever.
const maxConsecutiveDecodeFailures = 5

// Subscription owns one subscribe stream for a single Pub/Sub topic. It reuses
// the parent Client's connection, auth, and schema cache.
type Subscription struct {
Expand All @@ -46,6 +55,11 @@ type Subscription struct {
ready chan struct{}
streamErr error
state StreamState
// decodeFailures counts consecutive schema/decode failures at
// decodeFailureReplayID; guarded by mu. Reset on any successful decode or
// when the failing position changes.
decodeFailures int
decodeFailureReplayID []byte

// Atomic counters for health reporting.
eventsReceived atomic.Int64
Expand Down Expand Up @@ -121,22 +135,87 @@ func (s *Subscription) connectLocked(ctx context.Context) error {
// so waiting for the first Recv would block indefinitely on idle topics.
s.markReadyLocked()

go s.receiveLoop(ctx)
go s.receiveLoop(ctx, streamCtx)

return nil
}

// failDecode routes a schema/decode failure: reconnect-and-redeliver while
// the position is under the consecutive-failure bound, terminal stream
// failure once it is exceeded.
func (s *Subscription) failDecode(replayID []byte, failStream func(error), err error) {
if !s.recordDecodeFailure(replayID) {
failStream(err)
return
}
terminalErr := fmt.Errorf("decoding event at replay position %x: %d consecutive failures, treating as permanently undecodable: %w", replayID, maxConsecutiveDecodeFailures, err)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Schema-fetch failures and undecodable payloads share one counter, so a transient outage terminates the topic with a misleading "permanently undecodable" error.

Both call sites feed the same failDecode path: GetSchema errors (network/auth/5xx against the schema endpoint — subscription.go:257-262) and DecodeAvroPayload errors (a genuinely bad payload — subscription.go:264-269). Once five consecutive attempts at the same replay position fail for any of those reasons, the stream is failed terminally and the topic surfaces ... treating as permanently undecodable: get schema for event (schemaID=…): <connection refused>.

With reconnect_min_delay: 500ms / reconnect_max_delay: 30s, five consecutive attempts elapse in roughly a minute, so a schema-endpoint outage or an expired-token window longer than that permanently kills the topic — even though reconnect_max_attempts defaults to 0 (unlimited), i.e. the user explicitly asked for indefinite retry on transport failures. The error text then points a support engineer at the payload rather than at the actual cause.

Suggested fix: only count DecodeAvroPayload failures toward maxConsecutiveDecodeFailures (a decode failure against a successfully fetched schema really is deterministic), and route GetSchema failures through the normal reconnect/backoff path so they stay governed by reconnect_max_attempts. If they must share the bound, the terminal error should name the actual failing stage rather than asserting the payload is undecodable.

Refs: CONTRIBUTING.md §3.2.2 / §1.2.2 — poor error handling / difficult-to-diagnose failures, and subscription.go#L257-L269.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 844c3af — only DecodeAvroPayload failures against a successfully fetched schema (deterministic) count toward the bound; GetSchema failures route through the normal reconnect path governed by reconnect_max_attempts, and the terminal error wording names the actual stage.

s.lastError.Store(terminalErr)
s.lastErrorTime.Store(time.Now().UnixNano())
s.mu.Lock()
s.streamErr = terminalErr
s.state = StreamStateDisconnected
s.mu.Unlock()
s.client.log.Errorf("Pub/Sub stream failed permanently (topic=%s): %v", s.config.TopicName, terminalErr)
}

// recordDecodeFailure counts a schema/decode failure at the given replay
// position and reports whether the position has now failed
// maxConsecutiveDecodeFailures times in a row.
func (s *Subscription) recordDecodeFailure(replayID []byte) bool {
s.mu.Lock()
defer s.mu.Unlock()
if !bytes.Equal(replayID, s.decodeFailureReplayID) {
s.decodeFailureReplayID = append([]byte(nil), replayID...)
s.decodeFailures = 0
}
s.decodeFailures++
return s.decodeFailures >= maxConsecutiveDecodeFailures
}

// clearDecodeFailures resets the consecutive-failure count when the event at
// the tracked failing position decodes successfully. Successes at OTHER
// positions must not reset it: a reconnect redelivers the whole batch, so the
// decodable events preceding a permanently undecodable one succeed on every
// redelivery cycle - clearing on any success would keep the count oscillating
// below the bound forever.
func (s *Subscription) clearDecodeFailures(replayID []byte) {
s.mu.Lock()
if s.decodeFailureReplayID != nil && bytes.Equal(replayID, s.decodeFailureReplayID) {
s.decodeFailures = 0
s.decodeFailureReplayID = nil
}
s.mu.Unlock()
}

// receiveLoop reads from the gRPC stream and pushes decoded events into the
// buffer. On stream errors it attempts reconnection with backoff instead of
// exiting.
func (s *Subscription) receiveLoop(ctx context.Context) {
// exiting. streamCtx is this stream's cancellation context: it unblocks a
// backpressured buffer send when the subscription closes or reconnects.
func (s *Subscription) receiveLoop(ctx, streamCtx context.Context) {
// Capture done at goroutine start. reconnectWithBackoff → connectLocked
// replaces s.done with a fresh channel for the new goroutine; closing the
// old reference here prevents a double-close panic when both goroutines
// eventually return.
done := s.done
defer close(done)

// failStream logs err and hands control to the reconnect path. Because
// s.lastReplayID has not been advanced past the current batch, the
// reconnected stream redelivers it: duplicates, never loss.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment states the opposite of the invariant the same PR introduces.

"Because s.lastReplayID has not been advanced past the current batch, the reconnected stream redelivers it" was true before this PR, when the anchor only moved at batch boundaries. The buffer-send branch added below now advances it per delivered event (L461-L472), so on reconnect redelivery resumes from the last buffered event, not from the start of the batch — which is precisely the behaviour the per-event anchor exists to provide (no re-emitted prefix), and it is what the batch-loop comment at L359-L374 already says.

Since the whole no-silent-loss argument in this PR rests on exactly this invariant, the stale wording is actively misleading for the next reader. Suggest rewording to state that the anchor is not advanced past the failing event, so redelivery resumes at it.

Ref: CONTRIBUTING §3.1.2 (self-documenting code).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b3c2a32 — the comment now states the per-event invariant: the anchor never advances past the failing event, so redelivery resumes exactly there (duplicates at most, no re-emitted prefix).

failStream := func(err error) {
s.client.log.Errorf("Pub/Sub stream error (topic=%s), reconnecting: %v", s.config.TopicName, err)
s.lastError.Store(err)
s.lastErrorTime.Store(time.Now().UnixNano())

if reconnErr := s.reconnectWithBackoff(ctx); reconnErr != nil {
s.mu.Lock()
s.streamErr = reconnErr
s.state = StreamStateDisconnected
s.mu.Unlock()
s.client.log.Errorf("Reconnection failed permanently (topic=%s): %v", s.config.TopicName, reconnErr)
}
}

for {
resp, err := s.stream.Recv()
if err != nil {
Expand All @@ -148,17 +227,7 @@ func (s *Subscription) receiveLoop(ctx context.Context) {
}
s.mu.Unlock()

s.client.log.Errorf("Pub/Sub stream error (topic=%s): %v", s.config.TopicName, err)
s.lastError.Store(err)
s.lastErrorTime.Store(time.Now().UnixNano())

if reconnErr := s.reconnectWithBackoff(ctx); reconnErr != nil {
s.mu.Lock()
s.streamErr = reconnErr
s.state = StreamStateDisconnected
s.mu.Unlock()
s.client.log.Errorf("Reconnection failed permanently (topic=%s): %v", s.config.TopicName, reconnErr)
}
failStream(err)
return
}

Expand All @@ -177,19 +246,28 @@ func (s *Subscription) receiveLoop(ctx context.Context) {
continue
}

// A schema fetch or decode failure must not skip the event: the
// batch's replay ID would advance past it and the event would be
// silently lost. Reconnect instead — lastReplayID still points
// before this batch, so it is redelivered and transient failures
// (schema fetch) heal on retry. Redelivery is bounded: once the
// same position fails maxConsecutiveDecodeFailures times in a row
// the stream fails terminally (streamErr is surfaced through the
// health tick) instead of re-emitting the batch prefix forever.
schema, err := s.client.schemaCache.GetSchema(ctx, event.SchemaId)
if err != nil {
s.client.log.Errorf("get schema for event (schemaID=%s): %v", event.SchemaId, err)
s.eventsDecodeErrors.Add(1)
continue
s.failDecode(consumerEvent.ReplayId, failStream, fmt.Errorf("get schema for event (schemaID=%s): %w", event.SchemaId, err))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The "reconnect ⇒ redelivered" invariant does not hold when lastReplayID is still empty, so the first batch can still be silently lost.

The comment above states "lastReplayID still points before this batch, so it is redelivered", and failDecodefailStreamreconnectWithBackoff relies on that. But connectLocked only replays from a custom position when s.lastReplayID is non-empty:

if len(s.lastReplayID) > 0 {
    fetchReq.ReplayPreset = ReplayPreset_CUSTOM
    fetchReq.ReplayId = s.lastReplayID
} else {
    fetchReq.ReplayPreset = s.config.ReplayPreset
}

s.lastReplayID is only advanced after the whole resp.Events loop completes (or from an empty-events keepalive). So on a fresh subscription with no persisted replay ID and the default replay_preset: latest (input_salesforce_cdc.go:122), a schema-fetch or decode failure in the first batch reconnects with ReplayPreset_LATEST: the failing event, the rest of its batch, and everything published during the backoff are dropped without ever reaching the consumer. The consecutive-failure bound also never trips, because the position is never redelivered — so the loss is silent rather than loud, which is the exact failure mode this change is meant to eliminate.

Suggested fix: capture a resume position before the first response is fully processed (e.g. seed lastReplayID from resp.LatestReplayId/the first event's replay ID prior to the decode attempt, or refuse to reconnect via the configured preset when a decode failure is pending) so the redelivery guarantee holds for the first batch too.

Refs: CONTRIBUTING.md §5.4.2 (at-least-once delivery), and subscription.go#L110-L121.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 844c3af. The replay anchor now advances past each event as it is buffered, so any later failure reconnects from exactly the failing event — the first-batch case included, and re-emitted prefixes are gone as a side effect. When NO anchor exists (nothing delivered yet on a fresh stream) the reconnect path is never taken: schema fetches retry inline with backoff, and an undecodable first event fails the stream terminally and loudly (streamErr surfaced) instead of reconnecting via the preset and losing the batch silently. New tests cover the unanchored-terminal path and per-event anchor advancement.

return
}
Comment on lines 430 to 443

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Anchored schema-fetch failures are not actually bounded by the reconnect policy — a permanently unfetchable schema loops forever without ever surfacing an error.

The comment above claims schema-fetch failures "stay governed by the reconnect policy" (subscription.go#L273-L275), but reconnect_max_attempts cannot bound this path: reconnectWithBackoff restarts attempt at 0 on every call and returns nil as soon as connectLocked succeeds (subscription.go#L417-L461). Since the reconnect itself always succeeds here, the budget is never approached.

Failure scenario — a schema whose SchemaJson fails to compile (avro.Parse error in avro.go#L81-L84), or a schemaID that returns a permanent NotFound/PermissionDenied:

  1. GetSchema fails deterministically; an anchor exists, so failStream is called and the stream reconnects from lastReplayID.
  2. Salesforce redelivers starting at the same event; GetSchema fails again identically.
  3. streamErr is never set (the reconnect succeeded), so the health tick in input_salesforce_cdc.go#L874-L880 never observes anything, and handleStreamErr is never reached.

Result: the topic reconnects roughly once per backoff interval indefinitely, never advances its replay position, and never fails — the pipeline hangs silently apart from a repeated Errorf. This is the same unbounded-redelivery livelock that maxConsecutiveDecodeFailures was introduced for in 9cce455, reintroduced for the schema-fetch class when 844c3af split schema fetches out of that bound. Note the unanchored path already terminates correctly (lines 288–310), so the two branches disagree on whether a permanently failing schema fetch is survivable.

Suggested fix: give this path its own bound rather than relying on the per-call reconnect budget — e.g. track consecutive schema-fetch failures at the same replay position (mirroring recordDecodeFailure/clearDecodeFailures), or carry a reconnect budget across failStream calls that are triggered by schema fetches, so an uncompilable/inaccessible schema eventually reaches failTerminal with an error naming the schema stage — the same terminal outcome the unanchored branch already produces.

Rules referenced: CONTRIBUTING.md §3.1.4 ("implementation is complete and correct, with no known bugs"), §3.2.2 ("poor error handling or difficult-to-diagnose bugs"), and §1.2.2 (unexpected behavior should surface, not silently stall).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in f9f6346. Consecutive schema-fetch failures are now counted per replay position (the decode counter's machinery, extracted as a shared positionFailures type) with reconnect_max_attempts as the budget — counted across failStream cycles precisely because each individual reconnect succeeds, so the per-call budget alone can never trip. 0 keeps the documented opt-in to indefinite retries; a bounded policy now genuinely bounds this path and reaches failTerminal with an error naming the schema stage and the exhausted budget, matching the unanchored branch. Success at the tracked position clears the count; unrelated successes never do. Covered by counting-semantics and exhausted-budget receive-loop tests, and the stale 'stay governed by the reconnect policy' comment now describes the actual mechanism.


decoded, err := DecodeAvroPayload(schema, event.Payload)
if err != nil {
s.client.log.Errorf("decode Avro payload (schemaID=%s): %v", event.SchemaId, err)
s.eventsDecodeErrors.Add(1)
continue
s.failDecode(consumerEvent.ReplayId, failStream, fmt.Errorf("decode Avro payload (schemaID=%s): %w", event.SchemaId, err))
return
}
s.clearDecodeFailures(consumerEvent.ReplayId)

pubsubEvent := &PubSubEvent{
ReplayID: consumerEvent.ReplayId,
Expand All @@ -209,14 +287,20 @@ func (s *Subscription) receiveLoop(ctx context.Context) {
}
}

// A full buffer applies backpressure instead of dropping: while
// this send blocks, no flow-control FetchRequest is issued, so
// Salesforce stops sending and the replay cursor cannot advance
// past an undelivered event. The stream context unblocks the send
// on close or reconnect.
select {
case s.eventBuffer <- pubsubEvent:
s.eventsReceived.Add(1)
s.lastEventTime.Store(time.Now().UnixNano())
s.client.log.Debugf("Pub/Sub event received (topic=%s, schemaID=%s, replayID=%x)", pubsubEvent.TopicName, pubsubEvent.SchemaID, pubsubEvent.ReplayID)
default:
s.eventsDropped.Add(1)
s.client.log.Warnf("Pub/Sub event buffer full (topic=%s), dropping event", s.config.TopicName)
case <-streamCtx.Done():
return
case <-ctx.Done():
return
}
}

Expand Down
Loading