Google Pub/Sub #

Google Cloud Pub/Sub is Google Cloud’s fully-managed asynchronous messaging service designed for global scalability and low latency. It combines the strengths of Kafka (message retention, replay with snapshots) and SQS (fully-managed, no infrastructure) into a single service. Pub/Sub can handle millions of messages per second with at-least-once delivery, supports push subscriptions for serverless (Cloud Functions, Cloud Run), and has subscription filters for routing without complex consumer groups. In Ruby, the official google-cloud-pubsub gem provides a clean, idiomatic API with full support for all modern Pub/Sub features.

Google Pub/Sub Basic Concepts #

flowchart LR
    P[Publisher\nRuby App] -->|publish| T[Topic:\norders]

    T --> S1[Subscription:\norders-notifications\nPull]
    T --> S2[Subscription:\norders-analytics\nPull]
    T --> S3[Subscription:\norders-webhook\nPush → Cloud Run]

    S1 --> C1[Consumer\nNotification Service]
    S2 --> C2[Consumer\nAnalytics Service]
    S3 --> C3[Cloud Run\nEndpoint]

    T --> DLT[Dead Letter Topic:\norders-dlq]
Key Pub/Sub concepts:
  Topic        — a message channel, publishers send here
  Subscription — consumers create subscriptions to a topic
                 One topic can have many subscriptions
                 Every subscription receives ALL messages (not round-robin)
  Message      — a message unit with data, attributes, and a message_id
  Ack          — confirmation a message was processed (removed from the subscription)
  Nack         — reject a message, return it for redelivery

Key differences from SQS:
  ✓ Every subscription receives a copy of ALL messages (not shared)
  ✓ Snapshots for message replay (like Kafka offsets)
  ✓ Subscription-level filters — no routing needed in the application
  ✓ Push subscriptions — Pub/Sub sends HTTP requests to your endpoint
  ✗ No built-in FIFO (use ordering keys per-partition)

Installation and Setup #

gem install google-cloud-pubsub

# Or in the Gemfile
# Gemfile
gem 'google-cloud-pubsub', '~> 2.18'

Authentication #

require 'google/cloud/pubsub'

# Method 1: Application Default Credentials (ADC) — automatic from the environment
# Locally: run `gcloud auth application-default login`
# On GCE/GKE/Cloud Run: automatic from the attached service account
pubsub = Google::Cloud::PubSub.new(project_id: "my-project-id")

# Method 2: Service Account JSON file
pubsub = Google::Cloud::PubSub.new(
  project_id:  "my-project-id",
  credentials: "/path/to/service-account.json"
)

# Method 3: Environment variables (most common in production)
# GOOGLE_CLOUD_PROJECT=my-project-id
# GOOGLE_APPLICATION_CREDENTIALS=/path/to/service-account.json
pubsub = Google::Cloud::PubSub.new

# Method 4: Direct credential hash (avoid in production)
pubsub = Google::Cloud::PubSub.new(
  project_id:  "my-project-id",
  credentials: {
    type:                 "service_account",
    project_id:           "my-project-id",
    private_key_id:       ENV["GCP_PRIVATE_KEY_ID"],
    private_key:          ENV["GCP_PRIVATE_KEY"],
    client_email:         ENV["GCP_CLIENT_EMAIL"],
    client_id:            ENV["GCP_CLIENT_ID"],
    auth_uri:             "https://accounts.google.com/o/oauth2/auth",
    token_uri:            "https://oauth2.googleapis.com/token"
  }
)

# Method 5: Emulator for local development (no connection to GCP)
# Run: gcloud beta emulators pubsub start
ENV["PUBSUB_EMULATOR_HOST"] = "localhost:8085"
pubsub = Google::Cloud::PubSub.new(project_id: "test-project")

Topic and Subscription Management #

pubsub = Google::Cloud::PubSub.new(project_id: "my-project")

# Create a topic
topic = pubsub.create_topic("orders")

# Or fetch an existing topic
topic = pubsub.topic("orders")

# Create a topic with configuration
topic = pubsub.create_topic(
  "transactions",
  message_retention_duration: "604800s",  # 7 day message retention
  kms_key_name: "projects/my-project/locations/global/keyRings/my-ring/cryptoKeys/my-key"
)

