Google Chat: Pub/Sub Setup¶
The Pub/Sub transport is ideal for bots living in private networks (e.g., private GKE clusters) where Google Chat cannot reach the bot via HTTP.
Architecture¶
Google Chat publishes events to a Pub/Sub Topic, and the bot pulls from a Subscription. The bot is outbound-only — no Ingress or public Load Balancer is required.
1. GCP Infrastructure (Terraform)¶
Use the provided module at deploy/terraform/google-chat-bot to provision the required resources:
module "orrery_chat_bot" {
source = "../../deploy/terraform/google-chat-bot"
project_id = "your-project-id"
k8s_namespace = "orrery"
k8s_service_account = "orrery-chat-bot"
}
This creates: - The Pub/Sub Topic and Subscription (with retry policy and optional DLQ). - A Google Service Account (GSA) with subscriber permissions. - Workload Identity bindings for GKE. - Optional Vertex AI User binding for the Gemini provider.
Module variables¶
| Variable | Default | Purpose |
|---|---|---|
project_id | — | GCP project hosting the Pub/Sub resources and GSA. |
name | orrery-chat | Prefix for the topic / subscription / GSA names. |
k8s_namespace | — | K8s namespace running the worker. |
k8s_service_account | orrery-chat-bot | KSA bound to the GSA via Workload Identity. |
chat_publisher_email | chat-api-push@system.gserviceaccount.com | SA that Google Chat uses to publish events. Override for Workspace Add-ons or when constraints/iam.allowedPolicyMemberDomains blocks the default. |
enable_vertex_ai | true | Grant roles/aiplatform.user to the GSA. Set to false if you use OpenAI/Anthropic/Ollama. |
vertex_ai_project_id | var.project_id | GCP project where Vertex AI is called (supports cross-project). |
enable_dead_letter | true | Create a DLQ topic + diagnostics subscription. |
max_delivery_attempts | 5 | Redelivery attempts before routing to the DLQ. |
ack_deadline_seconds | 60 | Initial ack deadline (auto-extended while a callback runs). |
message_retention_duration | 3600s | How long unacked messages are kept. |
dlq_subscribers | [] | IAM members (e.g. group:sre-oncall@company.com) granted roles/pubsub.subscriber on the DLQ subscription for triage. |
labels | {component, managed-by} | Labels applied to all resources. |
Workspace Add-ons / Domain Restricted Sharing If your Chat app is a Workspace Add-on, or your org enforces
constraints/iam.allowedPolicyMemberDomains, the defaultchat-api-push@system.gserviceaccount.comwill fail to bind. Copy the exact SA from the Chat API console → Configuration → Connection settings → "Service Account Email" and pass it aschat_publisher_email(typicallyservice-<PROJECT_NUMBER>@gcp-sa-gsuiteaddons.iam.gserviceaccount.com).
2. Configure the Chat API¶
After applying Terraform, you must complete two manual steps in the Google Chat API -> Configuration tab:
- App Authentication: Select "Service Account" and paste the
gsa_emailfrom Terraform output. - Connection Settings: Select Cloud Pub/Sub and paste the
topic_idfrom Terraform output.
3. Deployment Configuration¶
Add the following to your .env (or Helm values):
# The subscription to pull from.
GOOGLE_CHAT_PUBSUB_SUBSCRIPTION=orrery-chat-events-sub
# The project hosting the subscription (if different from vertex/agent project).
GOOGLE_CHAT_PUBSUB_PROJECT=your-infra-project-id
# Performance tuning.
GOOGLE_CHAT_PUBSUB_MAX_MESSAGES=4
GOOGLE_CHAT_PUBSUB_HANDLER_TIMEOUT_SECONDS=600
# Health server (liveness /healthz, readiness /readyz).
# Readiness flips to 503 if the Pub/Sub streaming pull dies — kubelet
# will restart the pod. Default 8080 matches the Helm probe port.
GOOGLE_CHAT_PUBSUB_HEALTH_PORT=8080
# Idempotency guard (see "Idempotency" below).
# memory — process-local; single replica only.
# postgres — shared via DATABASE_URL; required for >1 replica.
GOOGLE_CHAT_PUBSUB_IDEMPOTENCY_BACKEND=memory
GOOGLE_CHAT_PUBSUB_IDEMPOTENCY_TTL_SECONDS=3600
Idempotency (why a redelivery won't double-act)¶
Pub/Sub delivers at least once. The same event can be redelivered when an ack is lost to a network blip, a pod OOMs mid-callback, or a handler timeout nacks after side effects already landed. Because the Chat handler can invoke @destructive tools (restart / scale / rollback a deployment, increase Kafka partitions, silence Alertmanager…), an unguarded redelivery would run those tools twice.
The worker claims each event id before dispatching and drops duplicates (ack without re-executing). If the handler then fails, the claim is released so a legitimate redelivery still retries — you get at-most-once execution of side effects without losing failed work.
| Backend | Use when | Notes |
|---|---|---|
memory | single replica (dev, or a pinned 1-replica prod) | Process-local; cannot dedup across pods. |
postgres | any multi-replica / autoscaled worker | Shares the claim across replicas via the platform's DATABASE_URL (INSERT … ON CONFLICT). No extra infra. |
Set GOOGLE_CHAT_PUBSUB_IDEMPOTENCY_TTL_SECONDS to match the subscription's message_retention_duration (default 3600s) so a redelivery anywhere in the retention window is still short-circuited. The Helm chart refuses to render a worker with replicaCount > 1 (or autoscaling on) while the backend is memory.
Timeout alignment
GOOGLE_CHAT_PUBSUB_HANDLER_TIMEOUT_SECONDSbounds a single turn; the subscriber's ack deadline is auto-extended while the callback runs, up to the subscription'smessage_retention_duration. Keep the handler timeout less than or equal tomessage_retention_duration(default3600s) so a stuck turn is reclaimed before the message expires.
4. Run the Worker¶
The Pub/Sub worker runs as a separate process:
# Local (requires GOOGLE_APPLICATION_CREDENTIALS pointing to a SA key)
make run-chat MODE=pubsub
# In-cluster (enabled via Helm)
# pubsubWorker.enabled: true
5. Scaling the worker (multi-replica)¶
A single worker is a SPOF during incidents — Chat traffic spikes exactly when things are on fire, and one pod serializes those turns behind max_messages. To run more than one replica:
- Switch both process-local stores to the shared backend so redeliveries are deduped — and pending approvals are visible — across pods: The confirmation store matters as much as the dedup guard: with the in-memory backend, the pending raised by pod A is invisible to pod B, so the operator's
pubsubWorker: idempotency: backend: postgres # reuses the platform DATABASE_URL confirmation: backend: postgres # same DATABASE_URL — approval handshake sharedapprovereply (or the LLM retry that consumes the approval) lands on a pod that knows nothing about it and the flow silently dies. The Postgres store also survives worker restarts — an in-memory pending dies with the pod. The chart refuses to render a multi-replica worker while either backend ismemory. Traffic is human-scale (a handful of ~1 ms row operations per confirmation), so there is no measurable latency cost. - Enable backlog-based autoscaling. CPU/memory are poor signals here (the worker is I/O-bound on LLM calls); scale on the subscription's undelivered message count instead:
Prerequisite: the backlog HPA reads an External metric (pubsub.googleapis.com|subscription|num_undelivered_messages), which requires the GKE Stackdriver custom-metrics adapter. Install it before enabling pubsubWorker.autoscaling.
Scaling lag. The adapter polls ~every 60s, so backlog scaling is not instantaneous. During known incident windows keep
minReplicas >= 2(or bump it on a schedule) rather than relying purely on reactive autoscaling.
Troubleshooting Pub/Sub¶
- Silence in logs: Verify the subscription lives in the project specified by
GOOGLE_CHAT_PUBSUB_PROJECT. - Permissions: Ensure the GSA has
roles/pubsub.subscriberon the subscription and is registered as the "App identity" in the Chat console. - Heartbeat: With version 0.1.5+, the worker logs a
heartbeatevery 60s. If you don't see this, the process is likely stuck during initialization. - Duplicate actions: If a destructive action ran twice, the idempotency guard is likely on
memoryacross multiple replicas (only possible if you bypassed the Helm guard). SwitchGOOGLE_CHAT_PUBSUB_IDEMPOTENCY_BACKEND=postgres. A log lineDuplicate event_id=… acking without re-executingconfirms the guard is working.