Ideal Architecture — Event-Driven Now-Playing Pipeline
Status: Proposal (not implemented)
Date: 2026-04-06
Context: Documents the optimal architecture for the ICY collection pipeline if rebuilt from scratch, without cost constraints. Serves as a north-star reference for future refactoring decisions.⚠️ Historical note: CosmosDB was decommissioned in April 2026. The database now runs on MongoDB Atlas. Cost comparisons and Cosmos DB references below reflect the pre-migration state.
Problem Statement
The current architecture couples ICY probing, DB writes, enrichment, and API reads in two processes (API + Worker) sharing a single Cosmos DB RU budget. This causes:
| Symptom | Root cause |
|---|---|
| Cosmos 429 (RequestRateTooLarge) | Burst writes from collector saturate shared RU pool |
API 500s on /favorites, /icy-history, /track-stats |
Read queries starved by write contention |
| Stale now-playing data | Collection cycle blocked by slow DB writes |
| Fragile concurrency control | Hand-coded semaphores with leak paths |
| Enrichment coupling | Spotify/MusicBrainz rate-limits slow the entire cycle |
The fundamental issue is structural: backpressure must be architectural (queue-based), not hand-coded (semaphores).
Target Architecture
High-Level Data Flow
(radios 1–1000)"] P2["Replica 2
(radios 1001–2000)"] end subgraph BUS["Azure Service Bus"] TOPIC["now-playing-probed
(topic)"] SUB_PERSIST["persist
(subscription)"] SUB_ENRICH["enrich
(subscription)"] SUB_REALTIME["realtime
(subscription)"] end subgraph WRITERS["Write Workers"] W1["Writer 1"] end subgraph ENRICHERS["Enrichment Workers"] E1["Enricher 1"] end subgraph PUSH["Real-Time Push"] SIGNALR["Azure SignalR"] end subgraph API["API (read-only)"] API_SVC["NestJS API"] end subgraph DATA["Data Layer"] DB_WRITE["Cosmos DB
(write)"] DB_READ["Read Replica"] REDIS["Redis Cache"] end P1 --> TOPIC P2 --> TOPIC TOPIC --> SUB_PERSIST --> W1 TOPIC --> SUB_ENRICH --> E1 TOPIC --> SUB_REALTIME --> SIGNALR W1 --> DB_WRITE DB_WRITE -.-> DB_READ E1 --> DB_WRITE W1 --> REDIS API_SVC --> DB_READ API_SVC --> REDIS SIGNALR --> CLIENTS["📱 Clients"] API_SVC --> CLIENTS
Design Principles
- Separation of concerns — Each component has a single responsibility: probe, persist, enrich, push, or serve.
- Structural backpressure — Queue depth regulates throughput. No hand-coded semaphores.
- Fault isolation — A component failure doesn't cascade. Messages stay in the queue until the consumer recovers.
- Independent scaling — Each component scales by its own bottleneck (radio count, queue depth, client connections, request rate).
- At-least-once delivery — Every write is a queue consumer with automatic retry. No fire-and-forget.
Component Specifications
A. ICY Probe Service
| Property | Value |
|---|---|
| Responsibility | Connect to radio streams, extract ICY metadata, emit events |
| Runtime | Node.js (or Rust for lower memory per connection) |
| Hosting | Azure Container Apps, KEDA-scaled by radio count |
| State | Stateless — radio shard assignments from Redis partition map |
| DB access | None |
| Output | NowPlayingProbed events → Service Bus topic |
Event schema:
interface NowPlayingProbed {
radioId: string;
streamUrl: string;
rawIcyTitle: string; // original ICY metadata string
parsedArtist?: string;
parsedTrack?: string;
station?: string;
genre?: string;
bitrate?: number;
probedAt: Date; // UTC timestamp of probe
probeLatencyMs: number; // network round-trip time
}
Behavior:
- Probes all assigned radios every ~60 seconds
- Concurrency: 50 per replica (configurable)
- Timeout: 8 seconds per stream
- Emits one event per radio per cycle (including "no change" marker for monitoring)
- Does NOT perform change detection — that's the writer's job
B. Azure Service Bus (Event Bus)
| Property | Value |
|---|---|
| Tier | Premium (for message sessions, higher throughput) |
| Topic | now-playing-probed |
| Subscriptions | persist, enrich, realtime, analytics (optional) |
| Message sessions | Enabled on persist subscription, keyed by radioId (ensures per-radio ordering) |
| Dead-letter | Enabled on all subscriptions (max delivery count: 5) |
| TTL | 5 minutes (stale probes are worthless) |
Why Service Bus over BullMQ/Redis:
- Dead-letter queues with inspection (no lost messages)
- Message sessions (ordered processing per radio)
- Built-in exponential retry
- Competing consumers (multiple workers on one subscription)
- No single-point-of-failure Redis
- KEDA scaler integration
C. Write Workers
| Property | Value |
|---|---|
| Responsibility | Consume persist subscription, write to Cosmos DB |
| Runtime | Node.js (NestJS) |
| Hosting | Azure Container Apps, KEDA-scaled by queue depth |
| Scaling | Min: 1, Max: 5 |
| Concurrency | Sequential per session (per radio), parallel across radios |
Behavior:
- Receive
NowPlayingProbedmessage - Compare with cached previous track in Redis (
now-playing:{radioId}) - If unchanged → ack message, update
now_playing_updated_attimestamp only - If changed:
Radio.updateOne()— setnow_playing,now_playing_updated_atIcyHistory.create()— append to history- Update Redis cache with new track
- Ack message after successful write
Backpressure: Service Bus prefetch count = write concurrency. If Cosmos is slow (429), worker naturally slows → messages buffer in the queue. No burst, no data loss.
D. Enrichment Workers
| Property | Value |
|---|---|
| Responsibility | Consume enrich subscription, resolve MusicLink + Spotify metadata |
| Runtime | Node.js (NestJS) |
| Hosting | Azure Container Apps, KEDA-scaled by queue depth |
| Scaling | Min: 0, Max: 3 (scale to zero when no work) |
Behavior:
- Receive
NowPlayingProbedmessage (only on track change — use SQL filter on subscription) - Look up MusicLink by
(artist, track):- If cached in DB → skip
- If not → query Spotify API → upsert MusicLink
- Increment
TrackRadioStatsfor the radio+track pair - Rate-limit Spotify calls: token bucket at 2 req/s per worker
Completely decoupled: If Spotify is down, messages retry in Service Bus with exponential backoff. Zero impact on now-playing freshness.
E. Real-Time Push Service
| Property | Value |
|---|---|
| Responsibility | Consume realtime subscription, push track changes to connected clients |
| Technology | Azure SignalR Service (serverless mode) |
| DB access | None |
| Latency target | < 2 seconds from probe to client notification |
Behavior:
- Receive
NowPlayingProbedmessage (track change only — SQL filter) - Broadcast to SignalR group
radio:{radioId} - Connected clients receive update immediately
Replaces: Current polling model where the app hits /api/radios/:id/now-playing every 30–60 seconds.
F. API (Read Gateway)
| Property | Value |
|---|---|
| Responsibility | Serve REST endpoints (radios, history, favorites, search, auth) |
| Runtime | NestJS |
| Hosting | Azure App Service |
| DB access | Read-only queries against Cosmos read replica |
| Caching | Redis for hot paths (radio detail, now-playing, favorites) |
| Collector logic | None — the API never touches streams or writes now-playing data |
Key difference from current: The API has zero write contention with the collector. Reads go to a read replica or Redis cache.
Database Architecture
Option 1: Cosmos DB with Dedicated Throughput per Container
| Container | Throughput | Access pattern |
|---|---|---|
radios |
2,000 RU/s | Read-heavy (API), serial writes (1 writer per radio) |
icy-history |
1,000 RU/s | Append-only, write-heavy, time-range reads |
music-links |
400 RU/s | Low-volume upserts, lookup by (artist, track) |
track-radio-stats |
400 RU/s | Low-volume increments |
users / favorites |
1,000 RU/s | Read-heavy (API only) |
Advantage: Each container has its own RU budget. Writes to icy-history cannot starve reads on radios.
Option 2: PostgreSQL (Recommended)
| Property | Value |
|---|---|
| Service | Azure Database for PostgreSQL Flexible Server |
| Connection pooling | PgBouncer (built-in) |
| Partitioning | icy_history partitioned by radio_id (range) |
| Read replicas | 1 read replica for API queries |
Advantages over Cosmos DB:
- No RU budgeting — throughput scales with compute tier
- Connection pooling eliminates connection storms
- Read replicas for zero-contention reads
- Familiar SQL, better tooling, simpler operations
- Significantly lower cost at this scale
- No 429 errors — ever
Scaling Model
| Component | Scaling trigger | Min | Max | Scale-to-zero |
|---|---|---|---|---|
| ICY Probe | Radio count / shard size | 2 | 20 | No |
| Write Workers | Service Bus queue depth | 1 | 5 | No |
| Enrichment Workers | Service Bus queue depth | 0 | 3 | Yes |
| Real-Time Push | Connected clients (SignalR units) | 1 | 10 | No |
| API | HTTP request rate / CPU | 2 | 10 | No |
How This Solves Current Problems
| Current problem | Root cause | How the ideal architecture eliminates it |
|---|---|---|
| Cosmos 429 RU starvation | Writers and readers share one RU pool | Separate throughput per container (or PostgreSQL with no RU concept). Writers consume from a queue with natural backpressure. |
| API 500s during collection | Read queries starved by write burst | API reads from read replica or cache. Zero contention with writers. |
| Stale now-playing | Collection blocked by slow DB writes | Probing is decoupled from writing. Probe latency = network only (~50ms). |
| Semaphore leak paths | Fire-and-forget writes, unthrottled loops | No fire-and-forget. Every write is a queue consumer with at-least-once delivery. |
| Semaphore complexity | Hand-coded concurrency control | No semaphores. Backpressure is structural (queue depth), not hand-coded. |
| Single-pod bottleneck | One Worker process handles all radios | Each component scales independently. 5,000 radios? Add probe replicas. |
| Enrichment coupling | Spotify rate-limits slow now-playing | Enrichment is a separate subscription. Spotify downtime has zero impact on freshness. |
| Monitoring blind spots | BullMQ has limited observability | Service Bus metrics (queue depth, dead-letters, latency) + per-component App Insights. |
Comparison: Current vs Ideal
| Dimension | Current architecture | Ideal architecture |
|---|---|---|
| Pattern | Monolith (probe + write + serve in one codebase) | Event-driven microservices (CQRS) |
| DB contention | Shared RU pool, semaphore-gated | Separate read/write throughput, queue backpressure |
| Backpressure | Hand-coded semaphore (5 slots) | Structural (Service Bus queue depth) |
| Real-time latency | ~60s (cron cycle + polling) | ~2s (event-driven push via SignalR) |
| Fault isolation | One failure cascades (429 → stale data → API 500s) | Components fail independently |
| Scaling | Single Worker pod, fixed concurrency | KEDA auto-scale per component |
| Enrichment | Inline with collection cycle | Decoupled subscription, independent retry |
| Operational complexity | Medium (growing semaphore/retry logic) | Higher initial setup, simpler runtime behavior |
| Estimated monthly cost | ~€300 | ~€800–1,200 |
Migration Path (Incremental)
If pursuing this architecture, a phased migration avoids a risky rewrite:
Phase 1 — Extract probe from write (lowest risk)
- Move ICY stream probing into a standalone Container App
- Probe emits events to Service Bus instead of writing to DB directly
- Writer still runs in the current Worker process, consuming from Service Bus
- Benefit: Probing is decoupled. Probe latency drops. DB writes become queue-driven.
Phase 2 — Separate enrichment
- Move MusicLink/Spotify enrichment to a separate Service Bus subscription + worker
- Benefit: Enrichment failures/slowness no longer affect collection freshness.
Phase 3 — Add real-time push
- Add SignalR integration consuming a
realtimesubscription - Update Flutter app to receive push updates instead of polling
- Benefit: Now-playing latency drops from ~60s to ~2s.
Phase 4 — Split read/write DB paths
- Add Cosmos read replica (or migrate to PostgreSQL)
- Route API reads to replica/separate compute
- Benefit: Zero read/write contention. No more API 500s during collection.
Phase 5 — Remove legacy code
- Delete semaphore logic, BullMQ ICY queue, inline write paths
- Benefit: Simpler codebase, easier to reason about.
Technology Choices
| Layer | Recommended | Why |
|---|---|---|
| ICY Probe runtime | Node.js (current team expertise) or Rust (lower memory/connection overhead) | Node: fast iteration, team knows it. Rust: 10× lower memory per connection, no GC pauses. |
| Event bus | Azure Service Bus Premium | Sessions (per-radio ordering), dead-letter queues, built-in retry, KEDA integration, no Redis SPOF |
| Database | Azure Database for PostgreSQL Flex | No RU budgeting, PgBouncer pooling, read replicas, partitioning, familiar SQL |
| Cache | Azure Cache for Redis | Existing pattern, now-playing cache, skip-if-unchanged hash |
| Real-time push | Azure SignalR Service (serverless) | Managed WebSocket/SSE fanout, scales to millions of connections, no custom infra |
| Compute | Azure Container Apps + KEDA | Already in use, KEDA triggers for Service Bus, scale-to-zero support |
| CI/CD | Azure DevOps Pipelines | Already in place, variable groups, multi-stage releases |
| Monitoring | Azure Application Insights + Service Bus metrics | Per-component telemetry, queue depth alerts, dead-letter monitoring |
Appendix: Event Flow Timeline
A typical 60-second cycle with ~2000 radios:
| Time | Component | Action |
|---|---|---|
| T+0s | ICY Probe (2 replicas) | Start probing 1000 radios each, 50 concurrent |
| T+15s | ICY Probe | ~1800 probes complete, 2000 events emitted to Service Bus |
| T+15s | Write Worker | Starts consuming persist messages sequentially per radio |
| T+15s | Real-Time Push | Starts consuming realtime messages (track changes only) |
| T+16s | SignalR → Clients | First track change pushed to connected app users |
| T+25s | Write Worker | All writes complete (~5ms each), ack all messages |
| T+25s | Enrichment Worker | Starts consuming enrich messages (300 track changes) |
| T+180s | Enrichment Worker | All enrichments complete (Spotify at 2 req/s) |
| T+60s | ICY Probe | Next cycle begins |
Key timing: Client sees track change at T+16s (vs T+60–120s in current architecture).
Cost Analysis by Environment
All prices are Azure West Europe retail pricing (April 2026). Costs include compute, messaging, database, cache, monitoring, and networking. Prices exclude tax and reserved-instance discounts.
Pricing Assumptions
| Resource | SKU / Tier | Unit price |
|---|---|---|
| Container Apps (Consumption) | vCPU + memory | €0.000024/vCPU-s + €0.000003/GiB-s |
| Service Bus Premium | 1 Messaging Unit | ~€560/month base |
| Service Bus Standard | Per-operation | ~€0.05/1M operations |
| Cosmos DB (per-container throughput) | Autoscale | ~€0.00595/100 RU/s/hour |
| PostgreSQL Flex (Burstable B2s) | 2 vCPU / 4 GiB | ~€50/month |
| PostgreSQL Flex (GP D2ds_v5) | 2 vCPU / 8 GiB | ~€135/month |
| PostgreSQL Flex (GP D4ds_v5) | 4 vCPU / 16 GiB | ~€270/month |
| PostgreSQL read replica | Same as primary | Same tier |
| Redis (Standard C0) | 250 MB | ~€14/month |
| Redis (Standard C1) | 1 GB | ~€55/month |
| SignalR (Free) | 20 concurrent / 20K msg/day | Free |
| SignalR (Standard, 1 unit) | 1K concurrent / unlimited msg | ~€42/month |
| App Insights | Per-GB ingestion | ~€2.30/GB (first 5 GB free) |
| App Service (B1) | 1 vCPU / 1.75 GiB | ~€12/month |
| App Service (S1) | 1 vCPU / 1.75 GiB | ~€60/month |
| App Service (P1v3) | 2 vCPU / 8 GiB | ~€115/month |
Dev Environment
Minimal footprint. Service Bus Standard (not Premium). Database scaled down. SignalR free tier. Probe runs every 5 min. Single replica of everything.
| Component | SKU / Config | Monthly cost |
|---|---|---|
| ICY Probe (Container App) | 1 replica, 0.25 vCPU / 0.5 GiB, probe every 5 min | ~€3 |
| Service Bus Standard | ~150K messages/day × 30 | ~€1 |
| Write Worker (Container App) | 1 replica, 0.25 vCPU / 0.5 GiB | ~€3 |
| Enrichment Worker (Container App) | Scale-to-zero, ~2 h active/day | ~€1 |
| DB Option A: Cosmos DB | 3 containers, 400+400+400 RU/s autoscale | ~€55 |
| DB Option B: PostgreSQL Flex | Burstable B2s (2 vCPU / 4 GiB), no replica | ~€50 |
| Redis | Standard C0 (250 MB) | ~€14 |
| API (App Service) | B1 (shared with current) | ~€12 |
| Admin Portal (App Service) | B1 (shared with current) | ~€12 |
| SignalR | Free tier (20 concurrent) | €0 |
| App Insights | ~1 GB/month | ~€0 (free allowance) |
| Total (Cosmos path) | ~€101/month | |
| Total (PostgreSQL path) | ~€96/month |
Staging Environment
Full architecture with reduced scale. Service Bus Standard (Premium sessions not needed for testing). One read replica (PostgreSQL) or per-container throughput (Cosmos). Probe at production frequency (every 1 min).
| Component | SKU / Config | Monthly cost |
|---|---|---|
| ICY Probe (Container App) | 1 replica, 0.5 vCPU / 1 GiB, probe every 1 min | ~€8 |
| Service Bus Standard | ~3M messages/day × 30 | ~€5 |
| Write Worker (Container App) | 1 replica, 0.5 vCPU / 1 GiB | ~€8 |
| Enrichment Worker (Container App) | Scale-to-zero, ~6 h active/day | ~€4 |
| DB Option A: Cosmos DB | 5 containers, total 4,800 RU/s autoscale | ~€165 |
| DB Option B: PostgreSQL Flex | GP D2ds_v5 + 1 read replica | ~€270 |
| Redis | Standard C0 (250 MB) | ~€14 |
| API (App Service) | B1 | ~€12 |
| Admin Portal (App Service) | B1 | ~€12 |
| ML Service (App Service) | B1 | ~€12 |
| SignalR | Standard 1 unit (testing real-time) | ~€42 |
| App Insights | ~3 GB/month | ~€2 |
| Total (Cosmos path) | ~€284/month | |
| Total (PostgreSQL path) | ~€389/month |
Production Environment
Full redundancy. Service Bus Premium (message sessions for per-radio ordering, higher throughput). Multi-replica probe. DB sized for 2000+ radios × 1 min cycle. SignalR scaled for concurrent users.
| Component | SKU / Config | Monthly cost |
|---|---|---|
| ICY Probe (Container App) | 2 replicas, 0.5 vCPU / 1 GiB each, probe every 1 min | ~€16 |
| Service Bus Premium | 1 Messaging Unit (sessions + partitioning) | ~€560 |
| Write Worker (Container App) | 1–3 replicas (KEDA), 0.5 vCPU / 1 GiB | ~€12 |
| Enrichment Worker (Container App) | 0–2 replicas (KEDA), 0.5 vCPU / 1 GiB | ~€8 |
| DB Option A: Cosmos DB | 5 containers, total 8,000 RU/s autoscale | ~€280 |
| DB Option B: PostgreSQL Flex | GP D4ds_v5 + 1 read replica | ~€540 |
| Redis | Standard C1 (1 GB) | ~€55 |
| API (App Service) | P1v3 (2 vCPU / 8 GiB) | ~€115 |
| Admin Portal (App Service) | S1 | ~€60 |
| ML Service (App Service) | S1 | ~€60 |
| SignalR | Standard 2 units (2K concurrent) | ~€84 |
| App Insights | ~10 GB/month | ~€12 |
| Key Vault + Private Endpoints | Standard + 3 PEs | ~€10 |
| Total (Cosmos path) | ~€1,272/month | |
| Total (PostgreSQL path) | ~€1,532/month |
Cost Summary: All Environments
| Environment | Cosmos DB path | PostgreSQL path | Current architecture |
|---|---|---|---|
| Dev | ~€101/month | ~€96/month | ~€60/month |
| Staging | ~€284/month | ~€389/month | ~€180/month |
| Production | ~€1,272/month | ~€1,532/month | ~€300/month |
| All environments | ~€1,657/month | ~€2,017/month | ~€540/month |
Cost vs Current: Where the Money Goes
| Cost driver | Current (all envs) | Ideal (Cosmos path) | Delta | Justification |
|---|---|---|---|---|
| Service Bus | €0 (BullMQ on Redis) | ~€566 | +€566 | Structural backpressure, dead-letters, sessions. Biggest delta. |
| Database | ~€230 | ~€500 | +€270 | Per-container throughput eliminates 429s. Read replicas for zero contention. |
| Compute (Container Apps) | ~€10 | ~€63 | +€53 | Separate probe/write/enrich containers. |
| SignalR | €0 (polling) | ~€126 | +€126 | Real-time push replaces 30–60s polling. |
| Redis | ~€14 | ~€83 | +€69 | Larger cache for write-through pattern. |
| App Service (API/Admin/ML) | ~€200 | ~€271 | +€71 | Prod upgrade B1→P1v3 for reliability. |
| Monitoring/Network | ~€20 | ~€22 | +€2 | Negligible change. |
| Total | ~€540 | ~€1,657 | +€1,117 | ~3× cost for zero 429s, 30× lower latency, fault isolation |
Cost Optimization Levers
If the ideal architecture is too expensive, these levers can reduce cost while keeping the benefits:
| Lever | Saving | Trade-off |
|---|---|---|
| Service Bus Standard in prod (skip Premium) | ~€500/month | Lose message sessions → must handle per-radio ordering in code |
| Skip SignalR, keep polling | ~€126/month | Latency stays at ~60s instead of ~2s |
| Skip PostgreSQL read replica | ~€135–270/month | API reads contend with writes (mitigated by PgBouncer) |
| Skip enrichment worker, keep inline | ~€13/month | Enrichment failures can slow collection (current behavior) |
| Dev: scale-to-zero all Container Apps | ~€5/month | Cold start latency when testing |
| Aggressive cost savings (all levers) | ~€780/month | Total drops to ~€877/month (still +€337 vs current) |