# List all topics
pubsub.topics.each { |t| puts t.name }

# Delete a topic
topic.delete

# ===== Subscriptions =====

# Pull Subscription — a consumer that actively requests messages
sub = topic.subscribe(
  "orders-notifications",
  deadline:                    60,     # ack deadline in seconds (max 600)
  retain_acked_messages:       false,  # true for replay capability
  message_retention_duration:  "604800s",  # 7 days
  enable_message_ordering:     false   # true for ordered delivery
)

# Push Subscription — Pub/Sub sends an HTTP POST to the endpoint
push_sub = topic.subscribe(
  "orders-webhook",
  endpoint: "https://api.example.com/webhook/pubsub"
)

# Subscription with a filter — only receive messages with certain attributes
filter_sub = topic.subscribe(
  "premium-orders",
  filter: 'attributes.customer_type = "premium"'
)

# Subscription with a Dead Letter Topic
dlq_topic = pubsub.create_topic("orders-dlq")
sub_with_dlq = topic.subscribe(
  "orders-with-dlq",
  dead_letter_topic:              dlq_topic,
  max_delivery_attempts:         5   # after 5 failures → DLQ
)

# Subscription with a retry policy
sub_with_retry = topic.subscribe(
  "orders-retry",
  minimum_backoff: 10,   # min 10 seconds before retry
  maximum_backoff: 600   # max 600 seconds (10 minutes)
)

# Fetch an existing subscription
sub = pubsub.subscription("orders-notifications")

# List subscriptions
topic.subscriptions.each { |s| puts s.name }

Publisher — Sending Messages #

topic = pubsub.topic("orders")

# Publish a simple message
message_id = topic.publish(
  JSON.generate({
    event:     "order_created",
    order_id:  12345,
    total:     150_000,
    timestamp: Time.now.iso8601
  })
)
puts "Message ID: #{message_id}"

# Publish with attributes — usable for subscription filters
message_id = topic.publish(
  JSON.generate({ order_id: 12346, total: 250_000 }),
  "event_type"      => "new_order",
  "customer_type"   => "premium",
  "schema_version"  => "v2",
  "environment"     => Rails.env
)

# Publish with an ordering key — messages with the same key are delivered in order
# (the subscription must have enable_message_ordering: true)
topic.publish(
  JSON.generate({ order_id: 100, status: "created" }),
  ordering_key: "user-#{user_id}"
)

topic.publish(
  JSON.generate({ order_id: 100, status: "paid" }),
  ordering_key: "user-#{user_id}"
  # This message is guaranteed to be delivered AFTER the one above for the same key
)

Batch Publish — High Throughput Efficiency #

# publish with a block — Pub/Sub batches automatically
topic.publish do |batch|
  order_list.each do |order|
    batch.publish(
      JSON.generate({
        order_id: order.id,
        total:    order.total,
        status:   order.status
      }),
      "event_type" => "order_sync"
    )
  end
end

# Async publisher — publishes in a background thread, non-blocking
publisher = topic.async_publisher(
  max_bytes:    1_000_000,   # flush when the buffer reaches 1MB
  max_messages: 100,         # flush when the buffer has 100 messages
  interval:     0.25         # flush every 250ms even if not full
)

# Callback for confirmations or errors
publisher.on_error do |exception|
  Rails.logger.error "Publish error: #{exception.message}"
end

# Async publish — returns immediately without waiting for confirmation
1000.times do |i|
  publisher.publish(
    JSON.generate({ seq: i, ts: Time.now.to_i }),
    "batch_id" => "batch-#{Time.now.to_i}"
  )
end

# Flush all messages still in the buffer
publisher.flush
publisher.stop.wait!   # wait for everything to finish before shutdown

Subscriber — Receiving Messages #

sub = pubsub.subscription("orders-notifications")

