Skip to content

Commit 0cb30a3

Browse files
alexluongclaude
andauthored
fix: prevent GCP Pub/Sub SDK receipt modack from overriding visibility timeout (#869)
* fix: prevent GCP Pub/Sub SDK receipt modack from overriding visibility timeout The GCP Pub/Sub native SDK sends a ModifyAckDeadline ("receipt modack") when it first receives a message from StreamingPull. The deadline value is based on the SDK's internal p99 ack-latency distribution, with a minimum of 10s. This overrides the subscription's ackDeadlineSeconds on the server side. With MaxExtension=-1 (which disables keep-alive extensions), the effective visibility timeout becomes 10s instead of the configured 60s. Any handler that takes >10s triggers premature Pub/Sub redelivery, causing duplicate processing and DLQ entries. Add VisibilityTimeout to QueueConfig and set MinExtensionPeriod to match it in the GCP Pub/Sub subscription, preventing the receipt modack from shortening the deadline below the configured value. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: shut down subscription in init test to prevent message stealing The first sub-test left its subscription running, causing it to steal messages published by the second sub-test. The stolen message sat until the ack deadline expired and Pub/Sub redelivered it — 60s with our MinExtensionPeriod fix, 10s on main. Test now runs in ~1s. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 1bd1e1f commit 0cb30a3

7 files changed

Lines changed: 34 additions & 12 deletions

File tree

internal/config/mqconfig_aws.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package config
33
import (
44
"context"
55
"fmt"
6+
"time"
67

78
"github.com/hookdeck/outpost/internal/mqinfra"
89
"github.com/hookdeck/outpost/internal/mqs"
@@ -51,6 +52,7 @@ func (c *AWSSQSConfig) ToQueueConfig(ctx context.Context, queueType string) (*mq
5152
ServiceAccountCredentials: c.getCredentials(),
5253
Topic: c.getQueueName(queueType),
5354
},
55+
VisibilityTimeout: 60 * time.Second,
5456
}, nil
5557
}
5658

internal/config/mqconfig_azure.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package config
22

33
import (
44
"context"
5+
"time"
56

67
"github.com/hookdeck/outpost/internal/mqinfra"
78
"github.com/hookdeck/outpost/internal/mqs"
@@ -96,6 +97,7 @@ func (c *AzureServiceBusConfig) ToQueueConfig(ctx context.Context, queueType str
9697
Topic: topic,
9798
Subscription: subscription,
9899
},
100+
VisibilityTimeout: 60 * time.Second,
99101
}, nil
100102
}
101103

@@ -110,6 +112,7 @@ func (c *AzureServiceBusConfig) ToQueueConfig(ctx context.Context, queueType str
110112
ResourceGroup: c.ResourceGroup,
111113
Namespace: c.Namespace,
112114
},
115+
VisibilityTimeout: 60 * time.Second,
113116
}, nil
114117
}
115118

internal/config/mqconfig_gcp.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package config
22

33
import (
44
"context"
5+
"time"
56

67
"github.com/hookdeck/outpost/internal/mqinfra"
78
"github.com/hookdeck/outpost/internal/mqs"
@@ -57,6 +58,7 @@ func (c *GCPPubSubConfig) ToQueueConfig(ctx context.Context, queueType string) (
5758
TopicID: c.getTopicByQueueType(queueType),
5859
SubscriptionID: c.getSubscriptionByQueueType(queueType),
5960
},
61+
VisibilityTimeout: 60 * time.Second,
6062
}, nil
6163
}
6264

internal/config/mqconfig_rabbitmq.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package config
22

33
import (
44
"context"
5+
"time"
56

67
"github.com/hookdeck/outpost/internal/mqinfra"
78
"github.com/hookdeck/outpost/internal/mqs"
@@ -42,6 +43,7 @@ func (c *RabbitMQConfig) ToQueueConfig(ctx context.Context, queueType string) (*
4243
Exchange: c.Exchange,
4344
Queue: c.getQueueName(queueType),
4445
},
46+
VisibilityTimeout: 60 * time.Second,
4547
}, nil
4648
}
4749

internal/mqs/queue.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"errors"
66
"sync"
7+
"time"
78

