Skip to content

Commit 14c3887

Browse files
authored
feat(pubsub/v2): add subscriber shutdown options (#12829)
This introduces the ability to tell the client library how you want your messages to behave when shutdown is initiated. You can configure the behavior as `ShutdownOptions.Behavior` as `ShutdownBehaviorWaitForProcessing` or `ShutdownBehaviorNackImmediately`. In addition, you can specify `ShutdownOptions.Timeout` to configure how long you want to wait for messages to be processed, or provide a timeout to the nack calls before returning.
1 parent a67a146 commit 14c3887

7 files changed

Lines changed: 344 additions & 30 deletions

File tree

‎pubsub/v2/integration_test.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -317,7 +317,7 @@ func TestIntegration_CancelReceive(t *testing.T) {
317317
return
318318
default:
319319
publisher.Publish(ctx, &Message{Data: []byte("some msg")})
320-
time.Sleep(time.Second)
320+
time.Sleep(10 * time.Second)
321321
}
322322
}
323323
}()

‎pubsub/v2/iterator.go‎

Lines changed: 29 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,8 @@ func (it *messageIterator) receive(maxToPull int32) ([]*Message, error) {
263263

264264
var rmsgs []*pb.ReceivedMessage
265265
var err error
266+
// This is a blocking call, because reading from the stream blocks.
267+
// We want to make sure this is canceled.
266268
rmsgs, err = it.recvMessages()
267269
// If stopping the iterator results in the grpc stream getting shut down and
268270
// returning an error here, treat the same as above and return EOF.
@@ -359,9 +361,9 @@ func (it *messageIterator) receive(maxToPull int32) ([]*Message, error) {
359361

360362
// If exactly once is enabled, we should wait until modack responses are successes
361363
// before attempting to process messages.
362-
it.sendModAck(ackIDs, deadline, false, true)
364+
ctx := context.Background()
365+
it.sendModAck(ctx, ackIDs, deadline, false, true)
363366
for ackID, ar := range ackIDs {
364-
ctx := context.Background()
365367
_, err := ar.Get(ctx)
366368
if err != nil {
367369
delete(pendingMessages, ackID)
@@ -508,16 +510,16 @@ func (it *messageIterator) sender() {
508510
}
509511
if sendNacks {
510512
// Nack indicated by modifying the deadline to zero.
511-
it.sendModAck(nacks, 0, false, false)
513+
it.sendModAck(context.Background(), nacks, 0, false, false)
512514
}
513515
if sendModAcks {
514-
it.sendModAck(modAcks, dl, true, false)
516+
it.sendModAck(context.Background(), modAcks, dl, true, false)
515517
}
516518
if sendPing {
517519
it.pingStream()
518520
}
519521
if sendReceipt {
520-
it.sendModAck(receipts, dl, true, true)
522+
it.sendModAck(context.Background(), receipts, dl, true, true)
521523
}
522524
}
523525
}
@@ -559,7 +561,7 @@ type ackFunc = func(ctx context.Context, subName string, ackIds []string) error
559561
type ackRecordStat = func(ctx context.Context, toSend []string)
560562
type retryAckFunc = func(toRetry map[string]*ipubsub.AckResult)
561563

562-
func (it *messageIterator) sendAckWithFunc(m map[string]*AckResult, ackFunc ackFunc, retryAckFunc retryAckFunc, ackRecordStat ackRecordStat) {
564+
func (it *messageIterator) sendAckWithFunc(ctx context.Context, m map[string]*AckResult, ackFunc ackFunc, retryAckFunc retryAckFunc, ackRecordStat ackRecordStat) {
563565
ackIDs := make([]string, 0, len(m))
564566
for ackID := range m {
565567
ackIDs = append(ackIDs, ackID)
@@ -575,9 +577,7 @@ func (it *messageIterator) sendAckWithFunc(m map[string]*AckResult, ackFunc ackF
575577
go func(toSend []string) {
576578
defer wg.Done()
577579
ackRecordStat(it.ctx, toSend)
578-
// Use context.Background() as the call's context, not it.ctx. We don't
579-
// want to cancel this RPC when the iterator is stopped.
580-
cctx, cancel2 := context.WithTimeout(context.Background(), 60*time.Second)
580+
cctx, cancel2 := context.WithTimeout(ctx, 60*time.Second)
581581
defer cancel2()
582582
err := ackFunc(cctx, it.subName, toSend)
583583
if exactlyOnceDelivery {
@@ -602,7 +602,8 @@ func (it *messageIterator) sendAckWithFunc(m map[string]*AckResult, ackFunc ackF
602602
// sendAck is used to confirm acknowledgement of a message. If exactly once delivery is
603603
// enabled, we'll retry these messages for a short duration in a goroutine.
604604
func (it *messageIterator) sendAck(m map[string]*AckResult) {
605-
it.sendAckWithFunc(m, func(ctx context.Context, subName string, ackIDs []string) error {
605+
ctx := context.Background()
606+
it.sendAckWithFunc(ctx, m, func(ctx context.Context, subName string, ackIDs []string) error {
606607
// For each ackID (message), setup links to the main subscribe span.
607608
// If this is a nack, also remove it from active spans.
608609
// If the ackID is not found, don't create any more spans.
@@ -667,7 +668,7 @@ func (it *messageIterator) sendAck(m map[string]*AckResult) {
667668
// percentile in order to capture the highest amount of time necessary without
668669
// considering 1% outliers. If the ModAck RPC fails and exactly once delivery is
669670
// enabled, we retry it in a separate goroutine for a short duration.
670-
func (it *messageIterator) sendModAck(m map[string]*AckResult, deadline time.Duration, logOnInvalid, isReceipt bool) {
671+
func (it *messageIterator) sendModAck(ctx context.Context, m map[string]*AckResult, deadline time.Duration, logOnInvalid, isReceipt bool) {
671672
deadlineSec := int32(deadline / time.Second)
672673
isNack := deadline == 0
673674
var spanName, eventStart, eventEnd string
@@ -680,7 +681,7 @@ func (it *messageIterator) sendModAck(m map[string]*AckResult, deadline time.Dur
680681
eventStart = eventModackStart
681682
eventEnd = eventModackEnd
682683
}
683-
it.sendAckWithFunc(m, func(ctx context.Context, subName string, ackIDs []string) error {
684+
it.sendAckWithFunc(ctx, m, func(ctx context.Context, subName string, ackIDs []string) error {
684685
if it.enableTracing {
685686
// For each ackID (message), link back to the main subscribe span.
686687
// If this is a nack, also remove it from active spans.
@@ -1016,3 +1017,19 @@ func processResults(errorStatus *status.Status, ackResMap map[string]*AckResult,
10161017
}
10171018
return completedResults, retryResults
10181019
}
1020+
1021+
// nackInventory nacks all the current messages being held by the iterator.
1022+
// This does not stop the existing callbacks, and does not try to remove
1023+
// messages from the scheduler. This is used specifically for when the
1024+
// user configured ShutdownOptions is set to NackImmediately
1025+
func (it *messageIterator) nackInventory(ctx context.Context) {
1026+
it.mu.Lock()
1027+
defer it.mu.Unlock()
1028+
1029+
toNack := make(map[string]*ipubsub.AckResult)
1030+
for ackID := range it.keepAliveDeadlines {
1031+
// Use a dummy AckResult since we don't propagate nacks back to the user.
1032+
toNack[ackID] = newSuccessAckResult()
1033+
}
1034+
it.sendModAck(ctx, toNack, 0, false, false)
1035+
}

‎pubsub/v2/shutdown.go‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
// Copyright 2025 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package pubsub
16+
17+
import "time"
18+
19+
// ShutdownOptions configures the shutdown behavior of the subscriber.
20+
// When ShutdownOptions is nil, the client library will
21+
// assume disabled/infinite timeout.
22+
//
23+
// Warning: The interaction between Timeout and Behavior might be surprising.
24+
// Read about the interaction of these below to ensure you
25+
// get the desired behavior.
26+
type ShutdownOptions struct {
27+
// Timeout specifies the time the subscriber should wait
28+
// before forcefully shutting down..
29+
// In ShutdownBehaviorNackImmediately mode, this configures the timeout
30+
// for message nacks before shutting down.
31+
//
32+
// Set to zero to immediately shutdown.
33+
// Set to a negative value to disable timeout.
34+
// Both zero and negative values overrides the ShutdownBehavior.
35+
Timeout time.Duration
36+
37+
// Behavior defines the strategy the subscriber should use when
38+
// shutting down (wait or nack messages).
39+
// When ShutdownOptions is set, but Timeout is unspecified, the default zero-value
40+
// will result in immediate shutdown. When needing a specific a behavior,
41+
// always set a non-zero Timeout.
42+
Behavior ShutdownBehavior
43+
}
44+
45+
// ShutdownBehavior defines the strategy the subscriber should take when
46+
// shutting down. Current options are graceful shutdown vs nacking messages.
47+
type ShutdownBehavior int
48+
49+
const (
50+
// ShutdownBehaviorWaitForProcessing means the subscriber client will wait for
51+
// outstanding messages to be processed.
52+
ShutdownBehaviorWaitForProcessing = iota
53+
54+
// ShutdownBehaviorNackImmediately means the subscriber client will nack all
55+
// outstanding messages before closing.
56+
ShutdownBehaviorNackImmediately
57+
)

‎pubsub/v2/shutdown_test.go‎

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
// Copyright 2025 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package pubsub
16+
17+
import (
18+
"context"
19+
"sync"
20+
"testing"
21+
"time"
22+
23+
pb "cloud.google.com/go/pubsub/v2/apiv1/pubsubpb"
24+
)
25+
26+
func TestShutdown_NackImmediately(t *testing.T) {
27+
t.Parallel()
28+
ctx, cancel := context.WithCancel(context.Background())
29+
defer cancel()
30+
client, srv := newFake(t)
31+
defer client.Close()
32+
defer srv.Close()
33+
34+
topic := mustCreateTopic(t, client, "projects/p/topics/t")
35+
sub := mustCreateSubConfig(t, client, &pb.Subscription{
36+
Name: "projects/p/subscriptions/s",
37+
Topic: topic.String(),
38+
})
39+
40+
// Part of this test: pretend to extend the min duration quite a bit so we can test
41+
// if the message has been properly nacked.
42+
sub.ReceiveSettings.MinDurationPerAckExtension = 10 * time.Minute
43+
sub.ReceiveSettings.ShutdownOptions = &ShutdownOptions{
44+
Behavior: ShutdownBehaviorNackImmediately,
45+
Timeout: 1 * time.Minute,
46+
}
47+
var wg sync.WaitGroup
48+
wg.Add(1)
49+
go func() {
50+
defer wg.Done()
51+
_, err := topic.Publish(ctx, &Message{Data: []byte("m1")}).Get(ctx)
52+
if err != nil {
53+
t.Errorf("Publish().Get() got err: %v", err)
54+
}
55+
}()
56+
wg.Wait()
57+
58+
cctx, ccancel := context.WithCancel(ctx)
59+
go sub.Receive(cctx, func(ctx context.Context, m *Message) {
60+
// First time receiving, cancel the context to trigger shutdown.
61+
// Don't cancel away to avoid race condition with fake.
62+
time.AfterFunc(2*time.Second, ccancel)
63+
})
64+
65+
// Wait for the message to be redelivered.
66+
time.Sleep(5 * time.Second)
67+
68+
var received int
69+
var receiveLock sync.Mutex
70+
ctx2, cancel := context.WithTimeout(ctx, 30*time.Second)
71+
err := sub.Receive(ctx2, func(ctx context.Context, m *Message) {
72+
receiveLock.Lock()
73+
defer receiveLock.Unlock()
74+
received++
75+
m.Ack()
76+
cancel()
77+
})
78+
if err != nil {
79+
t.Errorf("got err from recv: %v", err)
80+
}
81+
if received != 1 {
82+
t.Errorf("expected 1 delivery, got %d", received)
83+
}
84+
}
85+
86+
func TestShutdown_WaitForProcessing(t *testing.T) {
87+
t.Parallel()
88+
tests := []struct {
89+
name string
90+
shutdownTimeout time.Duration
91+
expectedTimeout time.Duration
92+
minTime time.Duration
93+
}{
94+
{
95+
name: "BailImmediately",
96+
shutdownTimeout: 0 * time.Second,
97+
expectedTimeout: 5 * time.Second,
98+
},
99+
{
100+
name: "WithTimeout",
101+
shutdownTimeout: 5 * time.Second,
102+
expectedTimeout: 6 * time.Second,
103+
minTime: 4 * time.Second,
104+
},
105+
}
106+
107+
for _, tc := range tests {
108+
t.Run(tc.name, func(t *testing.T) {
109+
ctx, cancel := context.WithCancel(context.Background())
110+
defer cancel()
111+
client, srv := newFake(t)
112+
defer client.Close()
113+
defer srv.Close()
114+
115+
topic := mustCreateTopic(t, client, "projects/p/topics/t")
116+
sub := mustCreateSubConfig(t, client, &pb.Subscription{
117+
Name: "projects/p/subscriptions/s",
118+
Topic: topic.String(),
119+
})
120+
sub.ReceiveSettings.ShutdownOptions = &ShutdownOptions{
121+
Behavior: ShutdownBehaviorWaitForProcessing,
122+
Timeout: tc.shutdownTimeout,
123+
}
124+
processingTime := 1 * time.Hour
125+
126+
var wg sync.WaitGroup
127+
wg.Add(1)
128+
go func() {
129+
defer wg.Done()
130+
_, err := topic.Publish(ctx, &Message{Data: []byte("m1")}).Get(ctx)
131+
if err != nil {
132+
t.Errorf("Publish().Get() got err: %v", err)
133+
}
134+
}()
135+
wg.Wait()
136+
137+
cctx, cancel2 := context.WithCancel(ctx)
138+
defer cancel2()
139+
start := time.Now()
140+
sub.Receive(cctx, func(ctx context.Context, m *Message) {
141+
cancel()
142+
// Simulate a long processing message that we want to cancel right away.
143+
// The message should never be acked since we expect the client to bail early.
144+
time.Sleep(processingTime)
145+
m.Ack()
146+
})
147+
148+
elapsed := time.Since(start)
149+
if elapsed > tc.expectedTimeout {
150+
t.Errorf("expected quick cancellation, elapsed: %v, want less than: %v", elapsed, tc.expectedTimeout)
151+
}
152+
if tc.minTime > 0 && elapsed < tc.minTime {
153+
t.Errorf("expected to wait for shutdown, elapsed: %v, want greater than: %v", elapsed, tc.minTime)
154+
}
155+
})
156+
}
157+
}

‎pubsub/v2/streaming_pull_test.go‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,8 @@ func testStreamingPullIteration(t *testing.T, client *Client, server *mockServer
114114
}
115115
}
116116
server.wait()
117+
server.mu.Lock()
118+
defer server.mu.Unlock()
117119
for i := 0; i < len(msgs); i++ {
118120
id := msgs[i].AckId
119121
if i%2 == 0 {
@@ -183,8 +185,8 @@ func TestStreamingPullCancel(t *testing.T) {
183185
}
184186

185187
func TestStreamingPullRetry(t *testing.T) {
186-
// Check that we retry on io.EOF or Unavailable.
187188
t.Parallel()
189+
// Check that we retry on io.EOF or Unavailable.
188190
client, server := newMock(t)
189191
defer server.srv.Close()
190192
defer client.Close()

0 commit comments

Comments
 (0)