Rust · Distributed Systems · AWS

NotiQ — Distributed Notification Platform

Eight Rust microservices communicating over gRPC and a Postgres-native job queue. Built to explore what actually breaks when you operate a reliable delivery system at scale — gossip-based membership, consistent hashing, lock-free concurrency, and end-to-end backpressure across service boundaries.

Language RustServices 8 microservicesQueue Postgres-native · SKIP LOCKEDMembership SWIM gossip · consistent hashingInfra AWS ECS · TerraformObservability OpenTelemetry · CloudWatch

System architecture

NotiQ microservices architecture Eight services in layers: client at top, public entry, write path, worker tier, scheduler, admin, cross-cutting stores. Client HTTPS PUBLIC Route 53 · ALB ACM TLS gateway-svc auth · rate limit · circuit breaker · tenant routing gRPC WRITE PATH enqueue-svc outbox · Bloom filter · idempotency Postgres jobs · outbox · job_events · delivery_log · dead_letter LISTEN/NOTIFY · SKIP LOCKED WORKER TIER worker-svc [N] consistent hash · gossip · lock-free MPSC ↔ UDP gossip between instances gRPC delivery-email SES · retry · bulkhead delivery-sms Twilio · retry · bulkhead delivery-webhook HMAC · retry · bulkhead gRPC scheduler-svc min-heap · cron · delayed jobs · saga orchestration admin-svc CQRS read model · tenant ops · dead-letter CROSS-CUTTING Redis rate limit idempotency · registry S3 payloads · audit log pg_dump backups OpenTelemetry traces · metrics → CloudWatch Secrets Manager creds · API keys tokens KMS RDS + S3 at-rest enc.

Services — bounded contexts

gateway-svc
owns: auth, routing, tenant resolution
API Gateway patterncircuit breakertoken bucketmTLS
Single public entry point. JWT validation, per-tenant rate limiting via atomic Lua scripts in Redis, and downstream routing via gRPC. Circuit breaker implemented as a custom Tower::Layer — trips on error rate threshold, half-opens to probe recovery.
enqueue-svc
owns: job ingestion, outbox, idempotency
outbox patternBloom filterSKIP LOCKEDevent sourcing
Writes job + outbox row in a single transaction — no dual-write risk. Bloom filter pre-screens idempotency keys before the Redis round-trip, cutting ~80% of duplicate checks. Job state is an append-only event log, never an overwritten status column.
worker-svc
owns: dequeue, shard routing, fan-out
consistent hashingSWIM gossiplock-free MPSCbackpressure
N instances, each a gossip node. A BTreeMap vnode ring routes notifications to shards — join/leave migrates only adjacent keys (~1/N). Lock-free MPSC channels connect the gossip, dequeue, and fan-out tasks inside each instance. No mutex on the hot path.
delivery-email
owns: SES delivery, email retry state
bulkheadSES rate awarenessdecorrelated jitter
Calls SES v2 with send-rate awareness — tracks quota consumption and signals backpressure upstream before hitting the SES throttle. Separate ECS service with its own pool. A SES outage cannot starve SMS or webhook workers.
delivery-sms
owns: Twilio delivery, SMS retry state
bulkheadper-tenant rate limitdecorrelated jitter
Twilio HTTP client with per-tenant send-rate limiting via Redis token bucket. Separate failure domain — a Twilio outage or rate-limit breach is contained here. Retry uses decorrelated jitter across transient errors and Twilio 429s.
delivery-webhook
owns: HTTP delivery, HMAC signing
bulkheadHMAC-SHA256strict timeout
Outbound POST with HMAC-SHA256 payload signing so receivers verify authenticity without a shared secret in the URL. Strict per-request timeout stops a slow consumer from blocking the pool. Retries on 5xx; 4xx goes straight to dead-letter.
scheduler-svc
owns: cron, delayed jobs, saga orchestration
min-heapsagadistributed lock
BinaryHeap over next_run_at — O(1) peek, O(log N) insert. A Postgres advisory lock ensures only one replica fires a given job across a scaled deployment. Orchestrates multi-step flows with explicit compensation on failure.
admin-svc
owns: tenant ops, read model, dead-letter
CQRSRDS read replicaevent replay
Separate read path — queries delivery_log and job_events projections from the RDS read replica. Writes go through enqueue-svc; reads never touch the primary. Dead-letter replay and job-state reconstruction from the event log.

Service communication