89
"go.opentelemetry.io/otel"
910
"go.opentelemetry.io/otel/trace"
@@ -17,6 +18,8 @@ type QueueConfig struct {
1718
GCPPubSub *GCPPubSubConfig
1819
RabbitMQ *RabbitMQConfig
1920
InMemory *InMemoryConfig // mainly for testing purposes
21+
22+
VisibilityTimeout time.Duration
2023
}
2124

2225
type InMemoryConfig struct {
@@ -88,7 +91,7 @@ func NewQueue(config *QueueConfig) Queue {
8891
} else if config.AzureServiceBus != nil {
8992
return NewAzureServiceBusQueue(config.AzureServiceBus)
9093
} else if config.GCPPubSub != nil {
91-
return NewGCPPubSubQueue(config.GCPPubSub)
94+
return NewGCPPubSubQueue(config.GCPPubSub, config.VisibilityTimeout)
9295
} else if config.RabbitMQ != nil {
9396
return NewRabbitMQQueue(config.RabbitMQ)
9497
} else {

internal/mqs/queue_gcppubsub.go

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -23,22 +23,24 @@ type GCPPubSubConfig struct {
2323
}
2424

2525
type GCPPubSubQueue struct {
26-
once *sync.Once
27-
base *wrappedBaseQueue
28-
config *GCPPubSubConfig
29-
topic *pubsub.Topic
30-
cleanupFns []func()
26+
once *sync.Once
27+
base *wrappedBaseQueue
28+
config *GCPPubSubConfig
29+
visibilityTimeout time.Duration
30+
topic *pubsub.Topic
31+
cleanupFns []func()
3132
}
3233

3334
var _ Queue = &GCPPubSubQueue{}
3435

35-
func NewGCPPubSubQueue(config *GCPPubSubConfig) *GCPPubSubQueue {
36+
func NewGCPPubSubQueue(config *GCPPubSubConfig, visibilityTimeout time.Duration) *GCPPubSubQueue {
3637
var once sync.Once
3738
return &GCPPubSubQueue{
38-
config: config,
39-
once: &once,
40-
base: newWrappedBaseQueue(),
41-
cleanupFns: []func(){},
39+
config: config,
40+
visibilityTimeout: visibilityTimeout,
41+
once: &once,
42+
base: newWrappedBaseQueue(),
43+
cleanupFns: []func(){},
4244
}
4345
}
4446

@@ -152,6 +154,14 @@ func (q *GCPPubSubQueue) Subscribe(ctx context.Context, opts ...SubscribeOption)
152154
// logic and do not want the SDK silently extending message leases — if a
153155
// handler exceeds the ack deadline, the message should be redelivered.
154156
sub.ReceiveSettings.MaxExtension = -1 * time.Second
157+
// The native SDK sends a "receipt modack" (ModifyAckDeadline) when it first
158+
// receives a message, using its internal ack-latency p99 as the deadline
159+
// (minimum 10s). This overrides the subscription's ackDeadlineSeconds on the
160+
// server side. Without MinExtensionPeriod, a subscription configured with a
161+
// 60s ack deadline effectively becomes 10s, causing premature redelivery for
162+
// any handler that takes >10s. Setting MinExtensionPeriod to match the
163+
// subscription's visibility timeout prevents this override.
164+
sub.ReceiveSettings.MinExtensionPeriod = q.visibilityTimeout
155165

156166
msgChan := make(chan *Message, concurrency)
157167
subCtx, cancel := context.WithCancel(ctx)

internal/mqs/queue_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,12 +103,12 @@ func testMQ(t *testing.T, makeConfig func() mqs.QueueConfig) {
103103
defer cleanup()
104104
subscription, err := queue.Subscribe(context.Background())
105105
require.Nil(t, err)
106+
defer subscription.Shutdown(context.Background())
106107
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
107108
defer cancel()
108109
msg, err := subscription.Receive(ctx)
109110
assert.Nil(t, msg)
110111
assert.Equal(t, err, context.DeadlineExceeded)
111-
defer cleanup()
112112
})
113113

114114
t.Run("should publish and receive message", func(t *testing.T) {

0 commit comments

Comments
 (0)