# Streaming pull — the subscriber runs in background threads
# Far more efficient than manual polling
subscriber = sub.listen(
  streams:           4,    # number of parallel streaming connections
  inventory:         1000, # number of messages buffered
  threads: {
    callback: 8,   # threads for running callbacks
    push:     4    # threads for pushing acks/nacks to Pub/Sub
  }
) do |received_message|

  begin
    data = JSON.parse(received_message.data, symbolize_names: true)
    attrs = received_message.attributes
    puts "Message: #{received_message.message_id}"
    puts "Publish time: #{received_message.published_at}"
    puts "Delivery attempt: #{received_message.delivery_attempt}"
    puts "Data: #{data}"
    puts "Attrs: #{attrs}"

    # Process the message
    process_order(data)

    # ACK — confirm successful processing
    received_message.acknowledge!
    puts "ACK: #{received_message.message_id}"

  rescue JSON::ParserError => e
    Rails.logger.error "Invalid JSON: #{e.message}"
    received_message.acknowledge!   # remove invalid messages so they aren't redelivered

  rescue => e
    Rails.logger.error "Error: #{e.message}"
    # NACK — reject, return for redelivery
    received_message.nack!
    # Pub/Sub will retry according to the retry policy (with backoff)
  end
end

# Start the subscriber
subscriber.start

# Graceful shutdown
trap("SIGTERM") do
  subscriber.stop
  subscriber.wait!
  puts "Subscriber stopped"
end

# Block the main thread
subscriber.wait!

Manual Pull — One by One #

sub = pubsub.subscription("orders-analytics")

# Pull a number of messages synchronously
loop do
  received_messages = sub.pull(
    max_messages:      10,     # max messages per pull
    return_immediately: false  # wait if there are no messages
  )

  received_messages.each do |message|
    begin
      data = JSON.parse(message.data, symbolize_names: true)
      process_analytics(data)

      # ACK via the subscription
      sub.acknowledge(message)

    rescue => e
      puts "Error: #{e.message}"
      sub.nack(message)
    end
  end

  sleep 1 if received_messages.empty?
end

Acknowledgment and Nack #

# Three options after receiving a message:

# 1. ACK — processed successfully
received_message.acknowledge!
# or: sub.acknowledge(received_message)

# 2. NACK — failed, request redelivery
received_message.nack!
# Pub/Sub will retry according to the backoff policy

# 3. Modify Ack Deadline — extend the time before a retry
# Useful if processing takes longer than the default ack deadline
received_message.modify_ack_deadline!(120)   # add another 120 seconds

# Extend periodically in a separate thread
Thread.new do
  loop do
    sleep 50   # extend before the 60 second deadline expires
    received_message.modify_ack_deadline!(60)
  end
end

# A processing task that takes a long time
heavy_process(JSON.parse(received_message.data))
received_message.acknowledge!

Dead Letter Topics #

# Set up a Dead Letter Topic
dlq_topic = pubsub.create_topic("orders-dlq")

# Create a DLQ subscription to monitor failed messages
dlq_sub = dlq_topic.subscribe("orders-dlq-monitor")

# Main subscription with a DLQ
sub = topic.subscribe(
  "orders-with-dlq",
  dead_letter_topic:     dlq_topic,
  max_delivery_attempts: 5   # after 5 attempts → DLQ
)

# Monitor the DLQ
dlq_subscriber = dlq_sub.listen do |message|
  attempts = message.delivery_attempt
  puts "DLQ: #{message.message_id} after #{attempts} attempts"

  data = JSON.parse(message.data) rescue message.data

  # Log and save for analysis
  FailedOrder.create!(
    message_id:       message.message_id,
    payload:          message.data,
    delivery_attempt: attempts,
    attributes:       message.attributes,
    failed_at:        Time.now
  )

  # Notify the team
  Monitoring.alert("Message in DLQ", message_id: message.message_id, data: data)

  message.acknowledge!
end

dlq_subscriber.start

Snapshots and Replay #

Snapshots let consumers re-read messages from a certain point in the past — similar to Kafka offsets:

sub = pubsub.subscription("orders-analytics")

# Create a snapshot — save the current position
snapshot = sub.create_snapshot("before-migration-#{Time.now.strftime('%Y%m%d%H%M%S')}")
puts "Snapshot: #{snapshot.name}"
puts "Valid until: #{snapshot.expiration_time}"

# Run a migration or experiment
# ...

# If there are problems, roll back to the snapshot
sub.seek(snapshot)   # rewind to the position when the snapshot was created
puts "Seek to snapshot successful"

# Or seek to a specific time (replay messages from 2 hours ago)
sub.seek(2.hours.ago)

# List all snapshots
pubsub.snapshots.each { |s| puts "#{s.name}: #{s.expiration_time}" }

