Google Pub/Sub #
Google Cloud Pub/Sub is a fully managed asynchronous messaging and data streaming service on Google Cloud Platform. It offers at-least-once delivery guarantees, ordering guarantees (with ordering keys), and native integration with the GCP ecosystem such as Cloud Functions, Dataflow, and BigQuery. Unlike Kafka (self-managed) and RabbitMQ (AMQP protocol), Pub/Sub is a fully managed service — no servers to configure.
Basic Pub/Sub Concepts #
Topic → a channel for publishing messages
Subscription → a subscription to a topic; each subscription has its own copy of messages
Publisher → sends messages to a topic
Subscriber → receives messages from a subscription (pull or push)
Message ID → the unique ID GCP assigns to each message
Ack ID → the ID used to acknowledge a message as processed
Visualizing the Google Cloud Pub/Sub Architecture #
Here’s a visualization of publishing messages to a Topic, distributing them to Subscriptions, and the difference between the Pull and Push delivery mechanisms:
flowchart TD
Pub["Publisher (Go Application)"] -->|"Publish Message"| Topic["GCP Pub/Sub Topic"]
Topic -->|"Message Copy"| SubPull["Subscription A (Pull)"]
Topic -->|"Message Copy"| SubPush["Subscription B (Push)"]
SubPull -->|"Pull (Worker Requests)"| Work1["Gopher Worker 1"]
SubPull -->|"Pull (Worker Requests)"| Work2["Gopher Worker 2"]
SubPush -->|"Push (GCP Sends)"| Endpoint["HTTPS API Endpoint (Cloud Run / Serverless)"]Installation #
go get cloud.google.com/go/pubsub
Setting Up the Client #
import "cloud.google.com/go/pubsub"
func newPubSubClient(ctx context.Context, projectID string) (*pubsub.Client, error) {
// Authentication via Application Default Credentials (ADC):
// - GOOGLE_APPLICATION_CREDENTIALS env var
// - gcloud auth application-default login
// - Service account on GCE/GKE/Cloud Run
client, err := pubsub.NewClient(ctx, projectID)
if err != nil {
return nil, fmt.Errorf("create client: %w", err)
}
return client, nil
}
// For a local emulator (development)
func newEmulatorClient(ctx context.Context) (*pubsub.Client, error) {
// Set env: PUBSUB_EMULATOR_HOST=localhost:8085
return pubsub.NewClient(ctx, "local-project")
}
Creating Topics and Subscriptions #
func createTopic(ctx context.Context, client *pubsub.Client, topicID string) (*pubsub.Topic, error) {
// Create the topic if it doesn't exist
topic, err := client.CreateTopic(ctx, topicID)
if err != nil {
// If it already exists, get the existing topic
if status.Code(err) == codes.AlreadyExists {
return client.Topic(topicID), nil
}
return nil, fmt.Errorf("create topic: %w", err)
}
// Topic configuration
cfg := pubsub.TopicConfig{
MessageStoragePolicy: pubsub.MessageStoragePolicy{
AllowedPersistenceRegions: []string{"asia-southeast1"},
},
// Schema validation (optional)
// SchemaSettings: &pubsub.SchemaSettings{...},
}
if _, err := topic.Update(ctx, pubsub.TopicConfigToUpdate{
MessageStoragePolicy: cfg.MessageStoragePolicy,
}); err != nil {
log.Printf("Update topic config: %v", err)
}
return topic, nil
}
func createSubscription(ctx context.Context, client *pubsub.Client,
subID, topicID string) (*pubsub.Subscription, error) {
topic := client.Topic(topicID)
sub, err := client.CreateSubscription(ctx, subID, pubsub.SubscriptionConfig{
Topic: topic,
AckDeadline: 30 * time.Second, // processing time limit before redelivery
RetainAckedMessages: false,
// Dead letter topic for messages that repeatedly fail
DeadLetterPolicy: &pubsub.DeadLetterPolicy{
DeadLetterTopic: "projects/myproject/topics/dead-letter",
MaxDeliveryAttempts: 5,
},
// Retry policy
RetryPolicy: &pubsub.RetryPolicy{
MinimumBackoff: 10 * time.Second,
MaximumBackoff: 600 * time.Second,
},
// Filter — only receive messages with certain attributes
Filter: `attributes.source = "order-service"`,
// Ordering — enable if the publisher uses ordering keys
EnableMessageOrdering: true,
})
if err != nil {
if status.Code(err) == codes.AlreadyExists {
return client.Subscription(subID), nil
}
return nil, fmt.Errorf("create subscription: %w", err)
}
return sub, nil
}
Publish — Sending Messages #
func publish(ctx context.Context, topic *pubsub.Topic, data interface{}, attrs map[string]string) error {
body, err := json.Marshal(data)
if err != nil {
return err
}
msg := &pubsub.Message{
Data: body,
Attributes: attrs,
// OrderingKey — messages with the same key are delivered in order
// (the subscription must have EnableMessageOrdering=true)
// OrderingKey: "order-" + orderID,
}
result := topic.Publish(ctx, msg)
// Wait for the server confirmation (blocking)
msgID, err := result.Get(ctx)
if err != nil {
return fmt.Errorf("publish: %w", err)
}
log.Printf("Message sent, ID: %s", msgID)
return nil
}
// Publish with an ordering key
func publishOrdered(ctx context.Context, topic *pubsub.Topic,
orderingKey string, data interface{}) error {
body, _ := json.Marshal(data)
result := topic.Publish(ctx, &pubsub.Message{
Data: body,
OrderingKey: orderingKey,
})
_, err := result.Get(ctx)
return err
}
// Batch publish — send many messages, Pub/Sub auto-batches
func publishBatch(ctx context.Context, topic *pubsub.Topic, messages []interface{}) error {
// Configure the batch settings
topic.PublishSettings = pubsub.PublishSettings{
ByteThreshold: 1e6, // flush when the batch reaches 1MB
CountThreshold: 100, // flush at 100 messages
DelayThreshold: 10 * time.Millisecond, // flush every 10ms
}
results := make([]*pubsub.PublishResult, len(messages))
for i, msg := range messages {
body, _ := json.Marshal(msg)
results[i] = topic.Publish(ctx, &pubsub.Message{Data: body})
}
// Wait for all publishes to finish
var errors []error
for i, r := range results {
if _, err := r.Get(ctx); err != nil {
errors = append(errors, fmt.Errorf("message %d: %w", i, err))
}
}
if len(errors) > 0 {
return fmt.Errorf("%d messages failed to send", len(errors))
}
return nil
}
Receive — Receiving Messages (Pull) #
func receive(ctx context.Context, sub *pubsub.Subscription,
handler func(ctx context.Context, msg *pubsub.Message) error) error {
// Configure the receive settings
sub.ReceiveSettings = pubsub.ReceiveSettings{
MaxExtension: 60 * time.Minute, // max time to extend the ack deadline
MaxExtensionPeriod: 10 * time.Minute,
MaxOutstandingMessages: 100, // max messages processed concurrently
MaxOutstandingBytes: 1e9, // 1GB max outstanding data
NumGoroutines: 5, // parallel goroutines for receiving
}
return sub.Receive(ctx, func(ctx context.Context, msg *pubsub.Message) {
log.Printf("Message received: ID=%s, PublishTime=%s",
msg.ID, msg.PublishTime.Format(time.RFC3339))
log.Printf(" Attributes: %v", msg.Attributes)
log.Printf(" OrderingKey: %s", msg.OrderingKey)
if err := handler(ctx, msg); err != nil {
log.Printf("Error processing message: %v", err)
// Nack — Pub/Sub will redeliver per the retry policy
msg.Nack()
return
}
// Ack — mark the message as successfully processed
msg.Ack()
})
}
// Receive with a timeout
func receiveWithTimeout(ctx context.Context, sub *pubsub.Subscription,
timeout time.Duration, handler func(*pubsub.Message) error) error {
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
return sub.Receive(ctx, func(ctx context.Context, msg *pubsub.Message) {
if err := handler(msg); err != nil {
msg.Nack()
return
}
msg.Ack()
})
}
Push Subscriptions — Server Receives via HTTP #
// For push subscriptions, GCP sends an HTTP POST to your endpoint
// Body format: {"message": {"data": "base64...", "attributes": {...}}, "subscription": "..."}
type PushMessage struct {
Message struct {
Data string `json:"data"`
Attributes map[string]string `json:"attributes"`
MessageID string `json:"messageId"`
PublishTime string `json:"publishTime"`
} `json:"message"`
Subscription string `json:"subscription"`
}
func pubsubPushHandler(w http.ResponseWriter, r *http.Request) {
var pushMsg PushMessage
if err := json.NewDecoder(r.Body).Decode(&pushMsg); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
// Decode the base64 data
data, err := base64.StdEncoding.DecodeString(pushMsg.Message.Data)
if err != nil {
http.Error(w, "bad data", http.StatusBadRequest)
return
}
log.Printf("Push message: ID=%s, data=%s",
pushMsg.Message.MessageID, string(data))
// Process the message
if err := processEvent(data, pushMsg.Message.Attributes); err != nil {
// Return non-200 → Pub/Sub will retry
http.Error(w, "processing failed", http.StatusInternalServerError)
return
}
// Return 200 → Pub/Sub considers the message acked
w.WriteHeader(http.StatusNoContent)
}
Complete Example Program — Event Streaming Pipeline #
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"os"
"os/signal"
"sync"
"syscall"
"time"
"cloud.google.com/go/pubsub"
)
const (
projectID = "my-gcp-project"
topicOrders = "orders"
subOrders = "orders-processor"
)
type OrderEvent struct {
EventID string `json:"event_id"`
Type string `json:"type"`
OrderID string `json:"order_id"`
CustomerID string `json:"customer_id"`
Amount float64 `json:"amount"`
Items []string `json:"items"`
Timestamp time.Time `json:"timestamp"`
}
// EventPublisher sends events to Pub/Sub
type EventPublisher struct {
topic *pubsub.Topic
}
func NewEventPublisher(ctx context.Context, client *pubsub.Client, topicID string) (*EventPublisher, error) {
topic := client.Topic(topicID)
ok, err := topic.Exists(ctx)
if err != nil {
return nil, err
}
if !ok {
topic, err = client.CreateTopic(ctx, topicID)
if err != nil {
return nil, err
}
}
topic.PublishSettings = pubsub.PublishSettings{
CountThreshold: 100,
DelayThreshold: 10 * time.Millisecond,
}
return &EventPublisher{topic: topic}, nil
}
func (p *EventPublisher) Publish(ctx context.Context, event OrderEvent) error {
data, err := json.Marshal(event)
if err != nil {
return err
}
result := p.topic.Publish(ctx, &pubsub.Message{
Data: data,
Attributes: map[string]string{
"event_type": event.Type,
"source": "order-service",
"version": "1.0",
},
// Use the OrderID as the ordering key — events for the same order are always in order
OrderingKey: event.OrderID,
})
msgID, err := result.Get(ctx)
if err != nil {
return fmt.Errorf("publish: %w", err)
}
log.Printf("[PUB] event=%s order=%s msgID=%s", event.Type, event.OrderID, msgID)
return nil
}
func (p *EventPublisher) Close() {
p.topic.Stop()
}
// EventProcessor processes events from Pub/Sub
type EventProcessor struct {
sub *pubsub.Subscription
stats map[string]int
statsMu sync.Mutex
}
func NewEventProcessor(ctx context.Context, client *pubsub.Client, subID, topicID string) (*EventProcessor, error) {
topic := client.Topic(topicID)
sub := client.Subscription(subID)
ok, err := sub.Exists(ctx)
if err != nil {
return nil, err
}
if !ok {
sub, err = client.CreateSubscription(ctx, subID, pubsub.SubscriptionConfig{
Topic: topic,
AckDeadline: 30 * time.Second,
EnableMessageOrdering: true,
})
if err != nil {
return nil, err
}
}
sub.ReceiveSettings = pubsub.ReceiveSettings{
MaxOutstandingMessages: 50,
NumGoroutines: 3,
}
return &EventProcessor{sub: sub, stats: make(map[string]int)}, nil
}
func (p *EventProcessor) Start(ctx context.Context) error {
log.Println("[SUB] Started processing events...")
return p.sub.Receive(ctx, func(ctx context.Context, msg *pubsub.Message) {
var event OrderEvent
if err := json.Unmarshal(msg.Data, &event); err != nil {
log.Printf("[SUB] ERROR unmarshal: %v", err)
msg.Nack()
return
}
log.Printf("[SUB] ID=%s type=%s order=%s amount=Rp%.0f",
msg.ID[:8]+"...", event.Type, event.OrderID, event.Amount)
if err := p.process(event); err != nil {
log.Printf("[SUB] FAILED to process %s: %v", event.OrderID, err)
msg.Nack()
return
}
p.statsMu.Lock()
p.stats[event.Type]++
p.statsMu.Unlock()
msg.Ack()
})
}
func (p *EventProcessor) process(event OrderEvent) error {
switch event.Type {
case "order.created":
fmt.Printf(" → Create order record %s (Rp%.0f)\n", event.OrderID, event.Amount)
time.Sleep(50 * time.Millisecond)
case "order.paid":
fmt.Printf(" → Mark paid and send a notification: %s\n", event.OrderID)
time.Sleep(30 * time.Millisecond)
case "order.shipped":
fmt.Printf(" → Update tracking: %s\n", event.OrderID)
time.Sleep(20 * time.Millisecond)
case "order.completed":
fmt.Printf(" → Complete the order and update stats: %s\n", event.OrderID)
time.Sleep(40 * time.Millisecond)
}
return nil
}
func (p *EventProcessor) PrintStats() {
p.statsMu.Lock()
defer p.statsMu.Unlock()
fmt.Println("\n=== Processing Statistics ===")
total := 0
for t, c := range p.stats {
fmt.Printf(" %-20s: %d events\n", t, c)
total += c
}
fmt.Printf(" %-20s: %d events\n", "TOTAL", total)
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sigCh
log.Println("Received signal, shutting down...")
cancel()
}()
// Set up the client
client, err := pubsub.NewClient(ctx, projectID)
if err != nil {
log.Fatal("Client:", err)
}
defer client.Close()
// Publisher
publisher, err := NewEventPublisher(ctx, client, topicOrders)
if err != nil {
log.Fatal("Publisher:", err)
}
defer publisher.Close()
// Processor
processor, err := NewEventProcessor(ctx, client, subOrders, topicOrders)
if err != nil {
log.Fatal("Processor:", err)
}
// Start the processor in a goroutine
go func() {
if err := processor.Start(ctx); err != nil && ctx.Err() == nil {
log.Printf("Processor error: %v", err)
}
}()
// Publish events
fmt.Println("=== Sending Order Events ===")
orders := []struct {
id string
events []string
amount float64
items []string
}{
{"ORD-001", []string{"order.created", "order.paid", "order.shipped", "order.completed"},
1_500_000, []string{"Laptop", "Mouse"}},
{"ORD-002", []string{"order.created", "order.paid"},
350_000, []string{"Keyboard"}},
{"ORD-003", []string{"order.created"},
85_000, []string{"T-Shirt"}},
}
for _, order := range orders {
for i, evtType := range order.events {
event := OrderEvent{
EventID: fmt.Sprintf("evt-%s-%d", order.id, i),
Type: evtType,
OrderID: order.id,
CustomerID: "CUST-42",
Amount: order.amount,
Items: order.items,
Timestamp: time.Now(),
}
if err := publisher.Publish(ctx, event); err != nil {
log.Printf("Failed to publish: %v", err)
}
time.Sleep(100 * time.Millisecond)
}
}
// Wait for processing
fmt.Println("\n=== Processing Events ===")
select {
case <-ctx.Done():
case <-time.After(5 * time.Second):
cancel()
}
processor.PrintStats()
fmt.Println("Done.")
}
Summary #
- Pub/Sub is a fully managed service on GCP — no brokers, replication, or partitions to manage.
- Topics for publishing; Subscriptions for receiving — each subscription gets its own copy of messages.
topic.Publish()returns aPublishResult— call.Get(ctx)for the delivery confirmation.PublishSettingscontrols automatic batching — adjustCountThresholdandDelayThreshold.OrderingKeyguarantees message order for the same key — enableEnableMessageOrderingon the subscription.msg.Ack()confirms successful processing;msg.Nack()triggers redelivery per the retry policy.ReceiveSettings.NumGoroutinesfor parallelism;MaxOutstandingMessagesfor backpressure.- Dead letter topics for messages that fail after
MaxDeliveryAttemptstimes.- Filters on subscriptions (
attributes.source = "order-service") for filtering without extra code.- Push subscriptions for serverless (Cloud Functions, Cloud Run) — GCP POSTs to your HTTP endpoint.