GCP Architecture

Event-Driven Architecture on GCP: Pub/Sub, Eventarc, Cloud Tasks and Workflows

Quick take: Google Cloud gives you four asynchronous primitives — Pub/Sub for events, Eventarc for routing Google-generated events, Cloud Tasks for rate-controlled commands, Workflows for orchestrated sagas — plus Cloud Scheduler for time. Most production failures trace back to using one where another belonged, or to skipping the disciplines none of them can skip: idempotency, schema contracts, and observability of flows no stack trace will ever show you.

Every team that moves to Google Cloud eventually draws the same whiteboard picture: boxes for services, arrows labelled “event” between them. Six months later the picture is real and the pain starts — a duplicate payment.captured event double-charges a customer, a poison message wedges an ordered subscription, nobody can say where a stuck refund actually is across five services, and the notification service hammers a legacy SMTP relay into the ground because Pub/Sub fan-out has no per-consumer rate limit. None of these are bugs in Pub/Sub. They are architecture decisions that were never consciously made: event or command? Choreography or orchestration? Who owns the schema, and who owns the dead-letter queue?

This article is the pattern piece that sits above the plumbing. The deep mechanics of topics, subscriptions, ordering keys and exactly-once delivery live in GCP Pub/Sub and Event-Driven Architecture: Decouple and Scale — here we recap them only far enough to build on, then spend our time on the decisions that shape the whole system: events vs commands (and why Cloud Tasks exists at all when Pub/Sub already exists), choreography vs orchestration (and when a saga must move from emergent event chains into an explicit Workflows definition with compensation steps), Eventarc as the router that turns Google Cloud itself into an event producer, Cloud Scheduler as the time-based producer, and the cross-cutting disciplines — idempotency, the transactional outbox, schema management, trace propagation through async hops, backpressure — that decide whether your event mesh is a nervous system or a haunted house.

By the end you will be able to look at any async requirement — “when an invoice lands in the bucket, process it”; “charge the card, reserve the seat, and undo both if issuing the ticket fails”; “send 100,000 emails but never more than 50 per second”; “re-run reconciliation at 02:00 IST” — and name the right primitive, delivery semantics, failure path and observability hooks, with the gcloud, YAML and Terraform to build it. This assumes you have shipped at least one Pub/Sub consumer and are now designing the system around it.

What problem this solves

Synchronous architectures fail loudly and locally: a call times out, a stack trace points at the line. Event-driven architectures fail quietly and globally: everything returns 200, and three hours later finance asks why 4% of orders have no invoice. This article closes the gap between “we use Pub/Sub” and “we run a correct, observable, cost-sane event-driven system.” Concretely, these are the production failures you prevent by making its decisions deliberately:

Coupling / correctness failure What it looks like in production The missing decision Primary tool
Slow consumer stalls producer Checkout p99 rises when analytics lags Events, not synchronous calls Pub/Sub
Duplicate side effects Double email, double charge, double stock decrement At-least-once ⇒ idempotent handlers Dedup markers, exactly-once, outbox
Downstream crushed by fan-out Legacy API dies at 400 req/s from a burst of events Commands need rate control, not fan-out Cloud Tasks
Nobody knows where the flow is “Where is order o-88123’s refund?” takes 40 minutes Orchestration for multi-step money paths Workflows
Partial failure leaves debris Inventory reserved, payment failed, seat never released Saga with explicit compensation Workflows try/except
Event and DB state disagree Row committed, publish failed (or vice versa) Transactional outbox Cloud SQL/Firestore + relay
Consumers break on producer change New field renames amountvalue, five services 500 Schema contract + evolution rules Pub/Sub schemas
Incidents are undebuggable No trace crosses the topic; logs don’t correlate Trace propagation through messages Cloud Trace + OTel
Infra events need polling Cron polls buckets/APIs for changes every minute Google Cloud is already a producer Eventarc
“It ran twice” / “it never ran” Scheduled job fired on two instances or none Single, observable time source Cloud Scheduler

If you have hit three or more rows of that table, you are the audience. The rest of the article works through the toolbox in order: the concepts, the backbone (Pub/Sub), the router (Eventarc), the command channel (Cloud Tasks + Scheduler), the orchestrator (Workflows), then the cross-cutting disciplines.

Learning objectives

By the end of this article you can:

Prerequisites & where this fits

You should be comfortable with core GCP mechanics: projects and IAM (members, roles, bindings), service accounts and OIDC tokens (service accounts & least privilege), deploying a container to Cloud Run (Cloud Run explained), and the Pub/Sub fundamentals from the Pub/Sub deep dive. The gcloud cheat sheet covers the CLI conventions used throughout. All examples use asia-south1 (Mumbai); substitute your region.

The division of labour, one line each: Pub/Sub is the event backbone (fan-out, backlog, DLQ); Eventarc routes Google Cloud and custom events to your services as CloudEvents; Cloud Tasks carries rate-controlled commands; Cloud Scheduler produces time; Workflows orchestrates sagas; Cloud Run hosts most handlers; BigQuery is the analytical sink; and Cloud Monitoring & Trace watch the whole mesh. If you come from AWS, map mentally: Pub/Sub ≈ SNS+SQS fused, Eventarc ≈ EventBridge, Cloud Tasks ≈ SQS with a managed dispatcher, Workflows ≈ Step Functions, Scheduler ≈ EventBridge Scheduler — the same patterns as AWS Lambda event-driven patterns, with different physics where this article flags them.

Core concepts

Four ideas carry everything else. Get these straight and every later decision becomes mechanical.

A message is not an event, and an event is not a command. A message is the transport unit — bytes plus attributes moving through a broker. An event is a statement of fact about the past: order.placed, object.finalized. The producer does not know or care who reacts; zero, one or ten consumers is a choice made after publish, by adding subscriptions. A command is an instruction aimed at a specific handler: send-this-email. It has exactly one intended executor, the sender cares that it happens, and duplicating it is harmful. GCP embodies the split in hardware: events ride Pub/Sub (fan-out, no per-consumer rate control), commands ride Cloud Tasks (one target, explicit rate and concurrency caps, scheduled delivery). Teams that push commands through Pub/Sub end up reimplementing Cloud Tasks badly in application code.

Delivery semantics are a contract you pay for. Every async system chooses between at-most-once (fast, lossy — never acceptable for business events), at-least-once (the Pub/Sub default: nothing is lost, retries create duplicates), and exactly-once (Pub/Sub can enforce non-redelivery of acked messages on regional pull subscriptions — but it cannot stop your publisher retrying, or your handler failing after the side effect). The consequence: end-to-end exactly-once is an application property, built from at-least-once delivery plus idempotent handlers plus, where the DB and the event must agree, a transactional outbox.

Choreography and orchestration are visibility trade-offs, not religions. In choreography, services react to each other’s events; the flow is emergent, coupling minimal, adding a consumer free — but nobody owns the end-to-end outcome, and partial failure leaves silent debris. In orchestration, a central definition (a Workflows program) calls each step, holds state, retries, and runs compensation on failure — at the cost of a component that knows every participant. Mature systems use both: choreography between bounded contexts (“order placed” is everyone’s business), orchestration inside a money-critical flow (reserve → charge → issue is one owner’s business).

The envelope matters as much as the payload. GCP’s routing layer, Eventarc, standardises every event — a Storage upload, an audit-log entry, your own Pub/Sub message — into a CloudEvents 1.0 envelope: id, source, type, subject, time as ce-* HTTP headers, payload in the body. One Cloud Run service can consume storage events today and Firebase events tomorrow without new parsing code, and the envelope is where trace context and idempotency keys ride.

The vocabulary in one table

Term One-line definition Lives in Why it matters architecturally
Event Immutable fact: “X happened” Pub/Sub topic Fan-out; producer ignorant of consumers
Command Targeted instruction: “do X” Cloud Tasks queue One executor; rate/schedule control
Topic Named event stream producers publish to Pub/Sub The unit of contract + schema
Subscription One consumer’s cursor + backlog + retry state Pub/Sub The unit of independence and failure isolation
Trigger Filter + destination binding for Google events Eventarc Turns platform activity into your events
CloudEvents CNCF envelope standard (ce-id, ce-type…) Eventarc → HTTP Uniform parsing, filtering, tracing
Queue Rate-limited command buffer with retry config Cloud Tasks The throttle you aim at fragile targets
Saga Multi-step transaction via local steps + undo steps Workflows Correctness without distributed locks
Compensation The undo action paired with a forward step Workflows except Cleans up partial failure
Connector Built-in Workflows call to a Google API with auth+retry Workflows No glue code, handles long-running ops
Idempotency key Business identifier that dedupes retries Message attribute / header Makes at-least-once safe
Outbox Event row committed in the same DB transaction as state Cloud SQL/Firestore/Spanner DB and event stream can’t disagree
DLQ (dead-letter topic) Parking lot after max delivery attempts Pub/Sub Poison messages can’t wedge a flow
Backlog Unacked messages awaiting a consumer Subscription The pressure gauge of the system

And the delivery-semantics contract, since every later section leans on it:

Semantics Guarantee Where you get it on GCP What it costs you
At-most-once May lose, never duplicates Nothing native (don’t build this) Data loss — unacceptable for business events
At-least-once Never loses, may duplicate Pub/Sub default, Tasks, Scheduler, Eventarc Handlers must be idempotent
Exactly-once delivery No redelivery of successfully acked message Pub/Sub regional pull subscriptions (opt-in) Pull only; regional; lower throughput ceiling; publisher dupes still possible
Exactly-once effect Side effect happens exactly once You build it: idempotent handler + outbox Engineering discipline, a dedup store
In-order Per-key sequence preserved Pub/Sub ordering keys; Tasks queue ≈ FIFO-ish (not guaranteed) ~1 MiB/s per key; head-of-line blocking

