Google Pub/Sub Transport
Google Cloud Pub/Sub transport for scalable, GCP-native messaging with global availability.
Before You Start
- .NET 10.0
- A Google Cloud project with Pub/Sub API enabled
- Familiarity with choosing a transport and dependency injection
Installation
dotnet add package Excalibur.Dispatch.Transport.GooglePubSub
Quick Start
Using the Dispatch Builder (Recommended)
services.AddDispatch(dispatch =>
{
dispatch.AddHandlersFromAssembly(typeof(Program).Assembly);
dispatch.UseGooglePubSub(pubsub =>
{
pubsub.ProjectId("my-gcp-project")
.TopicId("dispatch-events")
.SubscriptionId("dispatch-events-sub");
});
});
Standalone Registration
services.AddDispatch(dispatch =>
{
dispatch.AddHandlersFromAssembly(typeof(Program).Assembly);
});
services.AddGooglePubSubTransport("dispatch-events", pubsub =>
{
pubsub.ProjectId("my-gcp-project")
.TopicId("dispatch-events")
.SubscriptionId("dispatch-events-sub")
.MapTopic<OrderCreated>("orders-topic");
});
Google Pub/Sub registers a keyed IMessageBus named GooglePubSub or
GooglePubSub:{TopicId} when TopicId is set:
var bus = serviceProvider.GetRequiredKeyedService<IMessageBus>("GooglePubSub:dispatch-events");
Configuration
Pub/Sub Options
Configure core transport settings with GooglePubSubOptions:
services.Configure<GooglePubSubOptions>("google-pubsub", options =>
{
options.Connection.ProjectId = "my-gcp-project";
options.Connection.TopicId = "dispatch-events";
options.Connection.SubscriptionId = "dispatch-events-sub";
options.Subscriber.MaxPullMessages = 100;
options.Subscriber.AckDeadlineSeconds = 60;
options.Subscriber.EnableAutoAckExtension = true;
options.Subscriber.MaxConcurrentAcks = 10;
});
CloudEvents Configuration
Via Transport Builder
Configure CloudEvents settings directly on the transport builder:
services.AddGooglePubSubTransport("events", pubsub =>
{
pubsub.ProjectId("my-gcp-project")
.TopicId("dispatch-events")
.SubscriptionId("dispatch-events-sub")
.ConfigureOptions(options => options.Subscriber.EnableExactlyOnceDelivery = true)
.ConfigureCloudEvents(ce =>
{
ce.UseOrderingKeys = true;
ce.Transport.EnableCompression = true;
ce.Transport.CompressionThreshold = 1024 * 1024; // 1MB
});
});
Standalone CloudEvents Registration
Use AddCloudEventsForPubSub for standalone CloudEvents configuration:
The CloudEvents mapper bundled with this transport serializes the message payload with
reflection-based JSON, so these registrations carry [RequiresUnreferencedCode] and
[RequiresDynamicCode]. A host that trims or publishes ahead of time gets a warning at the
call. To compose without the requirement, register your own ICloudEventEncoder<TOutbound>
backed by a source-generated serializer.
services.AddCloudEventsForPubSub(options => options.UseOrderingKeys = true);
When UseOrderingKeys is enabled, CloudEvents use the partition key as the Pub/Sub
ordering key to preserve ordering for related messages.
Dead Letter Topics
Configure dead letter handling via the transport options:
services.AddGooglePubSubTransport("events", pubsub =>
{
pubsub.ProjectId("my-gcp-project")
.TopicId("dispatch-events")
.EnableDeadLetter("dispatch-events-dlq");
});
Auto-Apply Dead Letter Policy
By default the transport only references the dead letter topic; attaching the dead letter policy to the subscription is normally an infrastructure-as-code concern. You can opt in to having the transport automatically apply the policy to the subscription at startup (a GetSubscription + UpdateSubscription) so it is actually honored rather than configured but never attached:
services.AddGooglePubSubTransport("events", pubsub =>
{
pubsub.ProjectId("my-gcp-project")
.TopicId("dispatch-events")
.SubscriptionId("dispatch-events-sub")
.EnableDeadLetter("dispatch-events-dlq")
.ConfigureOptions(options =>
{
options.AutoApplyDeadLetterPolicy = true; // attach the policy at startup
options.DeadLetterMaxDeliveryAttempts = 5; // attempts before dead-lettering
});
});
The policy is applied only when AutoApplyDeadLetterPolicy is enabled, a dead letter topic is configured, and a subscription id is set. DeadLetterMaxDeliveryAttempts controls how many delivery attempts occur before a message is dead-lettered.
| Option | Type | Default | Description |
|---|---|---|---|
AutoApplyDeadLetterPolicy | bool | false | Attaches the configured dead letter policy to the subscription at startup. |
DeadLetterMaxDeliveryAttempts | int | 5 | Delivery attempts before a message is dead-lettered (applies when auto-apply is enabled). |
Publish Failures and Retryability
When a publish fails, the returned SendError.IsRetryable tells you whether trying again can succeed.
It is classified from the gRPC status code the Pub/Sub service returned, so you do not have to
interpret status codes yourself:
| Status code | IsRetryable | Why |
|---|---|---|
UNAVAILABLE | true | Transient. Retry with exponential backoff. |
DEADLINE_EXCEEDED | true | The deadline passed; the outcome is unknown. |
INTERNAL | true | Transient server-side condition. |
RESOURCE_EXHAUSTED | true | A traffic spike; backoff is the correct response. |
CANCELLED | true | Transient, unless you cancelled — see below. |
INVALID_ARGUMENT | false | The message is malformed; it will fail again unchanged. |
PERMISSION_DENIED / UNAUTHENTICATED | false | Fix the credentials, not the retry policy. |
FAILED_PRECONDITION / ALREADY_EXISTS | false | Retrying cannot change the condition. |
NOT_FOUND | false | Treated as permanent for a configured topic. |
| anything else | false | Unrecognised codes are conservatively non-retryable. |
var result = await sender.SendAsync(message, cancellationToken);
if (!result.IsSuccess && result.Error?.IsRetryable == true)
{
// Safe to retry with backoff.
}
CANCELLED and DEADLINE_EXCEEDED are ambiguous outcomes: the service may already have accepted
the message before the call ended. Retrying one of these can therefore deliver the message twice. That
is consistent with the at-least-once delivery this transport provides — your consumers must be
idempotent regardless.
Cancellation you requested is never reported as retryable. If the cancellation token you passed is already cancelled, whether to try again is a decision you have made, and the token would still be cancelled on a retry — so the failure is reported as non-retryable rather than inviting a loop.
Health Checks
When using transport adapters, register aggregate health checks:
services.AddHealthChecks()
.AddTransportHealthChecks();
Observability
services.AddOpenTelemetry()
.WithTracing(tracing =>
{
tracing.AddSource("Excalibur.Dispatch");
tracing.AddGoogleCloudTraceExporter();
})
.WithMetrics(metrics =>
{
metrics.AddDispatchMetrics();
});
Configure telemetry options via GooglePubSubOptions:
services.Configure<GooglePubSubOptions>("google-pubsub", options =>
{
options.Telemetry.EnableOpenTelemetry = true;
options.Telemetry.ExportToCloudMonitoring = true;
options.Telemetry.TracingSamplingRatio = 0.1; // 10% sampling
options.Telemetry.EnableTracePropagation = true;
});
Production Checklist
- Use Workload Identity or managed credentials
- Configure
Subscriber.EnableExactlyOnceDeliveryfor critical streams - Enable ordering keys for strict ordering requirements
- Configure dead letter topics for failed messages
- Enable OpenTelemetry and Cloud Monitoring
Next Steps
- Multi-Transport Routing — Combine Pub/Sub with other transports
- In-Memory Transport — For local development
See Also
- Choosing a Transport — Compare Google Pub/Sub against other transports
- Google Cloud Functions Deployment — Run Dispatch handlers in Cloud Functions with Pub/Sub triggers
- Multi-Transport Routing — Route different message types across Pub/Sub and other transports
- Google Cloud Monitoring — Configure GCP-native observability for Dispatch