blob: 529642f31368b86597a56f3246fadc5c7fdab279 [file] [edit]
// Copyright 2025 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package pubsub
import (
"context"
"errors"
"fmt"
"io"
"strings"
"sync"
"time"
ipubsub "cloud.google.com/go/internal/pubsub"
"cloud.google.com/go/pubsub/v2/internal/scheduler"
"github.com/google/uuid"
semconv "go.opentelemetry.io/otel/semconv/v1.26.0"
"go.opentelemetry.io/otel/trace"
"golang.org/x/sync/errgroup"
)
// Subscriber is a subscriber client that references a subscription.
type Subscriber struct {
c *Client
// The fully qualified identifier for the subscription, in the format "projects/<projid>/subscriptions/<name>"
name string
// Settings for pulling messages. Configure these before calling Receive.
ReceiveSettings ReceiveSettings
mu sync.Mutex
receiveActive bool
// clientID to be used across all streaming pull connections that are created.
// This indicates to the server that any guarantees made for a stream that
// disconnected will be made for the stream that is created to replace it.
clientID string
// enableTracing enable otel tracing of Pub/Sub messages on this subscriber.
// This is configured at client instantiation, and allows
// disabling of tracing even when a tracer provider is detected.
enableTracing bool
}
// Subscriber creates a subscriber client which references a single subscription.
func (c *Client) Subscriber(nameOrID string) *Subscriber {
s := strings.Split(nameOrID, "/")
// The string looks like a properly formatted topic name, use it directly.
if len(s) == 4 {
return newSubscriber(c, nameOrID)
}
// In all other cases, treat the arg as the topicID, even if misformatted.
return newSubscriber(c, fmt.Sprintf("projects/%s/subscriptions/%s", c.projectID, nameOrID))
}
func newSubscriber(c *Client, name string) *Subscriber {
return &Subscriber{
c: c,
name: name,
clientID: uuid.NewString(),
ReceiveSettings: DefaultReceiveSettings,
enableTracing: c.enableTracing,
}
}
// String returns the globally unique printable name of the subscription.
func (s *Subscriber) String() string {
return s.name
}
// ID returns the unique identifier of the subscription within its project.
func (s *Subscriber) ID() string {
slash := strings.LastIndex(s.name, "/")
if slash == -1 {
// name is not a fully-qualified name.
panic("bad subscription name")
}
return s.name[slash+1:]
}
// ReceiveSettings configure the Receive method.
// A zero ReceiveSettings will result in values equivalent to DefaultReceiveSettings.
type ReceiveSettings struct {
// MaxExtension is the maximum period for which the subscriber should
// automatically extend the ack deadline for each message.
//
// The subscriber will automatically extend the ack deadline of all
// fetched Messages up to the duration specified. Automatic deadline
// extension beyond the initial receipt may be disabled by specifying a
// duration less than 0.
MaxExtension time.Duration
// MaxDurationPerAckExtension is the maximum duration by which to extend the ack
// deadline at a time. The ack deadline will continue to be extended by up
// to this duration until MaxExtension is reached. Setting MaxDurationPerAckExtension
// bounds the maximum amount of time before a message redelivery in the
// event the subscriber fails to extend the deadline.
//
// MaxDurationPerAckExtension must be between 10s and 600s (inclusive). This configuration
// can be disabled by specifying a duration less than (or equal to) 0.
MaxDurationPerAckExtension time.Duration
// MinDurationPerAckExtension is the the min duration for a single lease extension attempt.
// By default the 99th percentile of ack latency is used to determine lease extension
// periods but this value can be set to minimize the number of extraneous RPCs sent.
//
// MinDurationPerAckExtension must be between 10s and 600s (inclusive). This configuration
// can be disabled by specifying a duration less than (or equal to) 0.
// Disabled by default but set to 60 seconds if the subscription has exactly-once delivery enabled.
MinDurationPerAckExtension time.Duration
// MaxOutstandingMessages is the maximum number of unprocessed messages
// (unacknowledged but not yet expired). If MaxOutstandingMessages is 0, it
// will be treated as if it were DefaultReceiveSettings.MaxOutstandingMessages.
// If the value is negative, then there will be no limit on the number of
// unprocessed messages.
MaxOutstandingMessages int
// MaxOutstandingBytes is the maximum size of unprocessed messages
// (unacknowledged but not yet expired). If MaxOutstandingBytes is 0, it will
// be treated as if it were DefaultReceiveSettings.MaxOutstandingBytes. If
// the value is negative, then there will be no limit on the number of bytes
// for unprocessed messages.
// This defaults to 1e9 or 1 GB. For machines that have less memory available,
// it is recommended to decrease this value so as to not run into OOM issues.
MaxOutstandingBytes int
// EnablePerStreamFlowControl enables enforcing the MaxOutstandingMessages and
// MaxOutstandingBytes flow control settings per stream instead of across
// streams and applying per subscriber client.
// Defaults to false for backwards compatibility.
// It is recommended this is enabled, and will default to true in the next
// major version.
EnablePerStreamFlowControl bool
// NumGoroutines sets the number of StreamingPull streams to pull messages
// from the subscription.
//
// NumGoroutines defaults to DefaultReceiveSettings.NumGoroutines.
//
// NumGoroutines does not limit the number of messages that can be processed
// concurrently. Even with one goroutine, many messages might be processed at
// once, because that goroutine may continually receive messages and invoke the
// function passed to Receive on them. To limit the number of messages being
// processed concurrently, set MaxOutstandingMessages.
NumGoroutines int
// ShutdownOptions configures the shutdown behavior of the subscriber.
// Default: if unset / nil, the client library will wait
// indefinitely for all in messages inflight to be acked/nacked.
ShutdownOptions *ShutdownOptions
}
// DefaultReceiveSettings holds the default values for ReceiveSettings.
var DefaultReceiveSettings = ReceiveSettings{
MaxExtension: 60 * time.Minute,
MaxDurationPerAckExtension: 0,
MinDurationPerAckExtension: 0,
MaxOutstandingMessages: 1000,
MaxOutstandingBytes: 1e9, // 1G
NumGoroutines: 1,
}
var errReceiveInProgress = errors.New("pubsub: Receive already in progress for this subscriber")
// Receive calls f with the outstanding messages from the subscription.
// It blocks until ctx is done, or the service returns a non-retryable error.
//
// The standard way to terminate a Receive is to cancel its context:
//
// cctx, cancel := context.WithCancel(ctx)
// err := sub.Receive(cctx, callback)
// // Call cancel from callback, or another goroutine.
//
// If the service returns a non-retryable error, Receive returns that error after
// all of the outstanding calls to f have returned. If ctx is done, Receive
// returns nil after all of the outstanding calls to f have returned and
// all messages have been acknowledged or have expired.
//
// Receive calls f concurrently from multiple goroutines. It is encouraged to
// process messages synchronously in f, even if that processing is relatively
// time-consuming; Receive will spawn new goroutines for incoming messages,
// limited by MaxOutstandingMessages and MaxOutstandingBytes in ReceiveSettings.
//
// The context passed to f will be canceled when ctx is Done or there is a
// fatal service error.
//
// Receive will send an ack deadline extension on message receipt, then
// automatically extend the ack deadline of all fetched Messages up to the
// period specified by s.ReceiveSettings.MaxExtension.
//
// Each Subscriber may have only one invocation of Receive active at a time.
func (s *Subscriber) Receive(ctx context.Context, f func(context.Context, *Message)) error {
s.mu.Lock()
if s.receiveActive {
s.mu.Unlock()
return errReceiveInProgress
}
s.receiveActive = true
s.mu.Unlock()
defer func() { s.mu.Lock(); s.receiveActive = false; s.mu.Unlock() }()
// TODO(hongalex): move settings check to a helper function to make it more testable
maxCount := s.ReceiveSettings.MaxOutstandingMessages
if maxCount == 0 {
maxCount = DefaultReceiveSettings.MaxOutstandingMessages
}
maxBytes := s.ReceiveSettings.MaxOutstandingBytes
if maxBytes == 0 {
maxBytes = DefaultReceiveSettings.MaxOutstandingBytes
}
maxExt := s.ReceiveSettings.MaxExtension
if maxExt == 0 {
maxExt = DefaultReceiveSettings.MaxExtension
} else if maxExt < 0 {
// If MaxExtension is negative, disable automatic extension.
maxExt = 0
}
maxExtPeriod := s.ReceiveSettings.MaxDurationPerAckExtension
if maxExtPeriod < 0 {
maxExtPeriod = DefaultReceiveSettings.MaxDurationPerAckExtension
}
minExtPeriod := s.ReceiveSettings.MinDurationPerAckExtension
if minExtPeriod < 0 {
minExtPeriod = DefaultReceiveSettings.MinDurationPerAckExtension
}
var shutdownOpts ShutdownOptions
if s.ReceiveSettings.ShutdownOptions != nil {
shutdownOpts = *s.ReceiveSettings.ShutdownOptions
} else {
// We can't store these in DefaultReceiveSettings because
// ShutdownOptions is a pointer, and editing one client's
/// ReceiveSettings will update the underlying ShutdownOptions value.
shutdownOpts = ShutdownOptions{
Behavior: ShutdownBehaviorWaitForProcessing,
Timeout: -1,
}
}
var numGoroutines int
switch {
case s.ReceiveSettings.NumGoroutines >= 1:
numGoroutines = s.ReceiveSettings.NumGoroutines
default:
numGoroutines = DefaultReceiveSettings.NumGoroutines
}
// TODO(jba): add tests that verify that ReceiveSettings are correctly processed.
po := &pullOptions{
maxExtension: maxExt,
maxExtensionPeriod: maxExtPeriod,
minExtensionPeriod: minExtPeriod,
maxPrefetch: trunc32(int64(maxCount)),
maxOutstandingMessages: maxCount,
maxOutstandingBytes: maxBytes,
clientID: s.clientID,
}
var clientFlowController flowController
// When using the legacy per-client flow control, limits
// are applied per-client rather than per stream/iterator.
if !s.ReceiveSettings.EnablePerStreamFlowControl {
clientFlowController = newSubscriberFlowController(FlowControlSettings{
MaxOutstandingMessages: maxCount,
MaxOutstandingBytes: maxBytes,
LimitExceededBehavior: FlowControlBlock,
})
}
var schedulerLimit int
if s.ReceiveSettings.EnablePerStreamFlowControl {
schedulerLimit = maxCount * numGoroutines
} else {
schedulerLimit = maxCount
}
sched := scheduler.NewReceiveScheduler(schedulerLimit)
// Wait for all goroutines started by Receive to return, so instead of an
// obscure goroutine leak we have an obvious blocked call to Receive.
group, gctx := errgroup.WithContext(ctx)
type closeablePair struct {
wg *sync.WaitGroup
iter *messageIterator
}
var pairs []closeablePair
// Cancel a sub-context which, when we finish a single receiver, will kick
// off the context-aware callbacks and the goroutine below (which stops
// all receivers, iterators, and the scheduler).
ctx2, cancel2 := context.WithCancel(gctx)
defer cancel2()
// This context is for forcefully shutting down the application if ShutdownTimeout
// is exceeded.
shutdownKillCtx, shutdownKillCancel := context.WithCancel(context.Background())
for i := 0; i < numGoroutines; i++ {
// The iterator does not use the context passed to Receive. If it did,
// canceling that context would immediately stop the iterator without
// waiting for unacked messages.
iter := newMessageIterator(s.c.SubscriptionAdminClient, s.name, po)
iter.enableTracing = s.enableTracing
var fc flowController
if s.ReceiveSettings.EnablePerStreamFlowControl {
fc = newSubscriberFlowController(FlowControlSettings{
MaxOutstandingMessages: maxCount,
MaxOutstandingBytes: maxBytes,
LimitExceededBehavior: FlowControlBlock,
})
} else {
fc = clientFlowController
}
// We cannot use errgroup from Receive here. Receive might already be
// calling group.Wait, and group.Wait cannot be called concurrently with
// group.Go. We give each receive() its own WaitGroup instead.
//
// Since wg.Add is only called from the main goroutine, wg.Wait is
// guaranteed to be called after all Adds.
var wg sync.WaitGroup
wg.Add(1)
pairs = append(pairs, closeablePair{wg: &wg, iter: iter})
group.Go(func() error {
defer wg.Wait()
defer cancel2()
for {
// If the context is done, don't pull more messages.
select {
case <-ctx.Done():
return nil
default:
}
// This is used to communicate the result of iter.receive.
msgChan := make(chan []*Message)
errChan := make(chan error)
go func() {
// Make message pulling dependent on iterator for context cancellation.
// If the context is cancelled while pulling messages, stop reading from stream early.
select {
case <-ctx.Done():
msgChan <- nil
return
default:
}
msgs, err := iter.receive()
if errors.Is(err, io.EOF) {
errChan <- nil
return
}
if err != nil {
errChan <- err
return
}
msgChan <- msgs
}()
var msgs []*Message
select {
case err := <-errChan:
return err
case msgs = <-msgChan:
}
// If context is done and messages have been pulled,
// nack them.
select {
case <-ctx.Done():
for _, m := range msgs {
m.Nack()
}
return nil
default:
}
for i, msg := range msgs {
iter.eoMu.RLock()
ackh, _ := msgAckHandler(msg, iter.enableExactlyOnceDelivery)
iter.eoMu.RUnlock()
// otelCtx is used to store the main subscribe span to the other child spans.
// We want this to derive from the main subscribe ctx, so the iterator remains
// cancellable.
// We cannot reassign into ctx2 directly since this ctx should be different per
// batch of messages and also per message iterator.
otelCtx := ctx2
// Stores the concurrency control span, which starts before the call to
// acquire is made, and ends immediately after. This used to be called
// flow control, but is more accurately describes as concurrency control
// since this limits the number of simultaneous callback invocations.
var ccSpan trace.Span
if iter.enableTracing {
c, ok := iter.activeSpans.Load(ackh.ackID)
if ok {
sc := c.(trace.Span)
otelCtx = trace.ContextWithSpanContext(otelCtx, sc.SpanContext())
// Don't override otelCtx here since the parent of subsequent spans
// should be the subscribe span still.
_, ccSpan = startSpan(otelCtx, ccSpanName, "")
}
}
// Use the original user defined ctx for this operation so the acquire operation can be cancelled.
if err := fc.acquire(ctx, len(msg.Data)); err != nil {
// TODO(jba): test that these "orphaned" messages are nacked immediately when ctx is done.
for _, m := range msgs[i:] {
m.Nack()
}
// Return nil if the context is done, not err.
return nil
}
if iter.enableTracing && ccSpan != nil {
ccSpan.End()
}
wg.Add(1)
// Only schedule messages in order if an ordering key is present and the subscriber client
// received the ordering flag from a Streaming Pull response.
var key string
iter.orderingMu.RLock()
if iter.enableOrdering {
key = msg.OrderingKey
}
// TODO(deklerk): Can we have a generic handler at the
// constructor level?
var schedulerSpan trace.Span
if iter.enableTracing {
_, schedulerSpan = startSpan(otelCtx, scheduleSpanName, "")
}
iter.orderingMu.RUnlock()
msgLen := len(msg.Data)
if err := sched.Add(key, msg, func(msg interface{}) {
m := msg.(*Message)
defer wg.Done()
var ps trace.Span
if iter.enableTracing {
schedulerSpan.End()
// Start the process span, and augment the done function to end this span and record events.
opts := getProcessSpanAttributes(s.c.projectID, s.name, m)
otelCtx, ps = startSpan(otelCtx, processSpanName, s.ID(), opts...)
old := ackh.doneFunc
ackh.doneFunc = func(ackID string, ack bool, r *ipubsub.AckResult, receiveTime time.Time) {
var eventString string
if ack {
eventString = eventAckCalled
} else {
eventString = eventNackCalled
}
ps.AddEvent(eventString)
// This is the process operation, but is currently named "Deliver". Replace once
// updated here: https://github.com/open-telemetry/opentelemetry-go/blob/eb6bd28f3288b173d148c67f9ed45390594abdc2/semconv/v1.26.0/attribute_group.go#L5240
ps.SetAttributes(semconv.MessagingOperationTypeDeliver)
ps.End()
old(ackID, ack, r, receiveTime)
}
}
defer fc.release(ctx, msgLen)
cbDone := make(chan struct{})
go func() {
defer close(cbDone)
f(otelCtx, m)
}()
select {
case <-cbDone:
// Callback finished gracefully.
case <-shutdownKillCtx.Done():
// Shutdown timeout exceeded, stop waiting for callback.
}
}); err != nil {
wg.Done()
// TODO(hongalex): propagate these errors to an otel span.
// If there are any errors with scheduling messages,
// nack them so they can be redelivered.
msg.Nack()
// Currently, only this error is returned by the receive scheduler.
if errors.Is(err, scheduler.ErrReceiveDraining) {
return nil
}
return err
}
}
}
})
}
go func() {
// Detected cancellation (either user initiated or permanent error).
<-ctx2.Done()
// Once shutdown is initiated, start the timer for forceful shutdown.
if shutdownOpts.Timeout == 0 {
// Stop all the pullstreams as the first thing we do to prevent new messages.
for _, p := range pairs {
p.iter.ps.cancel()
}
shutdownKillCancel() // Immediate forceful shutdown.
} else if shutdownOpts.Timeout > 0 {
// Stop all the pullstreams as the first thing we do to prevent new messages.
for _, p := range pairs {
p.iter.ps.cancel()
}
time.AfterFunc(shutdownOpts.Timeout, shutdownKillCancel)
if shutdownOpts.Behavior == ShutdownBehaviorNackImmediately {
for _, p := range pairs {
p.iter.nackInventory(shutdownKillCtx)
}
}
}
if shutdownOpts.Timeout >= 0 { // Timed shutdown
go func() {
for _, p := range pairs {
// Since we aren't looking for graceful timeout, call
// each iterator.stop asynchronously.
go func() {
p.iter.stop()
}()
p.wg.Done()
}
sched.Shutdown()
}()
} else { // Graceful shutdown
for _, p := range pairs {
p.iter.stop()
p.wg.Done()
}
// This _must_ happen after every iterator has stopped, or some
// iterator will still have undelivered messages but the scheduler will
// already be shut down.
sched.Shutdown()
}
}()
return group.Wait()
}
type pullOptions struct {
maxExtension time.Duration // the maximum time to extend a message's ack deadline in total
maxExtensionPeriod time.Duration // the maximum time to extend a message's ack deadline per modack rpc
minExtensionPeriod time.Duration // the minimum time to extend a message's lease duration per modack
maxPrefetch int32 // the max number of outstanding messages, used to calculate maxToPull
// If true, use unary Pull instead of StreamingPull, and never pull more
// than maxPrefetch messages.
maxOutstandingMessages int
maxOutstandingBytes int
clientID string
}