Events vs commands, choreography vs orchestration

These are the two axes of every event-driven design review I run. Every arrow on your whiteboard should be classifiable in ten seconds on both axes; arrows that resist classification are where incidents live.

Axis 1 — event or command?

Dimension Event (Pub/Sub) Command (Cloud Tasks)
Tense / meaning Past fact: “order placed” Imperative: “send email”
Intended receivers 0…N, unknown to producer Exactly 1, addressed explicitly
Adding a consumer New subscription; producer untouched New queue + new sender code
Rate control per receiver None (consumer must scale/absorb) First-class: dispatch/s + concurrency caps
Scheduled/delayed delivery No (deliver ASAP) Yes — scheduleTime up to 30 days ahead
Cancellation before execution No (published is published) Yes — delete the task by name
Duplicate tolerance Consumer’s job (idempotency) Named tasks dedupe (~1 h window) + idempotent target
Payload nature Domain fact, schema-governed Instruction + parameters, private contract
Failure ownership Each subscription retries independently Queue retry config; sender can inspect/purge
Typical GCP wiring Topic → subscriptions → Cloud Run/BQ/GCS Queue → HTTP target with OIDC

The classic smell: a topic named send-email-requests — a command wearing an event costume. No second consumer will ever subscribe, the SMTP relay needs a rate cap, and queued sends should be cancellable on unsubscribe: all three are Cloud Tasks features and Pub/Sub non-features.

Axis 2 — choreography or orchestration?

Dimension Choreography (event chain) Orchestration (Workflows)
Flow definition Emergent from subscriptions Explicit program (YAML), versioned
Coupling Minimal — services know only events Orchestrator knows every participant
End-to-end visibility Reconstructed from logs/traces One execution record with state + history
Partial failure Each service handles its own; debris possible except + compensation, centrally owned
Adding a step New subscriber — zero touch elsewhere Edit the workflow definition
Long waits (human approval, callback) Awkward — park state somewhere Native: callbacks, sys.sleep, up to 1 year
Timeout for the whole flow Nobody enforces it Execution-level, explicit
Testing the flow Integration test across N services Execute the workflow with test inputs
Risk profile Silent incompleteness Orchestrator = single point of coordination
Best for Broadcast facts across contexts Money paths, SLAs, anything needing an owner

The decision table

If the flow is… Choose GCP building blocks
A fact many teams react to (order placed, user signed up) Choreography Pub/Sub topic + N subscriptions
A multi-step transaction where partial failure has a cost Orchestration (saga) Eventarc/Pub/Sub → Workflows → services
One fragile downstream that must be fed slowly Command queue Cloud Tasks with rate limits
“Do X at time T” (seat-hold expiry, delayed retry) Scheduled command Cloud Tasks scheduleTime
“Do X every day at 02:00” Scheduled event/command Cloud Scheduler → Pub/Sub or HTTP
React to GCP platform activity (bucket, BQ job, IAM change) Routed event Eventarc trigger → Cloud Run/Workflows
Batch data movement with DAG dependencies, data engineers on Airflow Heavy orchestration Cloud Composer (see Workflows comparison)
Sub-second streaming transformation at scale Neither — streaming Pub/Sub → Dataflow → BigQuery

Rule of thumb I hold teams to: facts fan out, money gets orchestrated, fragile things get queued, time goes through Scheduler. Print it on the whiteboard; it settles 80% of design arguments.

Pub/Sub: the backbone, recapped for architects

The Pub/Sub deep dive covers mechanics message by message; here is the architect’s working summary — the settings that change your system design, with their limits and costs. If any row surprises you, read the deep dive before building.

First, delivery models, because the choice cascades into scaling, ack deadlines and observability:

Subscription type How messages arrive Scale driver Ack deadline reality Best for
Pull / StreamingPull Client opens stream, receives, acks Your consumer’s flow control 10–600 s, extendable by client lease mgmt High-throughput workers on GKE/GCE, exactly-once
Push Pub/Sub POSTs to your HTTPS endpoint Pub/Sub’s adaptive push rate Response is the ack; ≤600 s to respond Cloud Run/Functions consumers, scale-to-zero
BigQuery Pub/Sub writes rows directly Managed N/A (managed) Analytics sink with zero consumer code
Cloud Storage Pub/Sub writes batched objects Managed (batch by size/time) N/A (managed) Cheap archive / replay source
Export to other topic (via Dataflow templates) Managed pipeline N/A Cross-project/region distribution

Push nuance that bites architects: a push endpoint acks with 102, 200, 201, 202 or 204; anything else is a nack that schedules a retry. Pub/Sub adapts its push rate to your endpoint’s success rate, so a failing consumer automatically gets fewer requests — good for the consumer, invisible-backlog-shaped for you. Alert on backlog, not on consumer error rate alone.

The design-shaping settings in one matrix:

Setting Values / range Default Change it when Trade-off / gotcha
ackDeadlineSeconds 10–600 s 10 s Handler regularly needs >10 s Too low ⇒ duplicate deliveries mid-processing; watch expired_ack_deadlines_count
Message retention (subscription) 10 min–7 days 7 days Shorten for cost only with strong reasons Backlog older than retention is gone
Topic retention Off, up to 31 days Off Need replay for late-added subscriptions Billed per GiB-month on the topic
enableMessageOrdering + ordering key On/off + per-message key Off Per-entity sequence matters (orderId) ~1 MiB/s publish per key; head-of-line blocking per key
enableExactlyOnceDelivery On/off (regional pull only) Off Redelivery of acked msgs is unacceptable Not for push; throughput ceiling; publisher dupes remain
Retry policy Immediate, or exp. backoff 10–600 s Immediate redelivery Always set backoff on real consumers Immediate retry + failing handler = hot loop
Dead-letter policy maxDeliveryAttempts 5–100 Off (5 when enabled) Every production subscription Pub/Sub service agent needs publisher on DLQ + subscriber on source
Subscription filter Attribute expressions (=, !=, hasPrefix()) None Consumer wants a slice of a topic Filtered-out messages are auto-acked and still billed
Expiration policy 1 day–never 31 days idle ⇒ deleted Set never on everything in prod An expired subscription silently discards its future
Max message size 10 MB hard limit Never exceed; link to GCS object instead Oversized publish fails INVALID_ARGUMENT

And the minimal production-grade pair, CLI and Terraform — note the DLQ, backoff and non-expiring subscription, which I consider the floor for anything carrying business events:

gcloud pubsub topics create order-events --message-retention-duration=7d
gcloud pubsub topics create order-events-dlq

gcloud pubsub subscriptions create order-events-notify \
  --topic=order-events \
  --push-endpoint="https://notify-svc-xyz-el.a.run.app/pubsub" \
  --push-auth-service-account=push-invoker@$PROJECT_ID.iam.gserviceaccount.com \
  --ack-deadline=60 \
  --min-retry-delay=10s --max-retry-delay=600s \
  --dead-letter-topic=order-events-dlq --max-delivery-attempts=10 \
  --expiration-period=never
resource "google_pubsub_subscription" "notify" {
  name  = "order-events-notify"
  topic = google_pubsub_topic.order_events.id

  ack_deadline_seconds = 60

  push_config {
    push_endpoint = google_cloud_run_v2_service.notify.uri
    oidc_token {
      service_account_email = google_service_account.push_invoker.email
    }
  }
  retry_policy {
    minimum_backoff = "10s"
    maximum_backoff = "600s"
  }
  dead_letter_policy {
    dead_letter_topic     = google_pubsub_topic.order_events_dlq.id
    max_delivery_attempts = 10
  }
  expiration_policy { ttl = "" } # never expire
}

Consequences to carry forward: fan-out is free at the producer; each subscription is an independent failure domain (own backlog, retry, DLQ); there is no per-subscription rate limit (that is Cloud Tasks’ job); and at-least-once is the baseline contract everything downstream must survive.

Eventarc: the router that makes GCP itself a producer

Without Eventarc, reacting to platform activity means polling or hand-wiring service-specific notification channels (GCS Pub/Sub notifications, BQ log sinks), each with a different payload shape. Eventarc replaces that zoo with one model: a trigger matches events from a provider using filters and delivers them to a destination as a CloudEvents 1.0 HTTP POST, over a Pub/Sub transport it manages for you. Delivery is at-least-once, retried with backoff for up to 24 hours via the underlying subscription.

Source classes — where events come from

Source class Event type looks like Latency Coverage Use when
Direct events google.cloud.storage.object.v1.finalized Seconds Selected services (GCS, Firebase, IoT-ish sources, growing list) The service offers it — always prefer direct
Cloud Audit Logs google.cloud.audit.log.v1.written + serviceName/methodName filters Tens of seconds to minutes (log pipeline) Nearly every GCP API call No direct event exists (BQ job done, IAM change, VM created)
Custom (Pub/Sub) google.cloud.pubsub.topic.v1.messagePublished Seconds Anything you publish Your own apps, standardised into CloudEvents
Third-party / channels Partner-defined types Provider-dependent Registered partners SaaS providers emitting into your project
Eventarc Advanced bus Any CloudEvents you publish/enroll Seconds Cross-project mesh with CEL filters + transformation pipelines Central event mesh across many teams/projects

The direct-vs-audit-log decision deserves its own comparison because teams habitually reach for audit logs when a direct event exists (slower, noisier) or assume a direct event exists when it doesn’t:

Dimension Direct events Cloud Audit Logs events
Trigger filter fields type, provider-specific (e.g. bucket) type + serviceName + methodName (+ resourceName)
Path patterns (*, **) Supported on some fields Supported on resourceName
Prerequisite None beyond IAM Data Access audit logs enabled for that service
Payload Typed proto (e.g. StorageObjectData) LogEntryData — you parse protoPayload
Latency Seconds Log-pipeline latency; can be minutes
Noise Precise One API call can emit several entries; dedupe
Cost side effect None Data Access logs volume in Cloud Logging (billable)
Example GCS object finalized bigquery.googleapis.com / JobService.InsertJob

