-
Notifications
You must be signed in to change notification settings - Fork 957
salesforce_cdc: apply backpressure instead of dropping events on full buffer (CON-504) #4689
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 8 commits
d0e8c3c
f8cf8bf
6c4e103
9cce455
d90005a
844c3af
810750f
f9f6346
a66868a
6eb239f
b3c2a32
04c938f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -9,6 +9,7 @@ | |||||||||||||||||||||||||||||||||
| package salesforcegrpc | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| import ( | ||||||||||||||||||||||||||||||||||
| "bytes" | ||||||||||||||||||||||||||||||||||
| "context" | ||||||||||||||||||||||||||||||||||
| "errors" | ||||||||||||||||||||||||||||||||||
| "fmt" | ||||||||||||||||||||||||||||||||||
|
|
@@ -30,6 +31,16 @@ import ( | |||||||||||||||||||||||||||||||||
| // provides safety margin without meaningfully slowing startup. | ||||||||||||||||||||||||||||||||||
| const subscribeSettleDelay = 5 * time.Second | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| // maxConsecutiveDecodeFailures bounds redelivery attempts for an event whose | ||||||||||||||||||||||||||||||||||
| // Avro decode keeps failing at the same replay position. A decode failure | ||||||||||||||||||||||||||||||||||
| // against a successfully fetched schema is deterministic, and the replay | ||||||||||||||||||||||||||||||||||
| // anchor advances per delivered event, so each reconnect redelivers from | ||||||||||||||||||||||||||||||||||
| // exactly the failing event; once the same position has failed this many | ||||||||||||||||||||||||||||||||||
| // times in a row the payload is treated as permanently undecodable and the | ||||||||||||||||||||||||||||||||||
| // stream fails loudly. Schema-fetch failures never count toward this bound - | ||||||||||||||||||||||||||||||||||
| // they are transport-class errors governed by the reconnect policy. | ||||||||||||||||||||||||||||||||||
| 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 { | ||||||||||||||||||||||||||||||||||
|
|
@@ -46,6 +57,13 @@ type Subscription struct { | |||||||||||||||||||||||||||||||||
| ready chan struct{} | ||||||||||||||||||||||||||||||||||
| streamErr error | ||||||||||||||||||||||||||||||||||
| state StreamState | ||||||||||||||||||||||||||||||||||
| // decodeFailures and schemaFailures count consecutive failures pinned to | ||||||||||||||||||||||||||||||||||
| // one replay position each (guarded by mu): deterministic Avro-decode | ||||||||||||||||||||||||||||||||||
| // failures bounded by maxConsecutiveDecodeFailures, and schema-fetch | ||||||||||||||||||||||||||||||||||
| // failures bounded by the reconnect policy (reconnect_max_attempts). | ||||||||||||||||||||||||||||||||||
| // Position-scoped so an unrelated event's success never resets them. | ||||||||||||||||||||||||||||||||||
| decodeFailures positionFailures | ||||||||||||||||||||||||||||||||||
| schemaFailures positionFailures | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| // Atomic counters for health reporting. | ||||||||||||||||||||||||||||||||||
| eventsReceived atomic.Int64 | ||||||||||||||||||||||||||||||||||
|
|
@@ -121,22 +139,145 @@ 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 | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| // anchored reports whether a replay resume position exists: only then can a | ||||||||||||||||||||||||||||||||||
| // reconnect redeliver the current batch (CUSTOM replay is exclusive-after). | ||||||||||||||||||||||||||||||||||
| // On a fresh stream with no anchor, a reconnect falls back to the configured | ||||||||||||||||||||||||||||||||||
| // preset - with LATEST that silently drops the batch, so unanchored failures | ||||||||||||||||||||||||||||||||||
| // must never take the reconnect path. | ||||||||||||||||||||||||||||||||||
| func (s *Subscription) anchored() bool { | ||||||||||||||||||||||||||||||||||
| s.mu.Lock() | ||||||||||||||||||||||||||||||||||
| defer s.mu.Unlock() | ||||||||||||||||||||||||||||||||||
| return len(s.lastReplayID) > 0 | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| // failTerminal fails the stream permanently: streamErr surfaces through the | ||||||||||||||||||||||||||||||||||
| // health tick and no reconnect is attempted. | ||||||||||||||||||||||||||||||||||
| func (s *Subscription) failTerminal(err error) { | ||||||||||||||||||||||||||||||||||
| s.lastError.Store(err) | ||||||||||||||||||||||||||||||||||
| s.lastErrorTime.Store(time.Now().UnixNano()) | ||||||||||||||||||||||||||||||||||
| s.mu.Lock() | ||||||||||||||||||||||||||||||||||
| s.streamErr = err | ||||||||||||||||||||||||||||||||||
| s.state = StreamStateDisconnected | ||||||||||||||||||||||||||||||||||
| s.mu.Unlock() | ||||||||||||||||||||||||||||||||||
| s.client.log.Errorf("Pub/Sub stream failed permanently (topic=%s): %v", s.config.TopicName, err) | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
Comment on lines
+163
to
+172
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
connect/internal/impl/salesforce/salesforcegrpc/subscription.go Lines 160 to 168 in a66868a
connect/internal/impl/salesforce/input_salesforce_cdc.go Lines 978 to 983 in a66868a
So
Fix: have Related gap:
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in 6eb239f — and the finding was doubly right: the previous commit's failTerminal edit had silently failed to apply, which is exactly the class of gap the missing consumer-side test would have caught. failTerminal now genuinely wraps (the receive-loop terminal tests assert the marker type reaches StreamErr), and the suggested table-driven handleStreamErr test is added in the input package: bare/stream-wrapped InvalidArgument resets the checkpoint, terminal verdicts never touch it, other errors are fatal with the checkpoint intact. The same commit also fixes a panic this surfaced: lastError is an atomic.Value and one stream lifetime stores differently-typed errors (wrapped fmt, gRPC status, now TerminalStreamError), so all stores are boxed through one wrapper type, with a mixed-type regression test. |
||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| // failDecode routes an Avro decode failure: reconnect-and-redeliver while the | ||||||||||||||||||||||||||||||||||
| // position is under the consecutive-failure bound, terminal stream failure | ||||||||||||||||||||||||||||||||||
| // once it is exceeded. Schema-fetch failures do NOT come through here - they | ||||||||||||||||||||||||||||||||||
| // are transport-class errors governed by the reconnect policy, not evidence | ||||||||||||||||||||||||||||||||||
| // of an undecodable payload. | ||||||||||||||||||||||||||||||||||
| func (s *Subscription) failDecode(replayID []byte, failStream func(error), err error) { | ||||||||||||||||||||||||||||||||||
| if !s.recordDecodeFailure(replayID) { | ||||||||||||||||||||||||||||||||||
| failStream(err) | ||||||||||||||||||||||||||||||||||
| return | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
| s.failTerminal(fmt.Errorf("decoding event at replay position %x: %d consecutive failures, treating as permanently undecodable: %w", replayID, maxConsecutiveDecodeFailures, err)) | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| // positionFailures counts consecutive failures pinned to one replay | ||||||||||||||||||||||||||||||||||
| // position. A failure at a different position resets the count (the stream | ||||||||||||||||||||||||||||||||||
| // has moved on); a success clears it only when it lands at the tracked | ||||||||||||||||||||||||||||||||||
| // position - successes elsewhere must not reset it, since a redelivery cycle | ||||||||||||||||||||||||||||||||||
| // decodes the events around a permanently failing one successfully every | ||||||||||||||||||||||||||||||||||
| // time, which would keep the count oscillating below any bound forever. | ||||||||||||||||||||||||||||||||||
| // Callers must hold Subscription.mu. | ||||||||||||||||||||||||||||||||||
| type positionFailures struct { | ||||||||||||||||||||||||||||||||||
| count int | ||||||||||||||||||||||||||||||||||
| replayID []byte | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| func (p *positionFailures) record(replayID []byte) int { | ||||||||||||||||||||||||||||||||||
| if !bytes.Equal(replayID, p.replayID) { | ||||||||||||||||||||||||||||||||||
| p.replayID = append([]byte(nil), replayID...) | ||||||||||||||||||||||||||||||||||
| p.count = 0 | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
| p.count++ | ||||||||||||||||||||||||||||||||||
| return p.count | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| func (p *positionFailures) clear(replayID []byte) { | ||||||||||||||||||||||||||||||||||
| if p.replayID != nil && bytes.Equal(replayID, p.replayID) { | ||||||||||||||||||||||||||||||||||
| p.count = 0 | ||||||||||||||||||||||||||||||||||
| p.replayID = nil | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| // recordDecodeFailure counts an Avro-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() | ||||||||||||||||||||||||||||||||||
| return s.decodeFailures.record(replayID) >= maxConsecutiveDecodeFailures | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| // clearDecodeFailures resets the decode-failure count when the event at the | ||||||||||||||||||||||||||||||||||
| // tracked failing position decodes successfully. | ||||||||||||||||||||||||||||||||||
| func (s *Subscription) clearDecodeFailures(replayID []byte) { | ||||||||||||||||||||||||||||||||||
| s.mu.Lock() | ||||||||||||||||||||||||||||||||||
| s.decodeFailures.clear(replayID) | ||||||||||||||||||||||||||||||||||
| s.mu.Unlock() | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| // recordSchemaFailure counts a schema-fetch failure at the given replay | ||||||||||||||||||||||||||||||||||
| // position against the reconnect policy: with reconnect_max_attempts 0 | ||||||||||||||||||||||||||||||||||
| // (unlimited) it never trips - the user asked for indefinite retries on | ||||||||||||||||||||||||||||||||||
| // transport-class failures - but a bounded policy must also bound this path. | ||||||||||||||||||||||||||||||||||
| // Each reconnect here succeeds (the transport is healthy, the schema is | ||||||||||||||||||||||||||||||||||
| // not), so reconnectWithBackoff's own per-call budget never accumulates: a | ||||||||||||||||||||||||||||||||||
| // deterministically unfetchable schema (uncompilable SchemaJson, permanent | ||||||||||||||||||||||||||||||||||
| // NotFound) would otherwise reconnect-redeliver forever without ever | ||||||||||||||||||||||||||||||||||
| // surfacing an error, the same livelock the decode bound exists for. | ||||||||||||||||||||||||||||||||||
| func (s *Subscription) recordSchemaFailure(replayID []byte) bool { | ||||||||||||||||||||||||||||||||||
| s.mu.Lock() | ||||||||||||||||||||||||||||||||||
| defer s.mu.Unlock() | ||||||||||||||||||||||||||||||||||
| budget := s.client.maxReconnect | ||||||||||||||||||||||||||||||||||
| return budget > 0 && s.schemaFailures.record(replayID) >= budget | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| // clearSchemaFailures resets the schema-failure count when a fetch succeeds | ||||||||||||||||||||||||||||||||||
| // at the tracked failing position. | ||||||||||||||||||||||||||||||||||
| func (s *Subscription) clearSchemaFailures(replayID []byte) { | ||||||||||||||||||||||||||||||||||
| s.mu.Lock() | ||||||||||||||||||||||||||||||||||
| s.schemaFailures.clear(replayID) | ||||||||||||||||||||||||||||||||||
| 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. | ||||||||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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).
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 { | ||||||||||||||||||||||||||||||||||
|
|
@@ -148,17 +289,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 | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
|
|
@@ -177,19 +308,79 @@ 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. When a replay anchor exists, reconnect instead — | ||||||||||||||||||||||||||||||||||
| // lastReplayID advances per delivered event, so redelivery resumes | ||||||||||||||||||||||||||||||||||
| // exactly at the failing event. Both failure classes are bounded | ||||||||||||||||||||||||||||||||||
| // per replay position: Avro decode failures against a fetched | ||||||||||||||||||||||||||||||||||
| // schema (deterministic) by maxConsecutiveDecodeFailures, and | ||||||||||||||||||||||||||||||||||
| // schema-fetch failures by the reconnect policy itself | ||||||||||||||||||||||||||||||||||
| // (reconnect_max_attempts; 0 = retry forever) — counted here | ||||||||||||||||||||||||||||||||||
| // because each individual reconnect succeeds, so the per-call | ||||||||||||||||||||||||||||||||||
| // reconnect budget alone can never trip. On a fresh stream with | ||||||||||||||||||||||||||||||||||
| // NO anchor a reconnect would fall back to the configured preset | ||||||||||||||||||||||||||||||||||
| // and could silently drop the batch (LATEST), so schema fetches | ||||||||||||||||||||||||||||||||||
| // retry inline under the same policy and decode failures fail the | ||||||||||||||||||||||||||||||||||
| // stream terminally instead. | ||||||||||||||||||||||||||||||||||
| schema, err := s.client.schemaCache.GetSchema(ctx, event.SchemaId) | ||||||||||||||||||||||||||||||||||
| if err != nil && !s.anchored() { | ||||||||||||||||||||||||||||||||||
| // A reconnect here would resume via the configured preset and | ||||||||||||||||||||||||||||||||||
| // could silently drop this batch (LATEST), so the fetch retries | ||||||||||||||||||||||||||||||||||
| // inline instead - governed by the same reconnect policy a | ||||||||||||||||||||||||||||||||||
| // reconnect would use (reconnect_min_delay/_max_delay and | ||||||||||||||||||||||||||||||||||
| // reconnect_max_attempts; 0 = retry indefinitely). | ||||||||||||||||||||||||||||||||||
| for attempt := 0; err != nil; attempt++ { | ||||||||||||||||||||||||||||||||||
| if s.client.maxReconnect > 0 && attempt >= s.client.maxReconnect { | ||||||||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This PR makes The field still documents only the old meaning (
A user who sets a small bound to cap reconnect churn has no way to know they are also choosing when a transient schema-endpoint failure becomes fatal for the topic. Please extend the field description (and re-run Refs: CONTRIBUTING §1.1.1 (documentation with configuration examples), §1.2.3 (known limitations and edge cases documented).
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in b3c2a32 — the field description (and regenerated docs) now state that reconnect_max_attempts also bounds transient schema-fetch retries with a permanent topic failure when exhausted, and that deterministic schema/decode failures give up after a fixed number of attempts regardless of this setting. |
||||||||||||||||||||||||||||||||||
| break | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
| delay := grpcBackoffWithJitter(s.client.baseBackoff, s.client.maxBackoff, attempt) | ||||||||||||||||||||||||||||||||||
| s.client.log.Warnf("Schema fetch failed on a fresh stream with no replay anchor to redeliver from (topic=%s, schemaID=%s), retrying inline in %v (attempt %d): %v", s.config.TopicName, event.SchemaId, delay, attempt+1, err) | ||||||||||||||||||||||||||||||||||
| t := time.NewTimer(delay) | ||||||||||||||||||||||||||||||||||
| select { | ||||||||||||||||||||||||||||||||||
| case <-t.C: | ||||||||||||||||||||||||||||||||||
| case <-streamCtx.Done(): | ||||||||||||||||||||||||||||||||||
| t.Stop() | ||||||||||||||||||||||||||||||||||
| return | ||||||||||||||||||||||||||||||||||
| case <-ctx.Done(): | ||||||||||||||||||||||||||||||||||
| t.Stop() | ||||||||||||||||||||||||||||||||||
| return | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
| schema, err = s.client.schemaCache.GetSchema(ctx, event.SchemaId) | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
Comment on lines
+387
to
+423
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The unanchored inline schema retry never refreshes credentials, so an expired token stalls the topic indefinitely. This loop re-fetches the schema with the credentials already held by Failure scenario: no persisted replay ID for the topic (first run, or right after Suggested fix: in this loop, record the error (
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in 04c938f — the inline retry now mirrors everything reconnectWithBackoff does short of reconnecting: each failure is recorded in lastError (so Health() sees the stall), and an Unauthenticated failure triggers refreshAuth before the next attempt, propagating fresh credentials to the schema cache via UpdateAuth. Covered by a recovery test: GetSchema fails Unauthenticated until the refresh hook fires, then the event delivers inline with zero preset reconnects, with Health visibility asserted. Cross-checked the two branches for any remaining asymmetry — backoff/jitter, error recording, and auth refresh are the complete set reconnectWithBackoff contributes, and all three are now mirrored. |
||||||||||||||||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||||||||||||||||
| s.eventsDecodeErrors.Add(1) | ||||||||||||||||||||||||||||||||||
| s.failTerminal(fmt.Errorf("fetching schema for the first event of a fresh stream (schemaID=%s): no replay anchor to redeliver from and reconnect_max_attempts (%d) exhausted: %w", event.SchemaId, s.client.maxReconnect, err)) | ||||||||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Terminal schema-fetch errors now put a wrapped gRPC status into Both new terminal schema paths (this one and subscription.go:365) wrap the error from connect/internal/impl/salesforce/input_salesforce_cdc.go Lines 972 to 987 in f9f6346
Suggested fix: make the terminal decode/schema failures distinguishable (a sentinel or typed error checked with
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in a66868a — failTerminal now wraps its verdicts in a TerminalStreamError marker (Unwrap preserved for transparency), and handleStreamErr checks for it before the InvalidArgument stale-replay heuristic: a terminal schema/decode verdict can never clear the persisted replay ID. Terminal errors propagate as fatal topic errors with the checkpoint intact, so the stale-replay reset is once again reachable only by genuine stream-level errors. |
||||||||||||||||||||||||||||||||||
| return | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
Comment on lines
+381
to
+428
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The unanchored schema-fetch retry budget reuses an unrelated constant and bypasses the user's reconnect policy. Two problems with this loop:
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in 810750f on both points. The retry budget is now reconnect_max_attempts itself — 0 retries indefinitely, matching what the user asked for on transport failures — with the configured jittered backoff between attempts, so a schema-endpoint blip on a fresh stream no longer terminally kills the topic under an unlimited policy. When a bounded policy is exhausted the terminal error names the schema stage and the attempt budget rather than claiming the payload is undecodable. The stale maxConsecutiveDecodeFailures doc is rewritten for what it bounds post-split (deterministic per-position decode failures only). Reconnecting stays deliberately off this path: with no anchor, the preset could silently drop the batch. |
||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||||||||||||||||
| s.client.log.Errorf("get schema for event (schemaID=%s): %v", event.SchemaId, err) | ||||||||||||||||||||||||||||||||||
| s.eventsDecodeErrors.Add(1) | ||||||||||||||||||||||||||||||||||
| continue | ||||||||||||||||||||||||||||||||||
| // Bounded by the reconnect policy: each reconnect below | ||||||||||||||||||||||||||||||||||
| // succeeds (the transport is healthy), so the per-call | ||||||||||||||||||||||||||||||||||
| // reconnect budget never accumulates - the per-position count | ||||||||||||||||||||||||||||||||||
| // is what makes reconnect_max_attempts meaningful for a | ||||||||||||||||||||||||||||||||||
| // deterministically unfetchable schema. 0 = retry forever. | ||||||||||||||||||||||||||||||||||
| if s.recordSchemaFailure(consumerEvent.ReplayId) { | ||||||||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The new schema-fetch bound never engages under the shipped default, so the livelock this commit targets persists out of the box.
connect/internal/impl/salesforce/config.go Lines 179 to 181 in f9f6346
So with the default config a deterministically unfetchable schema (uncompilable Suggested fix: give this path its own bound analogous to
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in a66868a. Schema failures are now classified: deterministic ones — NotFound, InvalidArgument, PermissionDenied, Unimplemented, and compile failures (no gRPC status) — cannot heal by retrying, so they trip at maxConsecutiveDecodeFailures regardless of the reconnect policy; the shipped default no longer livelocks. Transport-class failures stay governed by the policy (0 = indefinite retries), preserving the earlier outage-must-not-kill-the-topic fix. Both the anchored and unanchored paths apply the same classification, and the counting test now covers the deterministic-under-default-policy case. |
||||||||||||||||||||||||||||||||||
| s.failTerminal(fmt.Errorf("fetching schema (schemaID=%s) failed %d consecutive times at replay position %x (reconnect_max_attempts exhausted): %w", event.SchemaId, s.client.maxReconnect, consumerEvent.ReplayId, err)) | ||||||||||||||||||||||||||||||||||
| return | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
| failStream(fmt.Errorf("get schema for event (schemaID=%s): %w", event.SchemaId, err)) | ||||||||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Turning a schema/decode failure into a reconnect does not produce the "stalls the topic loudly" behaviour the comment above describes — for a permanently undecodable payload it produces an unbounded redelivery loop.
Suggested fix: track consecutive decode failures at the same replay position and, once a small bound is exceeded, set
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in 9cce455. Consecutive schema/decode failures are now counted per replay position (recordDecodeFailure, cleared on any successful decode or when the failing position changes); past maxConsecutiveDecodeFailures=5 the stream fails terminally — streamErr set and state Disconnected, so subscribeAndPump's health tick surfaces it and the pipeline stops loudly instead of redelivering the batch prefix forever. Transient schema-fetch errors still heal by bounded redelivery. The comment now describes the actual behaviour. |
||||||||||||||||||||||||||||||||||
| return | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
Comment on lines
430
to
443
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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" ( Failure scenario — a schema whose
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 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 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).
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. |
||||||||||||||||||||||||||||||||||
| s.clearSchemaFailures(consumerEvent.ReplayId) | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| 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 | ||||||||||||||||||||||||||||||||||
| if !s.anchored() { | ||||||||||||||||||||||||||||||||||
| s.failTerminal(fmt.Errorf("decoding the first event of a fresh stream (schemaID=%s, replay position %x): payload is undecodable and no replay anchor exists to redeliver from: %w", event.SchemaId, consumerEvent.ReplayId, err)) | ||||||||||||||||||||||||||||||||||
| return | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
| 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, | ||||||||||||||||||||||||||||||||||
|
|
@@ -209,14 +400,27 @@ 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()) | ||||||||||||||||||||||||||||||||||
| // Advance the replay anchor past this event: CUSTOM replay is | ||||||||||||||||||||||||||||||||||
| // exclusive-after, so a reconnect now redelivers from exactly | ||||||||||||||||||||||||||||||||||
| // the next (possibly failing) event - no lost first batch, no | ||||||||||||||||||||||||||||||||||
| // re-emitted prefix. | ||||||||||||||||||||||||||||||||||
| s.mu.Lock() | ||||||||||||||||||||||||||||||||||
| s.lastReplayID = pubsubEvent.ReplayID | ||||||||||||||||||||||||||||||||||
| s.mu.Unlock() | ||||||||||||||||||||||||||||||||||
| 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 | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This doc comment states the opposite of what the code now does.
recordSchemaFailurebounds deterministic schema-fetch failures by exactly this constant, explicitly regardless of the reconnect policy:connect/internal/impl/salesforce/salesforcegrpc/subscription.go
Lines 275 to 281 in a66868a
and so does the unanchored inline-retry path:
connect/internal/impl/salesforce/salesforcegrpc/subscription.go
Lines 372 to 376 in a66868a
failDecode's doc carries the same stale claim ("Schema-fetch failures do NOT come through here - they are transport-class errors governed by the reconnect policy", L172-L174): the first clause is still true, but the "transport-class / governed by the reconnect policy" characterisation no longer holds for the deterministic class.A comment that contradicts the failure-handling policy it documents is a maintenance hazard on exactly the semantics this PR is iterating on —
CONTRIBUTING.md§3.1.2 (self-documenting code) and §1.2.3 (known limitations and edge cases documented accurately). Suggest rewording both to say that only transient schema-fetch failures are governed by the reconnect policy, while deterministic ones share this bound, and renaming the constant to something that covers both paths it now bounds.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in 6eb239f — the constant is renamed maxDeterministicPositionFailures (it bounds both decode failures and deterministic schema failures), and the failDecode, struct-field, and batch-loop comments now all state the deterministic/transient split: only transient schema-fetch failures are governed by the reconnect policy.