🏗️

Ideal Architecture — Event-Driven Now-Playing

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

graph LR subgraph PROBE["ICY Probe Service"] P1["Replica 1
(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

  1. Separation of concerns — Each component has a single responsibility: probe, persist, enrich, push, or serve.
  2. Structural backpressure — Queue depth regulates throughput. No hand-coded semaphores.
  3. Fault isolation — A component failure doesn't cascade. Messages stay in the queue until the consumer recovers.
  4. Independent scaling — Each component scales by its own bottleneck (radio count, queue depth, client connections, request rate).
  5. 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:

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:

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:

  1. Receive NowPlayingProbed message
  2. Compare with cached previous track in Redis (now-playing:{radioId})
  3. If unchanged → ack message, update now_playing_updated_at timestamp only
  4. If changed:
    • Radio.updateOne() — set now_playing, now_playing_updated_at
    • IcyHistory.create() — append to history
    • Update Redis cache with new track
  5. 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:

  1. Receive NowPlayingProbed message (only on track change — use SQL filter on subscription)
  2. Look up MusicLink by (artist, track):
    • If cached in DB → skip
    • If not → query Spotify API → upsert MusicLink
  3. Increment TrackRadioStats for the radio+track pair
  4. 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:

  1. Receive NowPlayingProbed message (track change only — SQL filter)
  2. Broadcast to SignalR group radio:{radioId}
  3. 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.

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:


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)

Phase 2 — Separate enrichment

Phase 3 — Add real-time push

Phase 4 — Split read/write DB paths

Phase 5 — Remove legacy code


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)