The CloudEvents envelope

Every Eventarc delivery is an HTTP POST in binary content mode: attributes as ce-* headers, payload as the body. This is what your Cloud Run service actually receives for a GCS upload:

POST /events HTTP/1.1
Host: invoice-processor-xyz-el.a.run.app
Authorization: Bearer <OIDC token for the trigger's service account>
Content-Type: application/json
ce-id: 8632110139364665
ce-source: //storage.googleapis.com/projects/_/buckets/invoices-raw
ce-specversion: 1.0
ce-type: google.cloud.storage.object.v1.finalized
ce-subject: objects/2026/07/inv-88123.pdf
ce-time: 2026-07-07T05:41:22.719Z

{ "bucket": "invoices-raw", "name": "2026/07/inv-88123.pdf", "size": "482113", ... }

The attributes you will actually use:

CloudEvents attribute Header Meaning Architectural use
id ce-id Unique per event (per source) Idempotency keysource+id dedupes retries
source ce-source Emitting resource path Multi-tenant routing, authz checks
type ce-type Versioned event type string The switch statement / router key
subject ce-subject The specific object within source Fine-grained dispatch without body parsing
time ce-time Emission timestamp Latency SLO measurement, out-of-order detection
specversion ce-specversion Always 1.0 Envelope compatibility
datacontenttype Content-Type Payload MIME Parser selection

Destinations and the IAM triangle

Destination How it receives Auth requirement Notes
Cloud Run service HTTPS POST to path you choose Trigger SA needs roles/run.invoker The default, scale-to-zero consumer
Cloud Functions (gen2) Same (gen2 is Cloud Run) Same CloudEvents SDK signatures
GKE service Via Eventarc event forwarder in-cluster Enable GKE destinations; forwarder pulls Good for existing in-cluster consumers
Workflows Event becomes the execution argument Trigger SA needs roles/workflows.invoker Event → saga in one hop, no glue service
Internal HTTP endpoint (VPC) POST to internal IP/ILB Network attachment config Reach private receivers without public ingress

Three IAM grants cover 90% of Eventarc setups — the triangle: the trigger’s service account needs roles/eventarc.eventReceiver plus invoker rights on the destination; and for GCS direct events, the Cloud Storage service agent needs roles/pubsub.publisher on the project. Miss the third and trigger creation fails with a quotable error; miss the second and events flow into a 403 black hole — the permission-denied decision tree applies verbatim.

# The GCS service agent must be able to publish (once per project)
GCS_SA=$(gcloud storage service-agent --project=$PROJECT_ID)
gcloud projects add-iam-policy-binding $PROJECT_ID \
  --member="serviceAccount:${GCS_SA}" --role=roles/pubsub.publisher

# Trigger: invoice PDFs landing in a bucket → Cloud Run
gcloud eventarc triggers create invoice-uploaded \
  --location=asia-south1 \
  --destination-run-service=invoice-processor \
  --destination-run-region=asia-south1 \
  --destination-run-path=/events \
  --event-filters="type=google.cloud.storage.object.v1.finalized" \
  --event-filters="bucket=invoices-raw" \
  --service-account=eventarc-sa@$PROJECT_ID.iam.gserviceaccount.com
resource "google_eventarc_trigger" "invoice_uploaded" {
  name     = "invoice-uploaded"
  location = "asia-south1"

  matching_criteria {
    attribute = "type"
    value     = "google.cloud.storage.object.v1.finalized"
  }
  matching_criteria {
    attribute = "bucket"
    value     = google_storage_bucket.invoices_raw.name
  }
  destination {
    cloud_run_service {
      service = google_cloud_run_v2_service.invoice_processor.name
      region  = "asia-south1"
      path    = "/events"
    }
  }
  service_account = google_service_account.eventarc.email
}

Trigger options that shape behaviour

Option / behaviour Values Default Gotcha
--event-filters Exact-match attribute=value pairs Required (type mandatory) Filters are AND-ed; no OR — make two triggers
--event-filters-path-pattern * (segment), ** (multi-segment) None Audit-log resourceName and some direct fields only
Location Region of trigger Must match source scope: GCS trigger in bucket’s region (or matching multi-region); audit-log triggers can be global
Transport topic Eventarc-managed or your own (custom source) Managed Managed subscription retains ~24 h — slower fixes lose events; add a DLQ to the transport subscription for critical flows
Retry Exponential backoff via transport subscription On Endpoint must return 2xx; 4xx also retries (it’s a nack) until retention expires
Payload encoding (custom) JSON / protobuf per provider JSON Consumers should parse by ce-type, never by topic name

Eventarc Standard vs Advanced: Standard is trigger-per-route, project-scoped, free apart from the Pub/Sub transport. Advanced adds a central message bus, enrollments that filter with CEL, and pipelines that transform payloads and deliver across projects — an event mesh rather than point wiring, with per-event pricing. Single team/project → Standard; a platform team offering events-as-a-service across many teams → evaluate Advanced.

Cloud Tasks vs Pub/Sub: commands need a different tool

This is the most common architecture-review correction I make on GCP, so it gets the full matrix. Both services move messages and retry until acked — the resemblance ends there.

Dimension Pub/Sub Cloud Tasks
Model Publish → fan-out to N subscriptions Enqueue → dispatch to one HTTP target
Receiver knowledge Producer unaware of consumers Sender chooses target URL per task
Rate limiting None per subscription maxDispatchesPerSecond up to 500/queue
Concurrency cap None (consumer self-limits) maxConcurrentDispatches up to 5,000
Scheduled delivery No scheduleTime up to 30 days ahead
Deduplication None at publish Named tasks rejected ALREADY_EXISTS (~1 h window; named creates add latency)
Cancel before run No Yes — delete task; pause/purge queue
Visibility into pending work Backlog metrics only List/inspect individual tasks
Ordering Ordering keys (opt-in) Best-effort — no FIFO guarantee
Max payload 10 MB 1 MB (HTTP task), 100 KB (App Engine task)
Handler deadline Ack deadline ≤600 s (push response) dispatchDeadline 15 s–30 min (default 10 min)
Retry control Sub-level backoff 10–600 s + DLQ Per-queue: attempts, backoff, doublings, duration — no DLQ (park failures yourself)
Replay history Seek to time/snapshot None — executed tasks are gone
Free tier / price 10 GiB/mo, then ~$40/TiB throughput 1 M ops/mo, then ~$0.40/M operations
Reach for it when Facts, fan-out, streams, analytics Throttled/deferred/cancellable work at one target

The queue configuration is the whole point of Cloud Tasks, so enumerate it properly:

Queue setting Range Default When to change Gotcha
maxDispatchesPerSecond 0.001–500 500 Match the downstream’s real capacity This is dispatch rate; retries count too
maxConcurrentDispatches 1–5,000 1,000 Downstream is concurrency-bound (DB pools) Both limits apply simultaneously
maxAttempts 1–∞ (-1 unlimited) 100 Business tolerance for retry After exhaustion the task is deleted — log it first
maxRetryDuration 0 (unlimited)–∞ 0 Cap total retry wall-clock Wins over remaining attempts
minBackoff / maxBackoff 0.1 s–1 h / up to 1 h 0.1 s / 1 h Slow down hammering on failure Defaults retry very hot at first
maxDoublings 0–16 16 Shape the exponential curve After doublings cap, backoff grows linearly
dispatchDeadline (per task) 15 s–30 min 10 min Long handlers on Cloud Run Deadline exceeded = failed attempt = retry
Queue state RUNNING / PAUSED / DISABLED RUNNING Pause during incidents; purge to drop all Purge is irreversible; named tasks recently purged can’t be re-created briefly
gcloud tasks queues create email-commands \
  --location=asia-south1 \
  --max-dispatches-per-second=50 \
  --max-concurrent-dispatches=100 \
  --max-attempts=8 --min-backoff=2s --max-backoff=300s --max-doublings=5

# Enqueue: send one email, no earlier than 09:00 IST, dedup by task name
gcloud tasks create-http-task email-o-88123-confirmation \
  --queue=email-commands --location=asia-south1 \
  --url="https://email-svc-xyz-el.a.run.app/send" \
  --method=POST \
  --header="Content-Type: application/json" \
  --body-content='{"orderId":"o-88123","template":"confirmation"}' \
  --schedule-time="2026-07-08T03:30:00Z" \
  --oidc-service-account-email=tasks-invoker@$PROJECT_ID.iam.gserviceaccount.com
resource "google_cloud_tasks_queue" "email_commands" {
  name     = "email-commands"
  location = "asia-south1"

  rate_limits {
    max_dispatches_per_second = 50
    max_concurrent_dispatches = 100
  }
  retry_config {
    max_attempts  = 8
    min_backoff   = "2s"
    max_backoff   = "300s"
    max_doublings = 5
  }
}

Two design notes engineers miss. First, Cloud Tasks has no dead-letter queue: when maxAttempts exhausts, the task disappears. For work you cannot lose, have the handler detect the final attempt (the X-CloudTasks-TaskRetryCount header) and persist the failure — a Firestore row or a “failed-commands” Pub/Sub topic you triage like a DLQ. Second, scheduled tasks are a state-machine primitive: “hold this seat for 10 minutes, release unless confirmed” is one named task with scheduleTime = now+10m, deleted on confirmation — no cron sweep, no polling, no race.

Cloud Scheduler: the time producer

