Skip to content

Commit 089a1a4

Browse files
authored
restore async replication connection keepalive when PullRecords is not active (#4405)
Prior to #4324, we had an async `maintainReplConn` goroutine that was running on a ticker every 15 seconds. When we moved the keepalive inside of PullRecords, we introduced an edge case where there can be _a large time gap_ between PullRecords where we are no longer keeping the connection alive(e.g. when normalize backpressure kicks in). This led to a new failure mode when PullRecords resumes and tries to read from PG, it hit wal_sender_timeout error. This PR brings back the async goroutine ~but only run the keepalive when PullRecords is not active; otherwise, it let the PullRecords handle the keepalive logic in the loop.~ The keepalive is hard-coded to 15 seconds. When we merge #4363 later, we can use the computed keepalive instead. ~Also note it's still safe to remove `replLock` because this ReplPing only runs when PullRecords is inactive, so there should be no lock contention (but doesn't hurt if we wish to keep it either for safety).~ There can be edge cases (see discussion below) where a race condition can happen, so we will want to keep the replLock around when. Also note that #4404 addresses slightly separate edge case, with the goal of ensuring ReplPing has full coverage _inside_ a PullRecord cycle.
1 parent ad79aec commit 089a1a4

2 files changed

Lines changed: 31 additions & 4 deletions

File tree

flow/connectors/postgres/cdc.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -737,8 +737,8 @@ func PullCdcRecords[Items model.Items](
737737

738738
if err != nil && pgconn.Timeout(err) {
739739
// On timeout, always send a ping before moving on to read more messages
740-
if err := p.ReplPing(ctx); err != nil {
741-
return fmt.Errorf("ReplPing failed: %w", err)
740+
if err := sendStandbyAfterReplLock("receive-timeout"); err != nil {
741+
return err
742742
}
743743
// After that, we let the condition checks at the beginging of the loop,
744744
// specifically "if we are past the next standby deadline (?)" to

flow/connectors/postgres/postgres_source.go

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,11 +80,38 @@ func (c *PostgresConnector) SetupReplConn(ctx context.Context, env map[string]st
8080
return err
8181
}
8282
c.replConn = conn
83+
go c.runReplConnKeepalive(ctx, 15*time.Second, c.ReplPing)
8384
return nil
8485
}
8586

86-
// To keep connection alive between sync batches.
87-
// By default postgres drops connection after 1 minute of inactivity.
87+
// runReplConnKeepalive pings the replication connection on a ticker so the
88+
// walsender doesn't hit wal_sender_timeout. This happens whenever keepalive
89+
// from the PullRecords stop: (1) between PullRecords calls (2) within
90+
// PullRecords when AddRecord blocks on a full record stream.
91+
//
92+
// While PullRecords holds replLock inside ReceiveMessage, ReplPing's TryLock
93+
// makes it a no-op. This is safe because ReceiveMessage will always run its
94+
// own keepalive on timeout.
95+
func (c *PostgresConnector) runReplConnKeepalive(
96+
ctx context.Context,
97+
interval time.Duration,
98+
keepalive func(context.Context) error,
99+
) {
100+
ticker := time.NewTicker(interval)
101+
defer ticker.Stop()
102+
for {
103+
select {
104+
case <-ctx.Done():
105+
return
106+
case <-ticker.C:
107+
if err := keepalive(ctx); err != nil {
108+
c.logger.Warn("replication keepalive failed", slog.Any("error", err))
109+
}
110+
}
111+
}
112+
}
113+
114+
// ReplPing sends a standby status update so Postgres doesn't terminate the walsender for inactivity.
88115
func (c *PostgresConnector) ReplPing(ctx context.Context) error {
89116
if c.replLock.TryLock() {
90117
defer c.replLock.Unlock()

0 commit comments

Comments
 (0)