# Delete a snapshot
snapshot.delete

Subscription Filters #

Subscription filters allow routing based on message attributes without needing routing code in the consumer:

# Main topic receiving all events
topic = pubsub.topic("all_events")

# Subscription that only receives premium orders
premium_sub = topic.subscribe(
  "premium-order-events",
  filter: 'attributes.customer_type = "premium" AND attributes.event_type = "new_order"'
)

# Subscription that only receives payments above 1 million
large_sub = topic.subscribe(
  "large-payment-events",
  filter: 'attributes.event_type = "payment" AND attributes.amount > "1000000"'
)

# Subscription for all errors
error_sub = topic.subscribe(
  "all-error-events",
  filter: 'attributes.severity = "ERROR" OR attributes.severity = "CRITICAL"'
)

# Publishers just publish to one topic with attributes
topic.publish(
  JSON.generate({ order_id: 999, total: 5_000_000 }),
  "event_type"     => "new_order",
  "customer_type"  => "premium",
  "amount"         => "5000000"
)
# Only premium_sub and large_sub will receive this message

Push Subscriptions — Serverless Receivers #

Push subscriptions reverse the direction — Pub/Sub actively sends HTTP POSTs to your endpoint:

# Create a push subscription sending to a Cloud Run endpoint
sub = topic.subscribe(
  "orders-push",
  endpoint:              "https://api.example.com/pubsub/orders",
  authentication: {
    service_account_email: "[email protected]",
    audience:              "https://api.example.com"
  }
)

# On the Rails server side receiving pushes
# app/controllers/pubsub_controller.rb
class PubsubController < ApplicationController
  skip_before_action :verify_authenticity_token

  def orders
    # Pub/Sub sends base64-encoded data in a JSON envelope
    envelope = JSON.parse(request.body.read)
    payload  = Base64.decode64(envelope["message"]["data"])
    data     = JSON.parse(payload, symbolize_names: true)
    attrs    = envelope["message"]["attributes"] || {}

    Rails.logger.info "Pub/Sub message: #{envelope['message']['messageId']}"

    process_order(data)

    # HTTP 200 = ACK. Non-2xx = NACK (Pub/Sub will retry)
    head :ok

  rescue => e
    Rails.logger.error "Error: #{e.message}"
    head :internal_server_error   # trigger a retry
  end
end

Rails Integration #

# config/initializers/pubsub.rb
module Pubsub
  def self.client
    @client ||= Google::Cloud::PubSub.new(
      project_id: ENV.fetch("GOOGLE_CLOUD_PROJECT"),
      credentials: ENV["GOOGLE_APPLICATION_CREDENTIALS"]
    )
  end

  def self.topic(name)
    @topics ||= {}
    @topics[name] ||= client.topic(name) || client.create_topic(name)
  end

  def self.publish(topic_name, data, **attributes)
    topic(topic_name).publish(
      JSON.generate(data),
      **attributes.transform_keys(&:to_s),
      "environment"   => Rails.env,
      "schema_version" => "v1",
      "timestamp"      => Time.now.iso8601
    )
  end
end

# Use from anywhere in Rails
class OrderService
  def self.create(user, params)
    order = Order.create!(user: user, **params)

    Pubsub.publish(
      "orders",
      { order_id: order.id, total: order.total, status: order.status },
      event_type:      "order_created",
      customer_type:   user.tier
    )

    order
  end
end

# app/workers/pubsub_worker.rb — worker for a pull subscription
class PubsubWorker
  SUBSCRIPTION_NAME = ENV.fetch("PUBSUB_SUBSCRIPTION", "orders-worker")

  def self.run
    sub = Pubsub.client.subscription(SUBSCRIPTION_NAME)

    subscriber = sub.listen(streams: 4, threads: { callback: 8 }) do |msg|
      begin
        data = JSON.parse(msg.data, symbolize_names: true)

        case data[:event]
        when "order_created"
          ProcessOrder.call(data)
        when "payment_success"
          ProcessPayment.call(data)
        else
          Rails.logger.warn "Unknown event: #{data[:event]}"
        end

        msg.acknowledge!

      rescue => e
        Rails.logger.error "PubsubWorker error: #{e.message}\n#{e.backtrace.first(5).join("\n")}"
        msg.nack!
      end
    end

    subscriber.start
    trap("SIGTERM") { subscriber.stop.wait! }
    subscriber.wait!
  end
