Spotify bulk-batch and queue routing
This is a developer-facing explanation of how BridgeBeats batches single-id Spotify lookups and orders work across the priority queue. It covers the design and the reasoning behind the constants, not a step-by-step task. Type and member names below are links by name into the source; line numbers are deliberately omitted because they go stale.
Overview
Spotify's Web API lets a caller fetch many ids in one request: up to 50 tracks per
GET /tracks?ids=... call and up to 20 albums per GET /albums?ids=... call. Issuing
one HTTP round-trip per id wastes that capacity and burns rate-limit budget faster than
necessary. The batch path exists to amortize those round-trips: single-id track and
album lookups that do not need an immediate answer are diverted onto dedicated Redis
streams, accumulated, and then resolved in multi-id batch calls.
The moving parts:
SpotifyBulkQueueDecorator(inBridgeBeats.Core.Infrastructure.Queue) wraps the Spotify request queue and re-routes non-interactiveSongIdLookupandAlbumIdLookupenqueues onto type-specific bulk streams.SpotifyBatchQueueHelperowns the consumer-group lifecycle, depth and age inspection, batch dequeue, acknowledgement, and bounded requeue for those bulk streams.SpotifyBulkProcessorServiceis the background service that decides when to flush each stream, calls the bulk API, and applies each result to its saga.RedisRequestQueue<T>is the generic per-provider queue. It does not handle the type-specific bulk streams; it governs how the interactive, background, and bulk lanes of the generic queue are ordered during dequeue.JetStreamWatcherServiceis one producer that feeds the bulk lane: it discovers music links on the Bluesky firehose and enqueues them at bulk priority.
The single-id-to-bulk diversion applies only to Spotify, because only the Spotify path has a decorator and a bulk processor wired up for it.
Queue lanes and dequeue ordering
Each provider's generic queue (RedisRequestQueue<T>) has four Redis streams, keyed by
the lower-cased provider name:
queue:{provider}:interactive— highest priority; a user is waiting.queue:{provider}:background— work that can wait.queue:{provider}:bulk— lowest priority; the generic bulk lane.queue:{provider}:dlq— the dead-letter queue (read by range, no consumer group).
Ordering is deterministic, not weighted-random. QueueSettings.Weights
(PriorityWeights) is retained for backward-compatible configuration but no longer drives
selection; the field's own remarks say so. The order for a given dequeue is produced by
GetStreamDequeueOrder, a pure method that takes the current dequeue counter and the
current queue depth.
Interactive-first
On a normal (non-aging) call, the order is interactive, then background, then bulk. The dequeue visits streams in that order and returns the first message it finds, so as long as interactive work exists it is always served first.
The aging escape hatch
Strict interactive-first ordering would let a steady stream of interactive work starve the
background and bulk lanes forever. To prevent that, every Nth dequeue is an "aging slot"
that demotes interactive and promotes a lower tier to the head of the order. N is
QueueSettings.InteractiveAgingInterval, default 8. A call is an aging slot when
dequeueCounter % agingInterval == 0.
The aging slots alternate which lower tier leads, so neither background nor bulk starves:
- Odd-numbered aging slots: background leads (
background, interactive, bulk). - Even-numbered aging slots: bulk leads (
bulk, interactive, background).
The slot index is dequeueCounter / agingInterval, and its parity selects which tier
leads. A configured interval of 1 or less is treated as misconfiguration — at N=1 every
single dequeue would demote interactive — so the queue logs a warning and falls back to
the default of 8 (RedisRequestQueue<T> validates this in its constructor; QueueSettings
documents the same fallback).
Bulk gating
Bulk is only worth serving once enough has accumulated to batch. GetStreamDequeueOrder
gates the bulk lane: bulk is included in the order only when its depth reaches the
per-provider minimum threshold. Below the threshold, bulk is dropped from the order
entirely (and on an even aging slot, background leads instead, since there is no eligible
bulk to alternate with).
The threshold comes from QueueSettings.GetMinBulkThreshold, which returns the
per-provider override from ProviderMinBulkThresholds or falls back to
DefaultMinBulkQueueThreshold, default 5. Setting the effective threshold to 0 or 1
disables gating, so bulk is always eligible.
Note the two distinct meanings of "bulk" here. This gating governs the generic
queue:{provider}:bulk lane inside RedisRequestQueue<T>. The Spotify type-specific
bulk streams described next are separate, and the bulk processor applies its own
size-or-age flush rather than this dequeue-order gating.
Bulk batching
SpotifyBulkProcessorService runs a loop on a 500 ms check interval. Each cycle it asks
whether the track-id stream and the album-id stream should flush, and processes whichever
qualify. The flush decision is a pure predicate, ShouldFlush, so it can be tested in
isolation.
Size-or-age flush
ShouldFlush(count, oldestAge, threshold, linger) returns true when either condition
holds:
- Size:
count >= threshold. For tracks the threshold isSpotifyConstants.MaxTracksPerBatchLookup(50); for albums it isSpotifyConstants.MaxAlbumsPerBatchLookup(20). These match the Spotify Web API page limits, so a full batch is exactly one maximal API call. - Age: there is at least one queued item and the oldest item's age has reached the linger window. The processor only computes the oldest-item age when the stream is below its size threshold, because at or above the threshold the size condition already fires.
Size is the primary trigger. A stream that fills to 50 tracks (or 20 albums) flushes
immediately; the age path is the backstop for low-volume streams that never reach the
size threshold. The oldest enqueue time is read from the stream's first entry —
Redis streams are time-ordered, so the first entry is the oldest
(SpotifyBatchQueueHelper.GetOldestEnqueuedAtAsync). A malformed enqueuedAt field is
logged and treated as absent so a bad timestamp cannot wedge the age decision.
A flush is also suppressed when the relevant bulk endpoint is rate-limited or the stream is in request-failure cooldown (see Reliability).
The linger default and saga-TTL reconciliation
The linger window is SpotifyBatchSettings.LingerMs, config key
BridgeBeats:Spotify:Batch:LingerMs, bound from the section
BridgeBeats:Spotify:Batch. Its default, SpotifyBatchSettings.DefaultLingerMs, is
86_400_000 ms — 24 hours. A non-positive configured value is clamped back to that
default, so batching's age backstop cannot be disabled by misconfiguration.
A 24-hour age backstop is much larger than a latency bound would be, and that is intentional. Because size is the real flush trigger, the linger is not there to bound how long a request waits; it is a staleness drain for streams that never fill. Setting it that high effectively turns off time-based flushing for normally-trafficked streams while still guaranteeing a low-volume stream eventually drains.
The size of that default is constrained from below by saga expiry. QueueSettings.JobExpirationMinutes
is the number of minutes before an incomplete job or saga expires; its default is 2880
(48 hours). The relationship the JobExpirationMinutes comment references: a saga can be
created when a request is enqueued and then sit in a bulk stream until the next flush. If
the saga's time-to-live were shorter than the linger, the saga could be evicted before the
flush ever writes its result. JobExpirationMinutes must therefore exceed the linger
backstop. The 48-hour default is 2× the 24-hour linger, which keeps visible margin between
"latest a batch can flush" and "earliest the saga can expire."
If you raise LingerMs past JobExpirationMinutes, you reintroduce that eviction race;
keep the saga TTL comfortably above the linger.
Routing
How typed id lookups reach the bulk streams
SpotifyBulkQueueDecorator decorates the Spotify IRequestQueue<QueuedLookupRequest>.
Its EnqueueAsync re-routes a request to a type-specific bulk stream only when both
conditions hold:
- the lookup type is
SongIdLookuporAlbumIdLookup, and - the priority is not
Interactive.
When routed, SongIdLookup goes to SpotifyConstants.BulkTrackIdStream
(queue:spotify:bulk:track-id) and AlbumIdLookup goes to
SpotifyConstants.BulkAlbumIdStream (queue:spotify:bulk:album-id). The request is
serialized and written directly with XADD, carrying the payload and enqueuedAt
fields, and an enqueue metric is recorded at bulk priority so dashboards can tell
bulk-stream enqueues apart from generic ones.
Everything else passes through to the inner queue unchanged: interactive SongIdLookup
and AlbumIdLookup (so an interactive caller is served on the interactive lane with a
single-item call, preserving its latency budget), and every other lookup type at any
priority. All non-enqueue operations on the decorator delegate straight to the inner
queue.
Using the shared stream-name constants on both the producer and consumer side matters: the constant's own remarks call out that a producer and consumer disagreeing on the stream name is a silent failure where no messages are ever delivered.
How firehose links land in the bulk lane
JetStreamWatcherService consumes the Bluesky Jetstream firehose, subscribing to the
app.bsky.feed.post and app.bsky.feed.repost collections. For each newly created record
it extracts candidate links from post facets, embedded external link cards, and the
subjects of quote posts and reposts (hydrating those through the Bluesky API). The watcher
is producer-only: it feeds the queue and never consumes results.
For each candidate link, IdentifyProviderAsync decides the provider and lookup type. The
checks run in a deliberate order — spotify.link short links first, then Spotify
track/album ids, then Tidal, then Apple Music. A Spotify track link becomes a
SongIdLookup and a Spotify album link becomes an AlbumIdLookup, both carrying the
resolved Spotify id as the lookup value. (Spotify prerelease links stay as UriLookup for
the generic worker; artist and playlist links are not recognized and are dropped.)
Every link the watcher enqueues is built as a QueuedLookupRequest with
OriginPriority set to QueuePriority.Bulk and enqueued at bulk priority through the
resolved provider queue. For Spotify track and album ids, that bulk-priority enqueue is
exactly the case SpotifyBulkQueueDecorator intercepts, so firehose-discovered Spotify
track/album links flow into the type-specific bulk streams rather than the generic
queue:spotify:bulk lane. Bulk origin priority is persisted into the saga so any
secondary lookups the coordinator spawns also stay out of the interactive lane. Enqueue
failures are logged and swallowed so one bad link does not interrupt the firehose.
Reliability
The bulk path keeps work moving across crashes, bad data, and transient provider failures.
Stale-entry recovery with XAUTOCLAIM
SpotifyBatchQueueHelper dequeues in two steps. First it runs an XAUTOCLAIM sweep to
reclaim pending entries that have been idle longer than AutoClaimMinIdleMs (60 seconds),
deserializing and appending them to the result; then it reads new entries with
XREADGROUP. Reclaimed entries count against the requested batch size, so a single
dequeue never returns more than asked. The 60-second idle threshold is chosen to be long
enough that a slow-but-alive processing cycle is never reclaimed, yet short enough that a
crashed consumer does not block the stream for more than a minute. There is exactly one
consumer service reading these streams, which is what makes reclaiming another consumer's
pending entries safe after the idle window.
XAUTOCLAIM requires Redis 6.2 or newer. If the server is older, the reclaim step throws,
is logged, and is skipped — the dequeue still reads new entries, only pending-entry
recovery is unavailable.
Poison-entry handling
An entry with a missing payload, a payload that deserializes to a literal JSON null, or a
payload that cannot be deserialized is poison: retrying it can never succeed and would just
loop forever under repeated XAUTOCLAIM re-claims. Such entries are acknowledged and
deleted (XACK + XDEL) on both the reclaim and the new-read paths rather than retried,
so they leave the pending list permanently.
Requeue cap and finalization
Requeue is bounded by LookupConstants.MaxQueueRetryAttempts (5), the same cap the generic
queue processor enforces. SpotifyBatchQueueHelper.RequeueAsync always acknowledges and
deletes the original entry first, then:
- if the current attempt count is below the cap, it re-adds a fresh entry with a new
enqueuedAtandAttemptCount + 1and returnsRequeued; - if the cap is reached, or the payload cannot be deserialized to read its attempt count,
it discards the message and returns
CapReached; - if the original entry no longer exists (already deleted, for example after an
XAUTOCLAIMre-claim by a duplicate in flight), it returnsNotFound.
The processor reacts to the outcome. On CapReached it writes a failed Spotify provider
state ("Bulk lookup failed after 5 attempts") and publishes saga-level and per-lookup
completion so the saga coordinator and any interactive waiter unblock instead of hanging.
On NotFound it leaves the saga untouched, since another consumer handled the entry.
Failure shapes in the bulk dispatch
The processor distinguishes three failure shapes when resolving a batch:
- A rate limit (
RetryAfterExceededException) records the endpoint rate limit, marks each affected saga partial, merges a rate-limit info entry for Spotify into the saga, publishes theRATE_LIMITEDsentinel (LookupConstants.RateLimitedSentinel) so synchronous waiters are not left hanging on a 429, and requeues the whole batch. - An empty result dictionary is treated as a request-level failure (network or auth error). No saga state is written; the whole batch is requeued and the stream's request-failure cooldown is armed.
- An absent key inside a non-empty result is a per-id partial: only that one message is requeued. A key that is present with a null value is a genuine not-found and is written as a not-found provider state, not requeued.
Exponential cooldown after request failures
After an empty-dict request failure, the processor arms a per-stream cooldown so it does
not re-flush and burn retries faster than Spotify can recover. The base is
SpotifyBatchSettings.RequestFailureCooldownSeconds (config key
BridgeBeats:Spotify:Batch:RequestFailureCooldownSeconds, default 5; non-positive values
are clamped back to the default). ComputeCooldownSeconds doubles the base once per prior
consecutive failure, clamping the exponent at 5 to avoid overflow, and caps the result at
SpotifyBatchSettings.MaxRequestFailureCooldownSeconds (60). The track-id and album-id
streams each keep their own cooldown and consecutive-failure counter, so a failure on one
endpoint does not suppress flushes on the other. A successful batch dispatch resets that
stream's counter and clears its cooldown.
Genre resolution
SpotifyArtistGenreService keeps the artist-to-genre cache fresh on a slow, rolling
schedule, separate from the per-request bulk path. It waits 30 seconds after startup, then
wakes on a 5-minute check interval. A full refresh runs only when 7 days have elapsed since
the last full run (s_scheduleInterval is TimeSpan.FromDays(7)).
A full run drains the per-provider artist-refresh queue (a Redis sorted set) in batches of
SpotifyConstants.MaxArtistsPerBatchLookup (50, the Spotify page limit for artists),
looks up each batch's genres, and writes them back to the cache. It pauses 100 ms between
batches to be gentle on Spotify's rate limits, and stops when the queue drains empty. An
artist that is not found is left uncached so it can be retried on a later run; an empty
genre list is a valid result and is cached. Errors in a single batch are logged and the run
continues with the next batch; errors in the loop are logged and retried after a one-minute
delay rather than crashing the host.