callprotocolpatternfailure handling
gateway → enqueue-svcgRPCsync request/replycircuit breaker trips → 503 to client
gateway → admin-svcgRPCsync request/replycircuit breaker → graceful degradation
enqueue-svc → worker-svcasync queueoutbox + CDC + LISTEN/NOTIFYworker down → jobs persist in Postgres, zero loss
worker-svc → delivery-*gRPCfan-out, bulkhead per channelchannel down → dead-letter; others unaffected
scheduler-svc → enqueue-svcgRPCsaga orchestration stepenqueue fail → saga compensates in reverse
worker-svc ↔ worker-svcUDP gossipSWIM probe + epidemic broadcastpartition → suspect → dead → ring rebalance
all services → PostgresTCP / sqlx poolconnection pool, schema per serviceMulti-AZ failover, pool reconnect
all services → RedisTCP / fred-rsrate limit · idempotency · backpressureRedis down → configurable allow-all fallback

How it was built — key decisions

01
Postgres as the queue broker
SKIP LOCKED · LISTEN/NOTIFY · outbox · event sourcing
"Why Postgres, and where it breaks"

A Postgres-native queue (like Graphile Worker, River, Oban) keeps job state transactional with the rest of the app. No external broker, no dual-write race, no new failure domain. The honest tradeoffs: Postgres becomes the throughput ceiling at very high write rates, WAL amplifies under heavy churn, and cross-region queue replication is hard. Acceptable for this scale.

"SKIP LOCKED for concurrent dequeue"

Multiple workers claim different jobs from the same table with zero serialization conflicts — no advisory locks, no SELECT FOR UPDATE blocking. Rows locked by another transaction are skipped entirely. The same mechanism behind pgqueue, Graphile Worker, and River.

"LISTEN/NOTIFY for low-latency wake"

Workers sleep on a Postgres channel and wake on INSERT rather than polling. Push for latency, pull for correctness — the outbox CDC task guarantees at-least-once delivery regardless of whether the notification arrived.

"Event log, not status column"

Job state is an append-only sequence: Enqueued → Claimed → Failed → Retried → Delivered. Current state is a projection. Audit trail is free, replay is exact. Overwriting a status column destroys history and makes debugging retried failures guesswork.

02
Distributed worker fleet
consistent hashing · SWIM gossip · lock-free concurrency
"Consistent hashing for shard routing"

BTreeMap vnode ring over a 2³² token space. 150 virtual nodes per worker smooth distribution. On join/leave, only the adjacent token range migrates — ~1/N of total keys. Proven with a test: add a worker, measure the fraction of keys that moved.

"SWIM-lite for membership"

Direct ping → suspect on timeout → indirect ping via K random peers → dead on no ack. Epidemic broadcast piggybacks membership deltas on outgoing pings — O(log N) convergence without a coordinator. A dead event triggers ring rebalance automatically.

"Lock-free MPSC between tasks"

crossbeam-channel connects the gossip, dequeue, and fan-out tasks inside each instance. No mutex on the hot path. AtomicU8 for node liveness with Acquire/Release ordering — not SeqCst, which would be correct but unnecessarily expensive.

"Zero-copy payload fan-out"

Payloads are wrapped in bytes::Bytes. Fan-out to N delivery channels clones the Arc pointer, not the heap allocation. A criterion benchmark with allocation counting confirms zero extra heap allocations per channel.

03
Reliability under failure
bulkhead · circuit breaker · backpressure · chaos testing
"Three delivery services, not one"

Email, SMS, and webhook are separate ECS services with separate pipelines and failure domains. A SES outage tripping circuits has zero effect on Twilio throughput. The bulkhead makes failure boundaries explicit rather than accidental.

"End-to-end backpressure"

Each delivery service signals capacity upstream near saturation. worker-svc aggregates via an atomic queue-depth counter and propagates upstream to enqueue-svc. Unbounded queues cause silent data loss; this prevents it.

"Decorrelated jitter, not exponential"

Naive exponential backoff synchronizes retries across instances and causes thundering herd after mass failure. Decorrelated jitter (sleep = min(cap, random(base, prev × 3))) keeps retry waves spread over time.

"Chaos test suite"

A --chaos flag randomly kills workers mid-flight, delays acks, and injects partitions. A run audits the delivery_log and proves zero message loss. That's the only real proof of reliability — review alone isn't enough.

04
Cloud infrastructure
ECS · RDS · Terraform · IAM · observability
"Everything in Terraform"

VPC with public/private split, SGs as code, one ECS task definition per service, RDS multi-AZ + read replica, ElastiCache, Secrets Manager, KMS, Route 53, ACM. Nothing clicked in the console — infra is reproducible and reviewable.

"IRSA over static credentials"

Each ECS task assumes its own least-privilege IAM role via IRSA. enqueue-svc writes S3 and reads Secrets Manager; delivery-email calls SES. No shared credentials, no static keys in env vars or images.

"Custom scaling metric"

worker-svc auto-scales on a custom CloudWatch metric — Postgres queue depth — not CPU. CPU is the wrong signal for a queue worker: low when idle, spiking when overwhelmed. Queue depth is what actually needs to scale against.

"OpenTelemetry throughout"

Every service emits traces via tracing-opentelemetry. One trace ID spans gateway → enqueue → worker → delivery across gRPC boundaries via propagated context. OTLP export to CloudWatch in prod, Jaeger locally.

05
Service communication choices
gRPC · protobuf · per-service schema · UUID v7
"gRPC over REST for inter-service"

gRPC gives strongly-typed protobuf contracts, generated client stubs, binary encoding (~3-10× smaller than JSON), and streaming. Breaking changes are caught at compile time, not at runtime in production.

"Per-service Postgres schema"

Each service owns its tables under a named schema. No service queries another's tables directly — cross-service reads go through gRPC. This enforces bounded-context ownership at the database level.

"UUID v7 for sortable IDs"

UUID v4 is random — poor index locality, page splits under write load. UUID v7 is time-ordered: new rows land at the end of the index, giving sequential writes and far better insert throughput on jobs and job_events.

"Tokio over async-std"

axum, tonic, sqlx, fred, and crossbeam all integrate directly with Tokio. Ecosystem coherence matters more than runtime micro-benchmarks at this scale.

Engineering depth

micro
Outbox pattern
enqueue-svc writes job + outbox row in one transaction. A CDC task reads the outbox, publishes to worker-svc, then deletes the row — reliable publishing without a distributed transaction or dual-write race.
micro
Saga — choreography vs orchestration
Delivery fan-out uses choreography; multi-step scheduler flows use an explicit orchestration state machine that compensates in reverse on failure. Both patterns implemented, both understood.
micro
Circuit breaker as Tower::Layer
gateway-svc wraps downstream gRPC in a from-scratch breaker: Closed → Open on error rate, Open → Half-open after timeout, Half-open → Closed on probe success. Composable with other Tower middleware.
micro
CQRS — separate read and write models
Writes flow through enqueue-svc to the primary; reads through admin-svc against the read replica. The write model is normalized for correctness; the read model is denormalized for delivery analytics.
dist
SWIM failure detection
Direct ping → indirect ping via K peers → suspect → dead. Epidemic dissemination piggybacks on pings; convergence is O(log N). False-positive rate is tunable against detection latency — a real tradeoff.
dist
Consistent hashing with virtual nodes
BTreeMap over a 2³² token space, 150 vnodes per worker. A node leaving migrates only ~1/N of keys. Replication factor 2: each notification has a primary shard and one replica.
dist
At-least-once with idempotency
Jobs retry on failure; a Bloom filter pre-screens idempotency keys before a Redis SET NX confirms dedup. ~80% of duplicate checks never reach Redis. Delivery guaranteed, double-firing prevented.
dist
End-to-end backpressure
Delivery services signal saturation upstream via a flag in gRPC responses; worker-svc tracks it with an atomic counter and signals enqueue-svc to reject new jobs past threshold. Unbounded queues are the classic silent failure.
sysdes
Push + pull hybrid delivery
LISTEN/NOTIFY wakes workers with microsecond latency when a job is inserted. SKIP LOCKED handles the actual claim. Push gives low latency; pull gives correctness under failure.
sysdes
Atomic rate limiting in Redis
Token bucket and sliding window rate limiters run as Lua scripts executed atomically in Redis. Two separate INCR + EXPIRE commands would race — any limit that can be violated under concurrent load is not a limit.
sysdes
Multi-tenant isolation at row level
Every table carries a tenant_id column. Per-tenant rate limits, queue depth budgets, and backpressure thresholds are configurable — one noisy tenant cannot exhaust another's delivery capacity.
sysdes
Graceful degradation by design
Redis unavailability falls back to allow-all rate limiting; an SES outage trips the circuit and jobs accumulate in Postgres. No single component failure causes data loss.
rust
Custom Tower middleware
The circuit breaker is a Tower::Layer + Tower::Service pair with each state transition modeled explicitly — composable with auth and tracing layers without coupling.
rust
Atomic memory ordering
AtomicU8 encodes liveness (alive/suspect/dead). The gossip task writes with Release; routing reads with Acquire. SeqCst would also be correct but imposes a global fence — unnecessary for a single writer/reader pair.
rust
Async stream composition with pin-project
The CDC outbox reader is a self-referential async stream via pin-project. tokio::select! multiplexes the gossip and dequeue loops inside each task — explicit waker propagation, no missed wakeups.
rust
Graceful shutdown with CancellationToken
A tokio-util CancellationToken propagates through the task tree. On SIGTERM, in-flight jobs drain before exit — proven by running the chaos suite across a rolling ECS deploy.
perf
Lock-free MPSC on the hot path
crossbeam-channel replaces a Mutex<VecDeque> between gossip, dequeue, and fan-out tasks. Benchmarked under 10k rps: measurably lower p99 and no lock-contention spikes.
perf
Zero-copy fan-out
Payloads held as bytes::Bytes; fanning out to 3 channels clones the Arc three times — no per-channel allocation. A criterion benchmark with allocation counting confirms a flat count.
perf
Bloom filter to cut Redis round-trips
A per-process Bloom filter pre-screens idempotency keys (zero false negatives). In duplicate-heavy workloads ~80% of Redis SET NX calls are skipped.
perf
Atomic queue depth without contention
Depth is an Arc<AtomicUsize> incremented on enqueue, decremented on completion. The backpressure check is a single atomic load — never blocks on the hot path under burst load.
dsa
BTreeMap as a hash ring
BTreeMap<u32, WorkerId> maps token positions to workers; clockwise successor lookup is a range() call — O(log N). Ordered iteration makes successor lookup trivial without a custom structure.
dsa
Min-heap for deadline scheduling
BinaryHeap with Reverse<Instant> orders jobs by next_run_at. O(1) peek, O(log N) insert. The scheduler sleeps until the top deadline rather than polling.
dsa
Bloom filter for probabilistic dedup
Constant-time membership with zero false negatives; the false-positive rate trades against memory. Correct use requires understanding that asymmetry.
dsa
Michael-Scott lock-free queue
crossbeam-channel's internal queue is a CAS-based MPSC structure. Understanding the ABA problem and the CAS loop is the depth behind using the library.

AWS infrastructure

"ECS Fargate"
One task definition per service. Independent deploy pipelines. Auto-scales on queue depth, not CPU.
"ALB · Route 53"
ALB in public subnet, all services private. Per-tenant subdomain routing via listener rules. ACM TLS.
"RDS Postgres · Multi-AZ"
Multi-AZ primary for HA failover. Read replica for admin-svc's CQRS read model. RTO tested and measured.
"ElastiCache Redis"
Cluster mode. Rate limiting, idempotency keys, worker registry, channel backpressure signals.
"S3 · SSE-KMS"
Large payload spill, audit log export, pg_dump backup. Server-side encryption via KMS.
"IAM · IRSA"
Each ECS task assumes its own least-privilege role. No static keys anywhere.
"Secrets Manager"
DB credentials, API keys, and Twilio tokens fetched at startup per service. Rotation without redeploy.
"KMS"
RDS encryption at rest, S3 SSE-KMS, ACM Private CA for inter-service mTLS certificates.
"CloudWatch · X-Ray"
OTel OTLP export per service. Custom queue-depth metric drives auto-scaling. Distributed traces via X-Ray.
"VPC Endpoints"
Interface endpoints for S3, Secrets Manager, and ECR — traffic stays in the VPC, off the NAT path.
"WAF · Shield"
WAF on the ALB with rate-based rules before gateway-svc limiting. Shield Standard for volumetric DDoS.
"ECR · GitHub Actions"
One ECR repo per service. Image scanning on push. CI builds, tests, pushes on merge; rolling deploy with rollback.

Data model

tenants id uuid PK name text api_key_hash text rate_limit_rps int schema text created_at timestamptz enqueue schema jobs id uuid PK tenant_id uuid FK status text priority smallint payload jsonb payload_s3_key text? idempotency_key text attempts int run_at timestamptz created_at timestamptz enqueue schema job_events id bigserial PK job_id uuid FK event text worker_id text detail jsonb created_at timestamptz enqueue schema outbox id uuid PK job_id uuid FK payload text processed bool created_at timestamptz enqueue schema notifications id uuid PK tenant_id uuid FK recipient_id text seq bigint channels text[] template text vars jsonb created_at timestamptz worker schema delivery_log id bigserial PK notification_id uuid FK channel text status text attempt int latency_ms int error text? created_at timestamptz admin schema dead_letter id uuid PK original_job_id uuid tenant_id uuid FK last_error text payload jsonb failed_at timestamptz admin schema 1:N 1:N 1:N enqueue schema worker / event schema admin schema shared / ops PK · FK · --- relation

Tech stack

axum
HTTP API + Tower middleware chain
tonic + prost
gRPC server, client, codegen from .proto
tokio
async runtime throughout all services
tower
circuit breaker + middleware composition
tower-http
request tracing · compression · timeouts
sqlx
Postgres · compile-time query validation
fred
Redis · rate limiter · worker registry
crossbeam-channel
lock-free MPSC on the hot path
bytes
zero-copy payload fan-out
bloom
probabilistic idempotency pre-screen
aws-sdk-rust
SES v2 · S3 · Secrets Manager
tracing-opentelemetry
OTel span instrumentation per service
opentelemetry-otlp
OTLP export → CloudWatch · Jaeger
metrics + metrics-exporter-prometheus
custom metrics emission · queue depth gauge
tokio-console
async task profiling + starvation detection
criterion
benchmarking · allocation counting
cargo-heaptrack
heap allocation profiling
pin-project
self-referential async stream structs
tokio-util
CancellationToken · graceful shutdown
uuid (v7)
time-ordered IDs · sequential index writes
thiserror
typed errors across service boundaries
jsonwebtoken
JWT validation in gateway-svc
hmac + sha2
webhook payload signing · vnode hashing
argon2
tenant API key hashing
reqwest
outbound webhook HTTP delivery
testcontainers
Postgres + Redis in integration tests
refinery
versioned SQL migrations

Decisions worth discussing

storage
SKIP LOCKED gives lock-free row exclusion without advisory locks or serializable transactions. Multiple workers claim different jobs concurrently with no conflicts. Compared to SELECT FOR UPDATE, it never blocks — it simply skips locked rows and moves on.
storage
LISTEN/NOTIFY is not reliable for at-least-once delivery — a worker that crashes between notification and claim loses the message. The outbox CDC task handles delivery guarantees independently. LISTEN/NOTIFY is used only for low-latency wake, not as the delivery mechanism.
storage
Append-only event log instead of a mutable status column. Replaying job_events reconstructs any job's history at any point in time. This makes debugging failures trivial and makes the dead-letter replay in admin-svc exact rather than approximate.
microservices
Outbox pattern over dual-write: writing to the queue atomically with the job record means there's no window where the job exists but hasn't been dispatched — or was dispatched but the write failed. The CDC task provides the eventual publishing guarantee.
microservices
Circuit breaker as a Tower::Layer rather than a global flag: it's scoped to the downstream service it protects, composable with other middleware, and testable in isolation. A global flag would require coordination across all goroutines and can't be per-route.
microservices
CQRS because the write path and read path have genuinely different access patterns. The write path needs low-latency transactional inserts. The admin read path runs multi-join analytics queries over millions of delivery_log rows. Forcing both through the same model means optimizing for neither.
distributed
SWIM over a central heartbeat: a heartbeat server is a single point of failure and a bottleneck at scale. SWIM scales O(log N) and has no coordinator. The tradeoff is that false positive detection latency is tunable but never zero — this is understood and accounted for in the chaos tests.
distributed
Consistent hashing with 150 virtual nodes: without virtual nodes, removing a single physical worker reassigns all its keys to one successor, causing a hotspot. Virtual nodes spread the impact across the ring. 150 per worker is the Cassandra default — a well-studied heuristic, not arbitrary.
distributed
Decorrelated jitter over exponential backoff: after a mass failure, naive exponential backoff synchronizes retries across instances at the same intervals. Decorrelated jitter breaks that synchronization. The thundering herd problem it prevents is not theoretical — it has taken down production systems.
infrastructure
Queue depth as the ECS scaling metric instead of CPU: a queue worker's CPU is low when it's idle and spikes when overwhelmed. By the time CPU triggers scale-out, the queue has already grown. Queue depth is a leading indicator; CPU is a lagging one.
infrastructure
IRSA over a shared IAM role: each service needs exactly the permissions its function requires. enqueue-svc needs S3 write and Secrets Manager read. delivery-email needs SES send. A shared role is a security smell — credential scope should be minimal and auditable per service.
Rust
Acquire/Release on AtomicU8 node state rather than SeqCst: SeqCst imposes a total global order on all atomic operations across all threads — unnecessarily expensive here. The gossip task always writes; the routing task always reads. Acquire/Release is sufficient and correct for this producer/consumer pair.
Rust
bytes::Bytes for zero-copy fan-out: cloning an Arc pointer is two atomic increments. Cloning a heap Vec is a malloc and a memcpy. At 3 delivery channels per notification, zero-copy fan-out eliminates 3 allocations per notification on the hot path. A criterion benchmark with allocation counting confirms this — the count is flat regardless of channel count.