Cloud Scheduler is deliberately tiny: a managed unix-cron firing at-least-once per tick into one of three target types. Its architectural role is to be the only source of time in the system — replacing the “leader-elected cron container” every team eventually builds and breaks.

Scheduler option Values Default Notes / gotcha
Schedule unix-cron (0 2 * * *) Minimum granularity 1 minute
--time-zone IANA (e.g. Asia/Kolkata) UTC (Etc/UTC) DST-aware; always set it explicitly
Target HTTP / Pub/Sub topic / App Engine Pub/Sub target = fan-out cron; HTTP for single action
Auth (HTTP) OIDC / OAuth token, SA-signed None OIDC for Cloud Run; SA needs run.invoker
--attempt-deadline 15 s–30 min 3 min (HTTP) Long jobs: fire-and-ack a Workflows execution instead
Retry config count, backoff, doublings, duration 0 retries Configure ≥1 retry; ticks are at-least-once anyway
Pricing 3 free jobs/billing acct, then ~$0.10/job/month Jobs, not invocations, are billed
gcloud scheduler jobs create pubsub nightly-reconciliation \
  --location=asia-south1 \
  --schedule="0 2 * * *" --time-zone="Asia/Kolkata" \
  --topic=reconciliation-runs \
  --message-body='{"window":"daily"}'

Pattern guidance: Scheduler → Pub/Sub → workers when the tick fans out (per-tenant reconciliation: one tick, N tenant messages); Scheduler → Workflows execution when the tick starts an orchestrated job; never Scheduler → long synchronous handler (the 30-minute attempt deadline becomes your job’s invisible timeout). Ticks are at-least-once, so the job itself must be idempotent — the recurring theme.

Workflows: orchestration, sagas and compensation

Workflows is GCP’s serverless orchestrator: you declare steps in YAML, Google runs the state machine — durable across failures, scale-to-zero, priced per step (~$0.01 per 1,000 internal steps, 5,000/month free; external calls ~$0.025 per 1,000 after 2,000 free), with executions allowed to run up to one year. That last number is the quiet superpower: a workflow can sys.sleep for days or park on a callback awaiting human approval with no infrastructure existing in the meantime.

The language surface you actually use

Construct What it does Note / limit
steps: + named step Ordered execution units Source ≤128 KB; step count drives price
assign: Set/compute variables Total variable memory ≤512 KB per execution
call: http.get/post/… HTTP call with auth: OIDC/OAuth2 Response size ≤2 MB; result into result: var
call: googleapis.* (connectors) Typed Google API calls Handle auth, retries, long-running op polling
switch: Conditional branching Conditions are ${} expressions
for: Iterate lists/maps Combine with parallel for fan-out
parallel: branches / for Concurrent steps with shared vars Explicit shared: declaration required
try / retry / except Error handling + backoff + catch The saga machinery — detailed below
raise Throw custom or re-throw caught errors Payload becomes catchable error map
Subworkflows Named reusable blocks with params Compose sagas from tested pieces
events.create_callback_endpoint / await_callback Pause for an external POST Human-in-the-loop, webhook waits — up to execution lifetime
sys.sleep Durable timer Days-long sleeps cost nothing while sleeping

Error handling and the saga pattern

Error handling composes from four pieces. Predefined retry policies — retry: ${http.default_retry} (retries 429/502/503/504 and connection errors; safe for idempotent calls) and retry: ${http.default_retry_non_idempotent} (when a POST must not re-fire) — or a custom policy with predicate, max_retries and backoff: {initial_delay, max_delay, multiplier}. Catches use except: as: e, where e is a map carrying code, message, tags (HttpError, TimeoutError, ConnectionError) and the HTTP body/headers where applicable — branch compensation on tags, never on message strings — and end with raise: ${e} so the execution fails honestly.

The saga pattern in Workflows is exactly this: each forward step is a try with a bounded retry; the except ladder runs compensations for every already-completed step in reverse order, then re-raises. Here is a complete, deployable order saga — reserve inventory, charge payment, issue ticket; any failure unwinds cleanly:

main:
  params: [event]
  steps:
    - init:
        assign:
          - order: ${json.decode(base64.decode(event.data.message.data))}
          - orderId: ${order.orderId}
          - project: ${sys.get_env("GOOGLE_CLOUD_PROJECT_ID")}
    - reserveInventory:
        try:
          call: http.post
          args:
            url: https://inventory-svc-xyz-el.a.run.app/v1/reserve
            auth: { type: OIDC }
            headers: { X-Idempotency-Key: '${orderId + "-reserve"}' }
            body: { orderId: '${orderId}', items: '${order.items}' }
          result: reservation
        retry:
          predicate: ${http.default_retry_predicate}
          max_retries: 4
          backoff: { initial_delay: 2, max_delay: 30, multiplier: 2 }
    - chargePayment:
        try:
          call: http.post
          args:
            url: https://payments-svc-xyz-el.a.run.app/v1/charge
            auth: { type: OIDC }
            body:
              orderId: ${orderId}
              amountMinor: ${order.amountMinor}
              idempotencyKey: '${orderId + "-charge"}'
          result: charge
        retry: ${http.default_retry_non_idempotent}
        except:
          as: e
          steps:
            - compensateReserve:            # undo step 1
                call: http.post
                args:
                  url: https://inventory-svc-xyz-el.a.run.app/v1/release
                  auth: { type: OIDC }
                  body: { orderId: '${orderId}' }
            - publishFailed:
                call: googleapis.pubsub.v1.projects.topics.publish
                args:
                  topic: ${"projects/" + project + "/topics/order-failed"}
                  body:
                    messages:
                      - data: ${base64.encode(json.encode(e))}
            - failSaga:
                raise: ${e}
    - publishConfirmed:
        call: googleapis.pubsub.v1.projects.topics.publish
        args:
          topic: ${"projects/" + project + "/topics/order-confirmed"}
          body:
            messages:
              - data: ${base64.encode(json.encode(charge.body))}
              - attributes: { orderingKey: '${orderId}' }
    - done:
        return: ${charge.body}
gcloud workflows deploy order-saga --location=asia-south1 \
  --source=order-saga.yaml \
  --service-account=saga-runner@$PROJECT_ID.iam.gserviceaccount.com

# Wire events to the saga: Eventarc trigger with a Workflows destination
gcloud eventarc triggers create order-placed-to-saga \
  --location=asia-south1 \
  --destination-workflow=order-saga --destination-workflow-location=asia-south1 \
  --event-filters="type=google.cloud.pubsub.topic.v1.messagePublished" \
  --transport-topic=projects/$PROJECT_ID/topics/order-events \
  --service-account=eventarc-sa@$PROJECT_ID.iam.gserviceaccount.com

Map every forward step to its compensation before you write YAML — if a step has no possible undo (an email cannot be unsent), it must move to the end of the saga, after every failure-prone step:

Saga step Forward action Compensation Ordering rule it implies
1. Reserve inventory POST /reserve (idempotent key) POST /release Reversible → can go early
2. Charge payment POST /charge (idempotency key) POST /refund Reversible but costly → after cheap steps
3. Issue ticket Insert + publish ticket.issued Void ticket Reversible → middle
4. Send confirmation email Cloud Tasks enqueue None — irreversible Must be last, after all can-fail steps

Connectors: the underrated half of Workflows

Connectors (googleapis.pubsub.v1, googleapis.tasks.v2, googleapis.bigquery.v2, googleapis.firestore.v1, and dozens more) are not just typed HTTP wrappers — they authenticate as the workflow’s service account, apply sane retries, and block on long-running operations (a BigQuery job, a Cloud Build) until completion, turning “submit + poll loop” into one step. A nightly “run BQ load job → verify row count → publish dataset.refreshed” pipeline is ~5 steps and costs fractions of a paisa per run.

Workflows vs the other orchestrators

Dimension Workflows Cloud Composer (Airflow) Dataflow Application Integration
Model Serverless step machine Managed Airflow DAGs Beam data pipelines iPaaS visual flows
Unit of work API calls / service steps Python tasks, operators Records/streams at scale SaaS connectors, mappings
Idle cost Zero Environment runs 24×7 (≈US$350+/mo ≈ ₹30,000+) Zero (batch) / job-time Per-connection/flow
Latency to start Milliseconds Scheduler tick + worker pickup Job spin-up minutes Seconds
Long waits Callbacks/sleep up to 1 yr Sensors occupy workers N/A Built-in waits
Best at Sagas, service glue, event-triggered flows Data-engineering DAGs, backfills, Airflow ecosystem ETL/streaming transforms SaaS-to-SaaS integration
Wrong for Heavy data movement in-band Low-latency event reactions Business sagas Core domain logic

The one-line rule: Workflows orchestrates services; Composer orchestrates data engineering; Dataflow transforms data; none of them replace Pub/Sub — they sit beside it.

Idempotency and the outbox pattern on GCP

Everything upstream — Pub/Sub, Eventarc, Tasks, Scheduler, Workflows retries — is at-least-once. Idempotency is therefore not a nice-to-have; it is the load-bearing wall. Two separate problems hide under the word:

Problem 1 — consuming twice. The same logical event arrives twice (ack-deadline miss, publisher retry, replay); the handler must make the second arrival a no-op. Strategies, weakest to strongest:

Strategy How Survives Fails when Cost
Naturally idempotent op SET status='paid', upsert by key Everything Op has side effects (email, charge) Free — prefer designing for this
Dedup on messageId Marker row keyed by Pub/Sub messageId Broker redelivery Publisher retries (new messageId, same fact) One read+write per event
Dedup on business key Marker keyed by orderId + eventType Publisher dupes, replays, cross-topic dupes Key not derivable from payload Same, plus key discipline
Exactly-once delivery (Pub/Sub) Regional pull sub, ack tracking Broker redelivery of acked msgs Push subs; publisher dupes; handler crash after side effect Throughput ceiling
Transactional marker Marker + business write in one transaction Crash between effect and marker Side effect lives outside the DB (HTTP call) The gold standard for DB effects

The transactional-marker shape on Firestore (the same idea works as a unique-constraint insert on Cloud SQL):

from google.cloud import firestore

db = firestore.Client()

@firestore.transactional
def apply_event(tx: firestore.Transaction, event_key: str, order: dict):
    marker_ref = db.collection("processed_events").document(event_key)
    if marker_ref.get(transaction=tx).exists:
        return "duplicate"                       # second delivery: no-op
    tx.set(db.collection("orders").document(order["orderId"]),
           {"status": "confirmed"}, merge=True)  # business write
    tx.set(marker_ref, {"at": firestore.SERVER_TIMESTAMP})
    return "applied"

# event_key = business key, NOT messageId: survives publisher duplicates
result = apply_event(db.transaction(), f'{order["orderId"]}-confirmed', order)

Give the marker collection a TTL policy (say 14 days — longer than max retention + replay window) so it doesn’t grow forever.

Problem 2 — publishing and committing must agree. The classic dual-write bug: the service commits the order row, then crashes before publishing order.placed (event lost) — or publishes first and the commit fails (phantom event). The fix is the transactional outbox: write the event into an outbox table in the same database transaction as the state change; a relay publishes outbox rows to Pub/Sub and marks them done. The relay is at-least-once — fine, because consumers are idempotent by Problem 1.

CREATE TABLE outbox (
  id            BIGSERIAL PRIMARY KEY,
  aggregate_id  TEXT        NOT NULL,        -- becomes the ordering key
  event_type    TEXT        NOT NULL,        -- e.g. order.placed.v1
  payload       JSONB       NOT NULL,
  created_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
  published_at  TIMESTAMPTZ                  -- NULL = pending
);

BEGIN;
INSERT INTO orders (id, status, amount_minor) VALUES ('o-88123', 'placed', 249900);
INSERT INTO outbox (aggregate_id, event_type, payload)
VALUES ('o-88123', 'order.placed.v1',
        '{"orderId":"o-88123","amountMinor":249900}');
COMMIT;   -- state and event now succeed or fail together

Relay options on GCP, in ascending order of sophistication:

Relay approach Mechanism Publish latency Effort Watch out
Poller (Cloud Run job / Scheduler every minute) SELECT … WHERE published_at IS NULL FOR UPDATE SKIP LOCKED Up to poll interval Hours Batch size + index on published_at
In-process post-commit publisher + poller sweep Publish after commit; poller catches crashes Milliseconds (happy path) Low The sweep is the correctness; the inline publish is just speed
Cloud SQL + Datastream/Debezium (CDC) Log-based capture of outbox inserts Seconds Medium Schema of outbox is now an interface
Spanner change streams → Dataflow → Pub/Sub Native change streams Seconds Medium The Rolls-Royce; use if you’re on Spanner
Firestore trigger (Eventarc document.created on outbox collection) Function publishes on write Seconds Low Trigger delivery itself at-least-once — fine
Dual write, no outbox “Publish then commit and hope” The anti-pattern this section exists to kill

Schema management: the contract between strangers

An event-driven system is a set of teams communicating through payloads with no compiler between them. Pub/Sub schemas put the compiler back: attach an Avro or Protocol Buffer schema to a topic and Pub/Sub rejects non-conforming publishes with INVALID_ARGUMENT — at the source, protecting every subscriber at once. Validation happens on publish only (delivery is unvalidated bytes), and schemas support up to 20 revisions with commit-time compatibility ranges on the topic (--first-revision-id / --last-revision-id pinning).

Aspect Avro Protocol Buffers Notes
Encoding on the wire JSON or binary JSON or binary --message-encoding set on the topic
Evolution ergonomics Defaults enable add/remove Field numbers sacred; add optional fields Both workable; Avro friendlier for analytics
BigQuery-subscription synergy use_topic_schema maps fields → columns Same Schema-less topics need data blob columns
Revision limit 20 revisions per schema 20 Prune old revisions as you roll forward
Validation point Publish time only Publish time only Bad old retained messages still deliverable
Enforcement gap Attributes are not validated Same Keep routing metadata in attributes, contract in payload
gcloud pubsub schemas create order-events-schema --type=avro \
  --definition-file=order-placed-v1.avsc
gcloud pubsub topics create order-events \
  --schema=order-events-schema --message-encoding=json

The rules that keep evolution boring — encode them in CI as a lint job diffing .avsc/.proto before deploy:

  1. Version the type, not the topicorder.placed.v1.v2 as an attribute/ce-type; a new topic only for a new domain concept.
  2. Additive changes only within a major version: new optional fields with defaults. Rename = remove + add = major bump.
  3. Consumers ignore unknown fields — mandatory reader behaviour, test it.
  4. Producers own the schema; consumers own contract tests pinning the fields they read, run against the producer’s schema in CI.
  5. Attributes are never schema-validated — document attribute conventions in the same repo as the schema.

Observability of async flows

A synchronous request carries its own story in the stack. An async flow’s story is scattered across a publisher, a broker, three consumers and a workflow — you must stitch it. Three instruments, in priority order.

1. The metrics that page you — alert on the first four per production subscription (Terraform for one shown, clone for the rest):

Metric (prefix pubsub.googleapis.com/) Meaning Alert threshold (starting point) What firing means
subscription/num_undelivered_messages Backlog depth > 10× normal for 10 min Consumer down/slow, or burst beyond capacity
subscription/oldest_unacked_message_age Staleness of head > your flow’s latency SLO Head-of-line blocking, stuck ordered key, dead consumer
subscription/dead_letter_message_count Msgs parked to DLQ > 0 (yes, zero) Poison messages exist — triage now, replay after fix
subscription/expired_ack_deadlines_count Acks that timed out Sustained > 0 Handler slower than ack deadline ⇒ duplicate storm
cloudtasks.googleapis.com/queue/depth Pending commands > expected watermark Target throttled/down; check attempt response codes
workflows.googleapis.com/finished_execution_count (label status) Saga outcomes FAILED rate > 1% Compensations firing — inspect execution history
resource "google_monitoring_alert_policy" "stale_backlog" {
  display_name = "order-events-notify: oldest unacked > 10 min"
  combiner     = "OR"
  conditions {
    display_name = "oldest_unacked_message_age"
    condition_threshold {
      filter          = "resource.type = \"pubsub_subscription\" AND resource.labels.subscription_id = \"order-events-notify\" AND metric.type = \"pubsub.googleapis.com/subscription/oldest_unacked_message_age\""
      comparison      = "COMPARISON_GT"
      threshold_value = 600
      duration        = "300s"
      aggregations {
        alignment_period   = "60s"
        per_series_aligner = "ALIGN_MAX"
      }
    }
  }
  notification_channels = [google_monitoring_notification_channel.oncall.id]
}

2. Trace propagation through the broker. Cloud Trace stitches HTTP hops automatically; a Pub/Sub hop breaks the chain unless context rides in the message. Modern Pub/Sub client libraries ship OpenTelemetry integration: enabled on publisher and subscriber, they emit publish/deliver spans and propagate W3C context in a googclient_traceparent message attribute automatically. Where you can’t use it (raw REST publishes, legacy libs), the manual pattern is one attribute:

Hop What carries the context Your responsibility
Producer → Pub/Sub traceparent/googclient_traceparent attribute Inject current span context at publish
Pub/Sub → push consumer Attribute in the push body (message.attributes) Extract → start consumer span as child
Eventarc → Cloud Run traceparent header on the POST (plus attributes) Most OTel HTTP middleware picks it up free
Consumer → Workflows execution Pass into the execution argument Log it from step 1; Workflows has no native OTel
Any service → Cloud Logging logging.googleapis.com/trace structured field projects/PROJECT/traces/TRACE_ID on every log line

3. Log correlation as the floor. Even with zero tracing, a correlation ID discipline — the business key (orderId) plus the event id logged as structured fields at every hop — turns “where is o-88123’s refund” into one Logs Explorer query: jsonPayload.orderId="o-88123". Put the field names in the schema repo and the code-review checklist; the Cloud Monitoring deep dive covers the dashboards and SLOs to hang off these signals.

Throttling, backpressure and flow control

Every event system eventually meets a consumer that cannot keep up. The design question is where the pressure goes. GCP gives you a knob at every layer — the failure mode is not knowing which one is actually in charge:

Layer Mechanism Knob Where pressure goes Gotcha
Publisher Client flow control / batching Publisher lib settings Producer slows/buffers Unbounded producer buffer = OOM at the edge
Pub/Sub (buffer) Retention absorbs bursts 7 d sub / 31 d topic retention Backlog grows harmlessly Backlog age > retention = data loss
Push delivery Adaptive push rate on failures None (automatic) Backlog Looks like “Pub/Sub is slow”; it’s your 5xx rate
Pull consumer Client flow control maxOutstandingMessages / bytes Backlog Set both; message-count alone under-protects on big msgs
Cloud Run consumer Concurrency + max instances --concurrency, --max-instances 429s → Pub/Sub retries Max-instances is your real throughput cap: instances × concurrency × (1/latency)
Cloud Tasks Explicit dispatch caps rate + concurrency per queue Queue depth (visible, fine) The only intentional throttle in the family
Workflows Serialised steps per execution parallel branches when safe Execution duration Fan-out via Pub/Sub, not 10k-iteration loops
Quotas Regional publish/subscribe caps Quota increase requests RESOURCE_EXHAUSTED errors Watch quota dashboards before launch days

The architecture rule: let the backlog be the buffer. Consumers don’t need sizing for peak — they need backlog drain time after peak to meet the SLO (drain ≈ backlog ÷ (consumer throughput − arrival rate)), with the observability section’s alerts telling you when that math breaks. And never simulate throttling with sleeps inside Pub/Sub handlers — route that leg through Cloud Tasks and give the number to the queue.

Architecture at a glance

The diagram reads left to right as one event’s life. Producers: a Cloud Storage upload emits object.finalized, and the checkout API publishes order-placed v1 — both become traffic in the routing column, where the Pub/Sub topic (carrying an orderId ordering key, badge 1) fans out to subscriptions and the Eventarc trigger wraps platform events into CloudEvents pushed with OIDC auth. Consumers — a Cloud Run service and a gen2 function — are deliberately boring: idempotent handlers (badge 2) that dedupe on a business key before side effects, transform, and ack. Anything transactional hands off to the orchestration column: the Workflows saga owns the money path with retries and compensation (badge 3), while Cloud Tasks rate-caps commands aimed at fragile targets (badge 4). Every flow terminates in the sinks column: BigQuery ingests the analytical copy through a native subscription, and the dead-letter topic (badge 5) catches what ten delivery attempts could not process — alarmed, triaged, replayed.

Notice what the picture does not contain: no synchronous call crosses a column boundary, every hop is a place where a backlog can absorb a burst, and both failure paths — compensation and DLQ — are drawn as first-class citizens. The whole doctrine in one image.

Event-driven architecture on GCP read left to right: producers (a Cloud Storage bucket emitting object.finalized and a checkout API publishing order-placed events) feed the routing layer, where a Pub/Sub topic with ordering keys fans out and an Eventarc trigger pushes CloudEvents with OIDC auth; consumers on Cloud Run and Cloud Functions gen2 process idempotently with dedup markers; the orchestration layer runs a Workflows saga with retry and compensation plus a rate-capped Cloud Tasks queue for commands; flows terminate in BigQuery via a native subscription and a dead-letter topic after max delivery attempts, with numbered badges marking ordering keys, idempotent consumers, saga compensation, the Cloud Tasks throttle and the dead-letter path

Real-world scenario

UtsavSeats, a Mumbai-based event-ticketing platform (14 engineers, ~₹1.8 lakh/month GCP spend), sells concert and festival tickets with brutal traffic shape: near-zero baseline, then 15,000 checkout attempts per minute for the first ten minutes of a big on-sale. Their v1 was pure choreography: checkout published seat.held, payment.captured, ticket.issued events; every service reacted to every other service’s events; email rode the same Pub/Sub topics as everything else.

Two incidents in one on-sale exposed the design. First, the payment provider’s webhook fired twice for ~0.7% of captures (documented at-least-once behaviour); the ticket service had no idempotency, so 214 customers received two tickets for one seat — found via support tickets, fixed by manual audit. Second, the email service — fan-out from the same events — pushed 300 requests/second at a legacy SMTP relay contractually capped at 50/s; the relay IP-banned them mid-on-sale, and transactional receipts queued behind 40,000 marketing notifications with no way to cancel any of it. Meanwhile “where is order X?” meant three engineers grepping three services’ logs in parallel: choreography had no owner for the end-to-end flow.

The redesign took one sprint plus a two-week burn-in, and it is essentially this article. The money path moved to orchestration: an Eventarc trigger starts an order-saga Workflows execution per checkout; reserve-seat → charge → issue-ticket became explicit steps with try/retry, and compensation (release-seat, refund) in except blocks. The duplicate-webhook problem died twice over: the payment callback dedupes on providerEventId with a Firestore transactional marker, and the charge step carries an idempotencyKey the provider honours. Email became commands: a Cloud Tasks queue at maxDispatchesPerSecond=45 (5/s headroom under the contract), OIDC dispatch, marketing sends in a second queue paused during on-sales — a one-command incident lever. Seat holds became scheduled tasks: hold creation enqueues a named task release-{holdId} at now+10m, confirmation deletes it; the cron sweep and its races were deleted. Analytics stayed choreographed — a BigQuery subscription on order-events, zero consumer code.

The next comparable on-sale: checkout p99 held at 180 ms while the saga ran behind it; the SMTP relay saw a flat 45/s for six hours (queue depth peaked at 31,000 — invisible to customers, drained by 02:00); zero duplicate tickets. When one poison message (malformed UTF-8 venue name) failed ten deliveries, it parked in the DLQ, paged via dead_letter_message_count > 0, and was fixed and replayed the same evening — 26 minutes, one engineer, no customer impact. Monthly cost of the new machinery: Workflows ≈ ₹1,400, Cloud Tasks ≈ ₹350, Eventarc/Pub/Sub delta ≈ ₹2,100 — noise against one prevented incident.

Phase Design Failure observed Fix applied Result
v1 on-sale Pure choreography, email on fan-out 214 double tickets (dup webhooks) Idempotency markers + provider idempotency keys 0 duplicates next on-sale
v1 on-sale Email fan-out, no rate cap SMTP relay banned at 300 req/s Cloud Tasks queue @45/s + separate marketing queue Flat 45/s, pausable
v1 incident No flow owner 3 engineers × log grep per “where is order X” Workflows saga = one execution record One console lookup
v1 seat holds Cron sweep every minute Races + zombie holds Named scheduled tasks, delete-on-confirm Exact-time release, no sweep
v2 burn-in DLQ + alerts Poison message (bad UTF-8) Park → fix → replay runbook 26-min MTTR, no impact

Advantages and disadvantages

Advantages Disadvantages
Failure isolation: a slow/down consumer grows its own backlog; producers and siblings unaffected Eventual consistency: “is it done?” has no synchronous answer; UX must be designed for async
Elastic absorption: 100× bursts become backlog + drain time, not outages Duplicates are the contract: every handler carries idempotency machinery
Independent evolution: new consumers attach without touching producers; schemas police the contract Debugging is distributed: without deliberate tracing/correlation, incidents are archaeology
Right-sized semantics per leg: fan-out (Pub/Sub), throttled commands (Tasks), sagas (Workflows), time (Scheduler) Four services to operate instead of one call stack; IAM/quota/config surface multiplies
Failure paths are first-class: DLQs, compensation, replay — recoverable by design Orchestrator discipline needed: a sloppy central workflow re-creates the monolith’s coupling
Serverless economics: scale-to-zero consumers + per-step orchestration ≈ ₹ hundreds/month at mid scale Latency floor: broker hops add tens of ms; wrong for request/response paths

The disadvantages dominate in small systems with no burst profile, hard synchronous UX requirements, or teams without the maturity for distributed debugging. An honest monolith with a job queue beats a cargo-culted event mesh — the compute decision guide pairs well with that conversation.

Hands-on lab

Build the full toolbox in ~30 minutes in Cloud Shell: a GCS→Eventarc→Cloud Run event path, a Pub/Sub topic with DLQ, a Workflows saga with visible compensation, and a rate-limited Cloud Tasks queue. Everything fits comfortably in the free tier if torn down after.

Step 1 — project, APIs, variables.

export PROJECT_ID=$(gcloud config get-value project)
export REGION=asia-south1
export PROJECT_NUMBER=$(gcloud projects describe $PROJECT_ID --format='value(projectNumber)')
gcloud services enable pubsub.googleapis.com eventarc.googleapis.com \
  run.googleapis.com workflows.googleapis.com cloudtasks.googleapis.com \
  cloudscheduler.googleapis.com

Step 2 — a consumer that shows you its events. Deploy the echo-friendly hello container; it logs every request body.

gcloud run deploy event-sink --region=$REGION \
  --image=us-docker.pkg.dev/cloudrun/container/hello --no-allow-unauthenticated

Step 3 — service account + the IAM triangle.

gcloud iam service-accounts create eventarc-lab
gcloud run services add-iam-policy-binding event-sink --region=$REGION \
  --member="serviceAccount:eventarc-lab@$PROJECT_ID.iam.gserviceaccount.com" \
  --role=roles/run.invoker
gcloud projects add-iam-policy-binding $PROJECT_ID \
  --member="serviceAccount:eventarc-lab@$PROJECT_ID.iam.gserviceaccount.com" \
  --role=roles/eventarc.eventReceiver
GCS_SA=$(gcloud storage service-agent --project=$PROJECT_ID)
gcloud projects add-iam-policy-binding $PROJECT_ID \
  --member="serviceAccount:${GCS_SA}" --role=roles/pubsub.publisher

Step 4 — Eventarc trigger on a bucket, then fire it.

gcloud storage buckets create gs://$PROJECT_ID-eda-lab --location=$REGION
gcloud eventarc triggers create lab-object-finalized --location=$REGION \
  --destination-run-service=event-sink --destination-run-region=$REGION \
  --event-filters="type=google.cloud.storage.object.v1.finalized" \
  --event-filters="bucket=$PROJECT_ID-eda-lab" \
  --service-account=eventarc-lab@$PROJECT_ID.iam.gserviceaccount.com
echo "hello eda" > sample.txt && gcloud storage cp sample.txt gs://$PROJECT_ID-eda-lab/
# Wait ~1–2 min for the first delivery, then:
gcloud run services logs read event-sink --region=$REGION --limit=20
# Expected: a POST with ce-type: google.cloud.storage.object.v1.finalized

Step 5 — topic + DLQ + production-grade subscription.

gcloud pubsub topics create lab-orders
gcloud pubsub topics create lab-orders-dlq
gcloud pubsub subscriptions create lab-orders-sub --topic=lab-orders \
  --ack-deadline=30 --min-retry-delay=10s --max-retry-delay=120s \
  --dead-letter-topic=lab-orders-dlq --max-delivery-attempts=5 \
  --expiration-period=never
gcloud pubsub subscriptions create lab-orders-dlq-sub --topic=lab-orders-dlq
# Grant the Pub/Sub service agent its DLQ roles (forwarder identity):
gcloud pubsub topics add-iam-policy-binding lab-orders-dlq \
  --member="serviceAccount:service-${PROJECT_NUMBER}@gcp-sa-pubsub.iam.gserviceaccount.com" \
  --role=roles/pubsub.publisher
gcloud pubsub subscriptions add-iam-policy-binding lab-orders-sub \
  --member="serviceAccount:service-${PROJECT_NUMBER}@gcp-sa-pubsub.iam.gserviceaccount.com" \
  --role=roles/pubsub.subscriber
gcloud pubsub topics publish lab-orders --message='{"orderId":"o-1"}' \
  --attribute=type=order.placed.v1
gcloud pubsub subscriptions pull lab-orders-sub --auto-ack --limit=1

Step 6 — a saga that compensates before your eyes. Save as lab-saga.yamlamountMinor over 50,000 triggers a simulated payment failure, and you watch the compensation run:

main:
  params: [input]
  steps:
    - init:
        assign:
          - orderId: ${input.orderId}
          - compensated: false
    - reserve:
        assign:
          - reservation: ${"seat-held-" + orderId}
    - charge:
        try:
          steps:
            - maybeFail:
                switch:
                  - condition: ${input.amountMinor > 50000}
                    raise:
                      code: 402
                      message: "card declined"
            - ok:
                assign:
                  - charge: ${"charged-" + orderId}
        except:
          as: e
          steps:
            - compensateReserve:
                assign:
                  - compensated: true
            - failSaga:
                raise: ${e}
    - done:
        return: { orderId: '${orderId}', charge: '${charge}', compensated: '${compensated}' }
gcloud workflows deploy lab-saga --location=$REGION --source=lab-saga.yaml
gcloud workflows run lab-saga --location=$REGION \
  --data='{"orderId":"o-1","amountMinor":19900}'      # succeeds
gcloud workflows run lab-saga --location=$REGION \
  --data='{"orderId":"o-2","amountMinor":99900}'      # fails AFTER compensating
gcloud workflows executions list lab-saga --location=$REGION --limit=2
# Expected: one SUCCEEDED, one FAILED — describe the failed one and find
# "card declined" plus the compensateReserve step in its history.

Step 7 — a throttled command queue.

gcloud tasks queues create lab-commands --location=$REGION \
  --max-dispatches-per-second=1 --max-concurrent-dispatches=1 --max-attempts=3
SINK_URL=$(gcloud run services describe event-sink --region=$REGION --format='value(status.url)')
for i in 1 2 3 4 5; do
  gcloud tasks create-http-task lab-cmd-$i --queue=lab-commands --location=$REGION \
    --url="$SINK_URL/cmd" --method=POST --body-content="{\"n\":$i}" \
    --oidc-service-account-email=eventarc-lab@$PROJECT_ID.iam.gserviceaccount.com
done
gcloud run services logs read event-sink --region=$REGION --limit=20
# Expected: the five POSTs arrive ~1 second apart — the queue, not the sender, sets the pace.

Step 8 — teardown.

gcloud eventarc triggers delete lab-object-finalized --location=$REGION --quiet
gcloud storage rm -r gs://$PROJECT_ID-eda-lab
gcloud run services delete event-sink --region=$REGION --quiet
gcloud workflows delete lab-saga --location=$REGION --quiet
gcloud tasks queues delete lab-commands --location=$REGION --quiet
for s in lab-orders-sub lab-orders-dlq-sub; do gcloud pubsub subscriptions delete $s --quiet; done
for t in lab-orders lab-orders-dlq; do gcloud pubsub topics delete $t --quiet; done
gcloud iam service-accounts delete eventarc-lab@$PROJECT_ID.iam.gserviceaccount.com --quiet

Common mistakes & troubleshooting

The playbook — symptom first, because that is what you have at 02:00:

# Symptom Root cause Confirm (exact command / path) Fix
1 Eventarc trigger exists, consumer never invoked Trigger SA lacks roles/run.invoker gcloud eventarc triggers describe T --location=R → find transport topic; check its subscription’s push_request_count response classes in Metrics Explorer Grant run.invoker on the destination service to the trigger SA
2 GCS trigger creation fails mentioning the service agent Storage service agent can’t publish to transport Error text names it; gcloud storage service-agent Grant that SA roles/pubsub.publisher at project level
3 Audit-log trigger never fires Data Access audit logs disabled for the service IAM & Admin → Audit Logs → service; Logs Explorer for matching protoPayload.methodName Enable Data Read/Write audit logs, accept the logging cost
4 Double emails / double stock decrements At-least-once + non-idempotent handler (often ack deadline too short) subscription/expired_ack_deadlines_count > 0; handler p99 vs ackDeadlineSeconds Raise ack deadline; add dedup marker keyed on business key
5 Backlog grows, consumer logs look idle Push backoff after error streak, or Cloud Run at max-instances returning 429s push_request_count grouped by response_class; Cloud Run “container instance count” at ceiling Fix handler errors; raise --max-instances; consider pull with flow control
6 Ordered subscription frozen for one entity Poison message head-of-line blocks its ordering key oldest_unacked_message_age climbing while other keys flow DLQ on the sub (it un-wedges the key); fix consumer; replay
7 Messages “vanish”, no consumer error Subscription created after publish, or 31-day-idle expiration deleted it gcloud pubsub subscriptions describeNOT_FOUND, or check expirationPolicy Create subs with the topic (IaC); set --expiration-period=never
8 DLQ configured but redelivery loops forever Pub/Sub service agent missing DLQ publisher / source subscriber roles Sub details page shows a warning banner; check IAM on DLQ topic Grant service-PROJECT_NUMBER@gcp-sa-pubsub… the two roles (lab step 5)
9 Saga double-charged a customer http.default_retry on a non-idempotent POST Workflows execution history shows retried charge step Use default_retry_non_idempotent + idempotency key the provider honours
10 Tasks dispatch far below configured rate Queue paused, or target 429/5xx streak triggering queue backoff gcloud tasks queues describe Q --location=Rstate; queue/task_attempt_count by response code Resume queue; fix target; retune maxDispatchesPerSecond to target reality
11 Scheduler “ran” but nothing happened Job succeeded against wrong URL/topic, or 2xx from an error-swallowing handler Scheduler job → View execution logs; target-side request logs Fix target; make handlers return non-2xx on failure (never swallow)
12 Push handler gets gibberish payload Forgot base64-decode of message.data in the push envelope Log the raw body once: {"message":{"data":"eyJvcmRlcklkIjoi..."}} Decode base64 then JSON-parse; write a shared envelope parser lib
13 Duplicates with exactly-once enabled Publisher retries created two distinct messages (different messageId) Compare payload business keys vs messageIds in logs Outbox/idempotent publish + business-key dedup — exactly-once ≠ publisher dedup
14 Workflows execution fails ResourceLimitError >512 KB of variables (usually a big HTTP response held in a var) Execution error names the limit; find the fat result: Don’t carry payloads through steps — pass GCS/BQ references instead

And the error-string reference for the family — what the API actually returns and what it means in this context:

Error / status Service Real meaning here First move
INVALID_ARGUMENT on publish Pub/Sub Message >10 MB, or schema validation failed Check size; validate against topic schema revision
NOT_FOUND on publish Pub/Sub Topic deleted/wrong project — publishes are NOT queued Fix producer config; alert on publish error rate
RESOURCE_EXHAUSTED Pub/Sub / Tasks Regional quota or queue throughput cap Check quota dashboard; request increase pre-launch
FAILED_PRECONDITION publish w/ ordering key Pub/Sub Prior publish failure paused the key; client must resume Call resumePublish for the key (client lib method)
ALREADY_EXISTS on task create Cloud Tasks Named task recently created/executed (dedup working) Expected — treat as success in enqueue code
HTTP 429 from *.a.run.app target Cloud Run max-instances ceiling hit Raise ceiling or lower queue/push pressure
PERMISSION_DENIED on trigger create Eventarc Caller or SA missing role in the IAM triangle Walk the decision tree
Execution FAILED with TimeoutError tag Workflows HTTP call exceeded its timeout arg Set timeout on the call; wrap in retry with backoff
deadline exceeded task attempt Cloud Tasks Handler exceeded dispatchDeadline Raise deadline ≤30 min or split the work
400 schema validation failed Pub/Sub Payload doesn’t match topic schema revision range Roll schema revision forward; fix producer serialisation

Best practices

  1. Classify every arrow: event → Pub/Sub, command → Cloud Tasks, time → Scheduler, saga → Workflows. Write the classification into the design doc; review it like an API.
  2. DLQ everything in production — every subscription gets a dead-letter topic, max-delivery-attempts 5–10, a monitored DLQ subscription, and a written replay runbook. Alert at dead_letter_message_count > 0.
  3. Idempotency is a code-review gate: no consumer merges without a dedup strategy named in the PR description (natural, marker, or transactional).
  4. Outbox for anything where DB state and events must agree — payments, inventory, entitlements. Dual writes are a standing incident invitation.
  5. Version event types (order.placed.v1) and enforce schemas at the topic. Additive evolution within a version; contract tests in consumer CI.
  6. Give every fragile downstream its own Tasks queue with a rate 10% under its real capacity — and use pause as an incident lever (practise it).
  7. Compensation is designed with the step, not after the outage: each saga step lands with its undo; irreversible steps go last.
  8. Propagate trace context in message attributes (enable the client libraries’ OTel support) and log the business key at every hop.
  9. Set --expiration-period=never and create subscriptions in Terraform alongside topics — the 31-day idle reaper and the “subscribed too late” gap both vanish under IaC. While you’re there: ack deadlines ≥ 2× handler p99.
  10. One event mesh doctrine per org: naming (<domain>.<entity>.<verb>.v<N>), attribute conventions, DLQ policy, replay procedure — documented once, linted in CI.