end

Pub/Sub Lite — For Ultra-High Throughput #

Pub/Sub Lite is a cheaper variant for very high throughput, with trade-offs: it’s not globally replicated and capacity must be managed:

require 'google/cloud/pubsub/v1'

# Pub/Sub Lite uses a different API
lite_client = Google::Cloud::PubSub::V1::AdminService::Client.new

# Create a Lite Topic with provisioned capacity
lite_topic = lite_client.create_topic(
  parent: "projects/my-project/locations/us-central1-a",
  topic_id: "transactions-lite",
  topic: {
    partition_config: {
      count:    8,                    # 8 partitions
      capacity: {
        publish_mib_per_sec:   16,    # 16 MB/s publish capacity
        subscribe_mib_per_sec: 32     # 32 MB/s subscribe capacity
      }
    },
    retention_config: {
      per_partition_bytes: 30.gigabytes,
      period: { seconds: 604_800 }   # 7 days
    }
  }
)

Monitoring with Google Cloud Monitoring #

# Important Pub/Sub metrics to monitor:
# subscription/num_undelivered_messages  → number of unprocessed messages
# subscription/oldest_unacked_message_age → age of the oldest unacked message
# topic/send_message_operation_count      → publisher throughput
# subscription/pull_message_operation_count → subscriber throughput

# Create an alert via the gcloud CLI:
# gcloud alpha monitoring policies create \
#   --notification-channels=... \
#   --condition-filter='resource.type="pubsub_subscription"
#     AND metric.type="pubsub.googleapis.com/subscription/num_undelivered_messages"
#     AND metric.labels.subscription_id="orders-notifications"' \
#   --condition-threshold-value=1000 \
#   --condition-threshold-comparison=COMPARISON_GT \
#   --display-name="Pub/Sub orders-notifications high lag"

Google Pub/Sub vs Amazon SQS Comparison #

AspectGoogle Pub/SubAmazon SQS
Delivery modelAt-least-onceAt-least-once (Standard) / Exactly-once (FIFO)
Fan-outNative — every subscription receives everythingNeeds SNS + SQS
OrderingWith ordering keys (per-partition)FIFO queues only
ReplaySnapshots + seek to time/snapshotNone (SQS), retention in Kafka
PushNative push subscriptionsNone (needs a Lambda trigger)
FiltersSubscription-level filtersNo native filters
Message retentionMax 7 daysMax 14 days
Globally replicatedYes (default)Yes (within a region)
Pricing modelPer operationPer request + data transfer
EcosystemGCP nativeAWS native

Summary #

  • Every subscription receives all messages — unlike SQS, which divides messages between consumers; in Pub/Sub, one topic + two subscriptions = every subscription receives an independent copy of all messages.
  • Streaming pull is more efficient than manual pollingsub.listen { |msg| ... } manages connections, thread pools, and batching automatically; use it for production consumers.
  • Ack deadlines must be realistic — set the deadline longer than the estimated processing time; if messages frequently time out before being acked, Pub/Sub will keep redelivering and cause duplicates.
  • Subscription filters for routing — instead of building routing logic in consumer code, use subscription-level filters; much cleaner and no redeployment needed when adding filters.
  • Snapshots for replay and rollback — create a snapshot before major deployments or data migrations; if problems arise, seek back to the snapshot to replay all processed messages.
  • Dead Letter Topics are mandatory for production subscriptions — configure max_delivery_attempts: 5 and create a consumer for the DLQ; persistently failing messages won’t block other messages.
  • Async publishers for high throughputtopic.async_publisher batches messages in a buffer and sends them asynchronously; far more efficient than synchronous one-by-one publishing.
  • Push subscriptions for serverless — Cloud Run and Cloud Functions can receive messages without needing keep-alive connections; Pub/Sub actively sends HTTP POSTs when messages arrive.
  • Ordering keys for guaranteed order — enable enable_message_ordering: true on the subscription and send with the same ordering_key to ensure per-key ordering.
  • ADC (Application Default Credentials) for authentication — on Cloud Run/GKE use the attached service account without credential files; locally use gcloud auth application-default login.

← Previous: Amazon SQS   Next: Redis →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact