< Summary - AsyncResponse (Release / net8.0+net10.0 / unit+integration)

Information
Class: AsyncResponse.Transports.GooglePubSub.GooglePubSubSubscriberService
Assembly: AsyncResponse.Transports.GooglePubSub
File(s): /_/src/Transports/AsyncResponse.Transports.GooglePubSub/GooglePubSubSubscriberServices.cs
Line coverage
95%
Covered lines: 97
Uncovered lines: 5
Coverable lines: 102
Total lines: 296
Line coverage: 95%
Branch coverage
100%
Covered branches: 4
Total branches: 4
Branch coverage: 100%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
.ctor(...)100%11100%
get_Options()100%11100%
get_Logger()100%11100%
CreateSubscriberBuilder(...)100%22100%
StartAsync(...)100%11100%
ExecuteAsync()100%22100%
RunSubscriberAsync()100%1191.66%
StopSubscriberQuietlyAsync()100%1172.72%

File(s)

/_/src/Transports/AsyncResponse.Transports.GooglePubSub/GooglePubSubSubscriberServices.cs

#LineLine coverage
 1using Google.Api.Gax;
 2using Google.Cloud.PubSub.V1;
 3using Microsoft.Extensions.Hosting;
 4using Microsoft.Extensions.Logging;
 5using Microsoft.Extensions.Options;
 6
 7namespace AsyncResponse.Transports.GooglePubSub;
 8
 9internal abstract class GooglePubSubSubscriberService : BackgroundService
 10{
 11    private readonly Func<SubscriptionName, GooglePubSubSubscriberOptions, Task<IGooglePubSubSubscriberClient>> _subscri
 12
 13    /// <summary>Runs the GooglePubSubSubscriberService operation.</summary>
 14    protected GooglePubSubSubscriberService(
 15        IOptions<GooglePubSubAsyncResponseOptions> options,
 16        ILogger logger)
 39217        : this(options, logger, CreateSubscriberAsync)
 18    {
 39219    }
 20
 21    /// <summary>Runs the GooglePubSubSubscriberService operation.</summary>
 43422    protected GooglePubSubSubscriberService(
 43423        IOptions<GooglePubSubAsyncResponseOptions> options,
 43424        ILogger logger,
 43425        Func<SubscriptionName, GooglePubSubSubscriberOptions, Task<IGooglePubSubSubscriberClient>> subscriberFactory)
 26    {
 43427        Options = options.Value;
 43428        Logger = logger;
 43429        _subscriberFactory = subscriberFactory;
 43430    }
 31
 476732    protected GooglePubSubAsyncResponseOptions Options { get; }
 129933    protected ILogger Logger { get; }
 34
 35    protected abstract string SubscriptionId { get; }
 36    protected abstract GooglePubSubSubscriberOptions SubscriberOptions { get; }
 37    protected abstract GooglePubSubSubscriberRole SubscriberRole { get; }
 38    /// <summary>Handles the delivered message.</summary>
 39    protected abstract Task HandleMessageAsync(PubsubMessage message, CancellationToken cancellationToken);
 40
 41    [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
 42    private static async Task<IGooglePubSubSubscriberClient> CreateSubscriberAsync(
 43        SubscriptionName subscriptionName,
 44        GooglePubSubSubscriberOptions subscriberOptions)
 45    {
 46        var subscriber = await CreateSubscriberBuilder(subscriptionName, subscriberOptions).BuildAsync().ConfigureAwait(
 47        return new GooglePubSubSubscriberClientAdapter(subscriber);
 48    }
 49
 50    /// <summary>
 51    /// Every streaming-pull knob is set explicitly. Left to the SDK they default to one connection
 52    /// per CPU, 1,000 outstanding messages <em>per connection</em> and a 60-minute ack-extension
 53    /// ceiling nothing in this package knew about — so a process leased thousands of jobs it was
 54    /// not running, and a handler outliving the ceiling had its message redelivered mid-run with
 55    /// no option to raise it and nothing advertising it to the durable-flow engine.
 56    /// </summary>
 57    internal static SubscriberClientBuilder CreateSubscriberBuilder(
 58        SubscriptionName subscriptionName,
 59        GooglePubSubSubscriberOptions subscriberOptions)
 60    {
 61        // EmulatorOrProduction honors PUBSUB_EMULATOR_HOST when present (local dev / tests) and uses
 62        // real Google Cloud otherwise — no behavior change in production.
 39663        return new SubscriberClientBuilder
 39664        {
 39665            SubscriptionName = subscriptionName,
 39666            EmulatorDetection = EmulatorDetection.EmulatorOrProduction,
 39667            ClientCount = subscriberOptions.ClientCount,
 39668            Settings = new SubscriberClient.Settings
 39669            {
 39670                MaxTotalAckExtension = subscriberOptions.MaxTotalAckExtension,
 39671                // In early-ACK mode, bound the streaming pull to the background queue capacity so the client
 39672                // never holds more un-ACKed messages than the dispatcher can accept. Combined with the
 39673                // dispatcher's write-side backpressure this keeps queue-full NACKs (which burn a configured
 39674                // DeadLetterPolicy's delivery attempts) out of steady-state operation.
 39675                FlowControlSettings = subscriberOptions.AckMode is GooglePubSubAckMode.AckAfterEnqueue
 39676                    ? new Google.Api.Gax.FlowControlSettings(
 39677                        maxOutstandingElementCount: subscriberOptions.BackgroundQueueCapacity,
 39678                        maxOutstandingByteCount: null)
 39679                    : new Google.Api.Gax.FlowControlSettings(
 39680                        maxOutstandingElementCount: subscriberOptions.MaxOutstandingMessages,
 39681                        maxOutstandingByteCount: subscriberOptions.MaxOutstandingBytes)
 39682            }
 39683        };
 84    }
 85
 86    /// <summary>Runs this background operation until cancellation is requested.</summary>
 87    /// <summary>
 88    /// Validates subscriber options here rather than at the top of <c>ExecuteAsync</c>: since
 89    /// Microsoft.Extensions.Hosting.Abstractions 10.0.10, <c>BackgroundService.StartAsync</c> no
 90    /// longer runs <c>ExecuteAsync</c> inline, so a throw there surfaces only through the host's
 91    /// background-exception handling — or never, when a fast stop discards the queued work —
 92    /// instead of failing host startup synchronously.
 93    /// </summary>
 94    public override Task StartAsync(CancellationToken cancellationToken)
 95    {
 42496        _ = GooglePubSubOptionsValidator.Required(Options.ProjectId, nameof(Options.ProjectId));
 42297        _ = SubscriptionId; // Resolving the id enforces its Required check at startup too.
 42298        GooglePubSubMessageDispatcher.ValidateOptions(Options, SubscriberOptions, SubscriberRole);
 41499        return base.StartAsync(cancellationToken);
 100    }
 101
 102    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
 103    {
 414104        var projectId = GooglePubSubOptionsValidator.Required(Options.ProjectId, nameof(Options.ProjectId));
 414105        var subscriptionId = SubscriptionId;
 414106        var subscriptionName = SubscriptionName.FromProjectSubscription(projectId, subscriptionId);
 107
 108        // The transport intentionally has no MaxDeliveryAttempts and no library-managed dead-letter
 109        // queue for Pub/Sub: capping redelivery is delegated to the subscription's native
 110        // DeadLetterPolicy. The client cannot cheaply probe whether one is configured, so tell the
 111        // operator unconditionally instead of failing silently forever on a poison message.
 112        // The same goes for the RetryPolicy: a subscription without one redelivers a NACKed message
 113        // immediately, so a transient fault burns a DeadLetterPolicy's whole delivery-attempt budget
 114        // in about a second and dead-letters every message that arrives during the blip. The
 115        // package never creates subscriptions and reading one needs an admin client plus
 116        // pubsub.subscriptions.get, which a consumer identity commonly lacks — so say it here too.
 414117        Logger.LogWarning(
 414118            "Pub/Sub redelivery is unbounded for subscription {Subscription} ({Role}): the transport enforces no deliver
 414119            + "Configure a DeadLetterPolicy on the subscription to cap redeliveries of failing messages, and a RetryPoli
 414120            + "without one Pub/Sub redelivers a NACKed message immediately, so a transient failure exhausts the DeadLett
 414121            subscriptionName.ToString(),
 414122            SubscriberRole);
 123
 124        // The dispatcher outlives every supervised attempt; only host stop drains it. A streaming-pull
 125        // fault (network blip, UNAVAILABLE) ends an attempt, not the host — yet scoped to the attempt,
 126        // the early-ACK dispatcher's dispose ran its STOP-TIME drain on each one: consumption paused
 127        // for up to BackgroundDrainTimeout, then queued work already ACKed at the broker (which
 128        // Pub/Sub will never redeliver) was refused as "drain budget lapsed" on a host that was not
 129        // stopping. It captures nothing per attempt, so every rebuilt client feeds the same queue.
 414130        await using var dispatcher = GooglePubSubMessageDispatcher.Create(
 414131            HandleMessageAsync,
 414132            Options,
 414133            SubscriberOptions,
 414134            Logger,
 414135            subscriptionId,
 414136            SubscriberRole);
 137
 414138        await SubscriberSupervisor.RunAsync(
 465139            ct => RunSubscriberAsync(subscriptionName, dispatcher, ct),
 414140            stoppingToken,
 55141            failures => AsyncResponseRetry.Backoff(
 55142                failures,
 55143                Options.SubscriberRetryBaseDelay,
 55144                Options.SubscriberRetryMaxDelay),
 467145            (ex, retryDelay) => Logger.LogWarning(
 467146                ex,
 467147                "Pub/Sub subscriber failed for subscription {Subscription} ({Role}); retrying in {RetryDelay}.",
 467148                subscriptionName.ToString(),
 467149                SubscriberRole,
 467150                retryDelay)).ConfigureAwait(false);
 412151    }
 152
 153    private async Task RunSubscriberAsync(
 154        SubscriptionName subscriptionName,
 155        GooglePubSubMessageDispatcher dispatcher,
 156        CancellationToken stoppingToken)
 157    {
 465158        var subscriber = await _subscriberFactory(subscriptionName, SubscriberOptions).ConfigureAwait(false);
 159        try
 160        {
 418161            Logger.LogInformation(
 418162                "Pub/Sub subscriber started. Subscription: {Subscription}. Role: {Role}. AckMode: {AckMode}.",
 418163                subscriptionName.ToString(),
 418164                SubscriberRole,
 418165                SubscriberOptions.AckMode);
 166
 418167            var runTask = subscriber.StartAsync(dispatcher.HandleAsync);
 168
 169            try
 170            {
 418171                await runTask.WaitAsync(stoppingToken).ConfigureAwait(false);
 2172            }
 410173            catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
 174            {
 410175                await subscriber.StopAsync(
 410176                    new SubscriberClient.ShutdownOptions
 410177                    {
 410178                        Timeout = Options.ShutdownTimeout
 410179                    },
 410180                    CancellationToken.None).ConfigureAwait(false);
 410181                await runTask.ConfigureAwait(false);
 182            }
 412183        }
 0184        catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
 185        {
 186            // Graceful shutdown: the cancellation branch above already stopped the client.
 0187            throw;
 188        }
 6189        catch
 190        {
 191            // A non-shutdown failure abandons the streaming pull: release the client BEFORE the
 192            // retry loop builds a replacement, or its gRPC channels, pull connection and
 193            // ack-extension timers stay alive — one leaked client per rebuild.
 6194            await StopSubscriberQuietlyAsync(subscriber).ConfigureAwait(false);
 6195            throw;
 196        }
 412197    }
 198
 199    /// <summary>
 200    /// Best-effort stop of a failed subscriber client, swallowing stop errors: StopAsync is the
 201    /// seam's only release primitive, and the caller is already propagating the original failure.
 202    /// </summary>
 203    private async Task StopSubscriberQuietlyAsync(IGooglePubSubSubscriberClient subscriber)
 204    {
 205        try
 206        {
 6207            await subscriber.StopAsync(
 6208                new SubscriberClient.ShutdownOptions
 6209                {
 6210                    Timeout = Options.ShutdownTimeout
 6211                },
 6212                CancellationToken.None).ConfigureAwait(false);
 6213        }
 0214        catch (Exception ex)
 215        {
 0216            Logger.LogDebug(ex, "Best-effort stop of a failed Pub/Sub subscriber client did not complete cleanly.");
 0217        }
 6218    }
 219
 220}
 221
 222internal sealed class GooglePubSubWorkerSubscriber : GooglePubSubSubscriberService
 223{
 224    private readonly IAsyncResponseIngress _ingress;
 225
 226    /// <summary>Runs the GooglePubSubWorkerSubscriber operation.</summary>
 227    public GooglePubSubWorkerSubscriber(
 228        IOptions<GooglePubSubAsyncResponseOptions> options,
 229        IAsyncResponseIngress ingress,
 230        ILogger<GooglePubSubWorkerSubscriber> logger)
 231        : base(options, logger)
 232    {
 233        _ingress = ingress;
 234    }
 235
 236    internal GooglePubSubWorkerSubscriber(
 237        IOptions<GooglePubSubAsyncResponseOptions> options,
 238        IAsyncResponseIngress ingress,
 239        ILogger<GooglePubSubWorkerSubscriber> logger,
 240        Func<SubscriptionName, GooglePubSubSubscriberOptions, Task<IGooglePubSubSubscriberClient>> subscriberFactory)
 241        : base(options, logger, subscriberFactory)
 242    {
 243        _ingress = ingress;
 244    }
 245
 246    protected override string SubscriptionId
 247        => GooglePubSubOptionsValidator.Required(Options.WorkerSubscriptionId, nameof(Options.WorkerSubscriptionId));
 248
 249    protected override GooglePubSubSubscriberOptions SubscriberOptions => Options.WorkerSubscriber;
 250    protected override GooglePubSubSubscriberRole SubscriberRole => GooglePubSubSubscriberRole.Worker;
 251
 252    /// <summary>Handles the delivered message.</summary>
 253    protected override Task HandleMessageAsync(PubsubMessage message, CancellationToken cancellationToken)
 254        => _ingress.HandleWorkerMessageAsync(message.Data.ToStringUtf8());
 255}
 256
 257internal sealed class GooglePubSubResponseIngressSubscriber : GooglePubSubSubscriberService
 258{
 259    private readonly IAsyncResponseIngress _ingress;
 260
 261    /// <summary>Runs the GooglePubSubResponseIngressSubscriber operation.</summary>
 262    public GooglePubSubResponseIngressSubscriber(
 263        IOptions<GooglePubSubAsyncResponseOptions> options,
 264        IAsyncResponseIngress ingress,
 265        ILogger<GooglePubSubResponseIngressSubscriber> logger)
 266        : base(options, logger)
 267    {
 268        _ingress = ingress;
 269    }
 270
 271    internal GooglePubSubResponseIngressSubscriber(
 272        IOptions<GooglePubSubAsyncResponseOptions> options,
 273        IAsyncResponseIngress ingress,
 274        ILogger<GooglePubSubResponseIngressSubscriber> logger,
 275        Func<SubscriptionName, GooglePubSubSubscriberOptions, Task<IGooglePubSubSubscriberClient>> subscriberFactory)
 276        : base(options, logger, subscriberFactory)
 277    {
 278        _ingress = ingress;
 279    }
 280
 281    protected override string SubscriptionId
 282        => GooglePubSubOptionsValidator.Required(Options.ResponseSubscriptionId, nameof(Options.ResponseSubscriptionId))
 283
 284    protected override GooglePubSubSubscriberOptions SubscriberOptions => Options.ResponseSubscriber;
 285    protected override GooglePubSubSubscriberRole SubscriberRole => GooglePubSubSubscriberRole.ResponseIngress;
 286
 287    /// <summary>Handles the delivered message.</summary>
 288    protected override Task HandleMessageAsync(PubsubMessage message, CancellationToken cancellationToken)
 289    {
 290        var messageJson = message.Data.ToStringUtf8();
 291        var correlationId = !_ingress.IsOverInboundBudget(messageJson)
 292            ? GooglePubSubCorrelationIdExtractor.Extract(message, messageJson, Options)
 293            : null;
 294        return _ingress.HandleResponseMessageAsync(messageJson, correlationId);
 295    }
 296}