Security notes

Event meshes multiply the identities that can invoke things, so least privilege does the heavy lifting. Per-leg service accounts: each trigger, push subscription, queue and workflow runs as its own SA with exactly the invoker/publisher roles that leg needs — never the default compute SA (see service accounts & least privilege). Authenticated push everywhere: Cloud Run consumers stay --no-allow-unauthenticated; Pub/Sub push, Eventarc, Tasks and Scheduler all sign OIDC tokens, and Cloud Run’s IAM check does the rest — there is no reason for an unauthenticated event endpoint on GCP.

Principal Minimum role(s) On
Producer service SA roles/pubsub.publisher Its topics only (topic-level binding)
Push/trigger invoker SA roles/run.invoker The one destination service
Eventarc trigger SA roles/eventarc.eventReceiver (+ invoker) Project / destination
Cloud Storage service agent roles/pubsub.publisher Project (GCS direct events)
Pub/Sub service agent roles/pubsub.publisher + subscriber DLQ topic + source subscription
Task enqueuer SA roles/cloudtasks.enqueuer (+ roles/iam.serviceAccountUser on the dispatch SA) The queue
Workflow runner SA Exactly the APIs its connectors touch Per-resource
Scheduler job SA roles/pubsub.publisher or run.invoker Its one target

Data protection: messages are encrypted at rest and in transit by default; use CMEK on topics where compliance demands key custody, and keep PII out of attributes (they surface in logs, filters and metric labels far more readily than payloads). Regulated perimeters: all five services respect VPC Service Controls, and Eventarc’s internal-HTTP destinations reach private receivers without public ingress. Finally, treat replay as privileged: seek/replay re-triggers side effects at scale, so gate pubsub.subscriptions.seek behind a break-glass role.

Cost & sizing

The family is cheap at mid-scale and its costs are load-shaped, not idle-shaped — the expensive mistakes are architectural (chatty events, payloads that should be GCS references, Composer where Workflows would do).

Cost driver Rate (approx, USD) The lever
Pub/Sub throughput ~$40/TiB (publish + each delivery leg); first 10 GiB/mo free Payload size discipline; references not blobs; subscription filters still bill filtered messages
Topic/acked retention Per GiB-month extra Retain 7 d only where replay is a real requirement
Eventarc (Standard) Pass-through Pub/Sub transport Free-ish; Advanced bills per event — model before adopting
Cloud Tasks ~$0.40/M operations; 1 M free/mo Ops = create+dispatch+mgmt; batch tiny commands
Cloud Scheduler ~$0.10/job/month; 3 free Jobs are billed, not ticks — consolidate micro-crons
Workflows ~$0.01/1k internal steps (5k free); ~$0.025/1k external calls (2k free) Connectors count as steps; avoid per-item loops — fan out via Pub/Sub
Cloud Run consumers vCPU-s + GiB-s while handling Concurrency > 1 for IO-bound handlers; min-instances=0
Cloud Logging/Monitoring Ingest per GiB Audit-log triggers can 10× log volume — sample/exclude

A worked month for a mid-size platform — 50 M events, 2 KB average payload, three consumers per event, 5 M commands, 1 M saga executions of ~12 steps:

Component Math ≈ USD ≈ INR
Pub/Sub (publish + 3 deliveries) 50 M × 2 KB × 4 legs ≈ 0.38 TiB × $40 $15 ₹1,350
Cloud Tasks 5 M ops × $0.40/M $2 ₹180
Workflows 12 M steps × $0.01/1k $120 ₹10,800
Cloud Scheduler 12 jobs $1.2 ₹110
Cloud Run (consumers) ~3 vCPU avg utilised $70–120 ₹6,300–10,800
Total event machinery ~$210–260 ~₹19,000–23,500

Sizing heuristics: keep payloads ≤ ~10 KB (larger → GCS object + reference event); size Cloud Run as instances × concurrency × (1/handler-seconds) ≥ arrival rate + drain margin; and multiply your hottest saga’s step count by monthly volume before committing — a 40-step saga at 10 M executions/month is $4,000; restructure or batch. Baseline for perspective: one always-on Composer environment costs more than this entire worked example.

Interview & exam questions

Relevant to the Professional Cloud Architect and Professional Cloud Developer certs; also standard system-design interview territory.

  1. Q: When would you choose Cloud Tasks over Pub/Sub? A: When the message is a command with one intended executor and I need controls Pub/Sub lacks: per-queue rate limiting, concurrency caps, scheduled delivery up to 30 days, named-task deduplication, and cancellation of pending work. Pub/Sub wins for facts that fan out to unknown consumers.

  2. Q: Explain choreography vs orchestration and when each fits. A: Choreography lets services react to each other’s events — minimal coupling, easy consumer addition, but no owner of the end-to-end outcome. Orchestration (Workflows) centralises the flow: explicit steps, retries, compensation and one execution record. Broadcast facts choreograph; money paths and SLA-bound flows orchestrate.

  3. Q: What is a saga and how does Workflows implement compensation? A: A saga replaces a distributed transaction with local transactions, each paired with an undo. In Workflows each forward step is a try with a retry policy; the except block runs compensations for already-completed steps (release reservation, refund charge) and re-raises so the execution fails honestly.

  4. Q: Pub/Sub exactly-once delivery is enabled — can duplicates still occur end to end? A: Yes. Exactly-once applies to redelivery of acked messages on a regional pull subscription. A publisher retry creates a second message with a new messageId, and a handler crash after its side effect but before ack still repeats the effect. End-to-end exactly-once needs idempotent handlers and, where DB state and events must agree, an outbox.

  5. Q: What problem does the transactional outbox solve? A: The dual-write inconsistency: committing state and publishing an event are two systems that can’t share a transaction. Writing the event to an outbox table in the same DB transaction, with an at-least-once relay publishing it, guarantees state and event agree; consumer idempotency absorbs relay duplicates.

  6. Q: Three ways Eventarc gets events, and when do you use audit-log triggers? A: Direct events from integrated sources (fastest, typed — e.g. GCS object.finalized), Cloud Audit Logs events (near-universal API coverage, higher latency, Data Access logs required), and custom Pub/Sub topics. Audit-log triggers are the fallback when no direct event exists — e.g. reacting to a BigQuery job completion.

  7. Q: How do you stop a burst of events from crushing a rate-limited legacy system? A: Don’t fan Pub/Sub directly into it. Route that leg through a Cloud Tasks queue whose maxDispatchesPerSecond/maxConcurrentDispatches sit under the target’s real capacity; the burst becomes queue depth. Pub/Sub can feed an enqueuer, keeping fan-out semantics upstream.

  8. Q: An ordered subscription has stopped delivering for one customer but others flow. Why? A: Head-of-line blocking on that customer’s ordering key — a message that keeps failing blocks everything behind it on the same key (ordering also caps publishes ~1 MiB/s per key). Attach a dead-letter policy so the poison message parks after max attempts, unblocking the key, then fix and replay.

  9. Q: Workflows vs Cloud Composer? A: Workflows is serverless, per-step billed, millisecond-start, year-long executions with callbacks — built for service orchestration and sagas. Composer is managed Airflow: an always-on environment (hundreds of dollars monthly) with the operator/backfill ecosystem data engineering expects. Orchestrate microservices with Workflows; orchestrate data pipelines with Composer.

  10. Q: How does trace context survive a Pub/Sub hop? A: It must travel in the message, not the connection — the client libraries’ OpenTelemetry integration propagates W3C context via a googclient_traceparent attribute and emits publish/deliver spans; consumers extract it and start child spans. Logs correlate via the logging.googleapis.com/trace field.

  11. Q: Design “release unsold seat holds after exactly 10 minutes” without cron sweeps. A: On hold creation, enqueue a named Cloud Tasks task release-{holdId} with scheduleTime = now+10m targeting the release endpoint; on purchase, delete the task. Exact-time dispatch, per-hold cancellation, named-task dedup — no polling, no sweep races.

  12. Q: What belongs in message attributes vs payload? A: Attributes carry routing and infrastructure metadata — event type/version, tenant, trace context, idempotency key — filterable without deserialising. The payload carries the schema-governed domain fact. Never PII in attributes.

Quick check

  1. A team publishes generate-invoice-pdf messages to a Pub/Sub topic consumed by one service that keeps overloading a PDF API. What two properties do they need that Pub/Sub can’t give them?
  2. Your saga’s email step cannot be undone. Where does it go in the workflow and why?
  3. dead_letter_message_count just went above zero. Is that an error in the DLQ setup?
  4. Exactly-once delivery is on, yet finance sees duplicate ledger entries. Name two remaining duplicate sources.
  5. Which Eventarc source class reacts to any GCP API call, and what must be enabled first?

Answers

  1. Per-consumer rate limiting and concurrency caps (plus cancellation) — it’s a command, so it belongs in a Cloud Tasks queue with maxDispatchesPerSecond tuned to the PDF API.
  2. Last, after every step that can fail — irreversible actions run only once all compensable steps have succeeded, so no compensation ever needs to “unsend” it.
  3. No — it means the system worked: a poison message parked after max delivery attempts instead of wedging the subscription. The action is triage, fix, replay.
  4. Publisher retries (new messageId, same fact) and handler crash after the side effect but before ack; exactly-once only prevents redelivery of already-acked messages on that pull subscription.
  5. Cloud Audit Logs triggers (google.cloud.audit.log.v1.written filtered by serviceName/methodName) — Data Access audit logs must be enabled for the service, with their logging cost accepted.

Glossary

Next steps

GCPEvent-DrivenPub/SubEventarcCloud TasksWorkflowsCloud RunArchitecture
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading