This example runs the relayer with QUEUE_BACKEND=pubsub in two modes:
- Local (default): the GCP Pub/Sub emulator with automatic topic + subscription provisioning at startup — no real GCP project required. See Getting Started.
- Real GCP: your own project's topics/subscriptions with Application Default
Credentials, via
docker-compose.gcp.yaml. See Running against real GCP.
Both modes use Redis for the repository, distributed locks, and deferred-job scheduling.
- Docker
- Docker Compose
- Rust (for signer key generation)
git clone https://github.com/OpenZeppelin/openzeppelin-relayer
cd openzeppelin-relayercargo run --example create_key -- \
--password <DEFINE_YOUR_PASSWORD> \
--output-dir examples/gcp-pubsub-queue-storage/config/keys \
--filename local-signer.jsonCreate .env:
cp examples/gcp-pubsub-queue-storage/.env.example examples/gcp-pubsub-queue-storage/.envThen set:
KEYSTORE_PASSPHRASEAPI_KEYWEBHOOK_SIGNING_KEY
Edit examples/gcp-pubsub-queue-storage/config/config.json and set
notifications[0].url to your webhook endpoint (for example from
Webhook.site).
docker compose -f examples/gcp-pubsub-queue-storage/docker-compose.yaml upServices started:
relayer—QUEUE_BACKEND=pubsub,PUBSUB_PROJECT_ID=test-project,PUBSUB_EMULATOR_HOST=pubsub-emulator:8085,DISTRIBUTED_MODE=falseredis— repository, distributed locks, and the deferred-job scheduled setspubsub-emulator—gcloud beta emulators pubsub startpubsub-init— creates all 8 topics + 8 subscriptions, then exits
pubsub-init runs to completion before the relayer starts, so all required
resources exist when the backend probes them at startup.
curl -X GET http://localhost:8080/api/v1/relayers \
-H "Content-Type: application/json" \
-H "AUTHORIZATION: Bearer YOUR_API_KEY"Submit a transaction via the API and watch the logs: you'll see the per-subscription pubsub workers pick it up (publish → pull → handler) and the status-check re-checks run until the transaction reaches a final state.
For the default prefix relayer, pubsub-init creates these 8 topic/subscription pairs:
| Topic | Subscription |
|---|---|
relayer-transaction-request |
relayer-transaction-request-sub |
relayer-transaction-submission |
relayer-transaction-submission-sub |
relayer-status-check |
relayer-status-check-sub |
relayer-status-check-evm |
relayer-status-check-evm-sub |
relayer-status-check-stellar |
relayer-status-check-stellar-sub |
relayer-notification |
relayer-notification-sub |
relayer-token-swap-request |
relayer-token-swap-request-sub |
relayer-relayer-health-check |
relayer-relayer-health-check-sub |
No dead-letter topics are required (see below).
Pub/Sub has no native delayed delivery, so deferred jobs and retry backoff are held in a Redis sorted set and published when due (the apalis store-and-run-when-due pattern). The topic therefore only ever carries already-due jobs.
- A failed bounded job is re-enqueued with an incremented
retry_attempt; once its budget (max_retries) is exhausted it stops retrying, and the transaction's terminal state in the repository is the durable record — the relayer does not publish to a dead-letter topic (matching the Redis/SQS backends). Handler panics and lease timeouts count as failed attempts too, so a consistently-failing bounded job stops atmax_retriesrather than looping. - Status-check queues are unbounded: they re-run (with increasing backoff) until the transaction reaches a final state.
A handler that runs longer than a subscription's default ack deadline is not
double-processed — the worker extends the lease to 600s (Pub/Sub's max) before
processing and bounds the handler to it. If that extension can't be secured
(after a few retries), the message is released for redelivery rather than run
under a too-short lease, so it is never executed concurrently. Ctrl-C triggers
a graceful shutdown that drains in-flight work without acking incomplete jobs.
Backlog depth is read from Cloud Monitoring, which the emulator does not
serve, so depth counts are reported as unavailable locally (not 0) — the
health endpoint's reachability still works. Against real GCP, depth populates
from subscription/num_undelivered_messages.
The same backend runs against a real GCP project using docker-compose.gcp.yaml
(the emulator + auto-provisioning are replaced by your real topics/subscriptions
- ADC). Complete the signer,
.env, and webhook setup from Getting Started (Steps 1–3), then:
The relayer never creates resources — it probes them at startup and fails fast if any are missing. Create them with the idempotent helper (or your own Terraform):
PUBSUB_PROJECT_ID=my-project ./examples/gcp-pubsub-queue-storage/scripts/provision-gcp.shIt uses an authenticated gcloud (run gcloud auth login first) and prints the
IAM bindings to apply — unlike init-pubsub.sh, which targets the emulator.
The relayer's principal needs roles/pubsub.publisher + roles/pubsub.subscriber
(or roles/pubsub.editor) and roles/monitoring.viewer (for the backlog-depth
read). Provide Application Default Credentials either way:
-
Service-account key (default): place the key JSON at
examples/gcp-pubsub-queue-storage/config/keys/gcp-sa-key.json(gitignored, mounted read-only). The relayer fails to start if it is missing. -
Your gcloud ADC: set
GCP_CREDENTIALS_FILEin.envto its path:GCP_CREDENTIALS_FILE="$HOME/.config/gcloud/application_default_credentials.json"
In production (GKE/Cloud Run), prefer Workload Identity or the GCE metadata server over a key file.
Set PUBSUB_PROJECT_ID in .env, then:
docker compose -f examples/gcp-pubsub-queue-storage/docker-compose.gcp.yaml upAgainst real GCP the startup log shows depth_read=true: the backend reads
backlog depth from Cloud Monitoring (subscription/num_undelivered_messages,
~1–3 min sampling lag), whereas under the emulator that read is unavailable.
The depth is exposed per queue as the queue_depth Prometheus gauge (labels
backend, queue_type) — metrics are off by default in this compose, so set
METRICS_ENABLED=true and publish :8081 to scrape it. /api/v1/ready reports
aggregate queue health; /api/v1/health is a plain liveness probe.
See the configuration docs for production provisioning details.