architecture · domain layer
The domain layer, no I/O.
The domain layer contains the pure business logic of bunqueue. No external dependencies, just core algorithms and data structures.
├── types/ # Type definitions
└── queue/ # Core queue logic
├── shard.ts # Shard container
├── priorityQueue.ts # 4-ary indexed heap
├── dlqShard.ts # Dead letter queue
├── uniqueKeyManager.ts # Deduplication
├── limiterManager.ts # Rate/concurrency
├── dependencyTracker.ts # Job dependencies
├── temporalManager.ts # Temporal index + delayed jobs
├── waiterManager.ts # Long-poll waiters
└── shardCounters.ts # Running shard totals
Jobs are distributed across N shards (auto-detected from CPU cores) for parallelism:
QueueManager N independent shards, auto-detected
queueName
→
fnv1a()
→
& SHARD_MASK
→
idx
↓
Shard 0 queues, unique, dlq, limits
Shard 1 queues, unique, dlq, limits
Shard 2 queues, unique, dlq, limits
Shard N queues, unique, dlq, limits
Shard count is a power of 2, based on CPU cores, max 64.
Each shard is a composition of managers:
Shard composition of managers
queues Map<string, PriorityQueue>
UniqueKeyManager deduplication with TTL
DlqShard failed job storage
LimiterManager rate and concurrency control
DependencyTracker waitingDeps + dependencyIndex
TemporalManager delayed jobs, MinHeap
stats running shard totals: queued, delayed, dlq
activeGroups Map, FIFO groups
waiters queue-local cursor deques, long-poll support
The shard counters make aggregate queued, delayed, and DLQ totals constant-time.
Splitting ready jobs into waiting versus prioritized still examines current
queue entries. Multi-queue summary calls batch that work and traverse global
processing/completed/dependency collections once, instead of once per queue.
4-ary indexed heap with lazy deletion:
PriorityQueue 4-ary indexed heap with lazy deletion
PUSH
1. Generate generation number, 2. add to index Map<jobId, {job, generation}> , 3. push to heap {jobId, priority, runAt, generation} , 4. bubbleUp O(log₄ n)
POP
Loop: 1. peek heap top, 2. check index for matching generation, 3. if generation mismatch, stale entry: removeTop, continue, 4. if match: removeTop, delete from index, return job O(log₄ n) amortized
REMOVE, by jobId
1. Delete from index O(1) , 2. heap entry becomes stale skipped on pop , 3. compact heap when stale ratio > 20%
Waiters are isolated by queue. Each queue keeps an append-only entry array with
a head cursor, an active count, and one coalesced pending-notification bit.
Notification clears a waiter’s timer immediately and advances the cursor; it
does not repeatedly filter or splice the full array. Consumed prefixes are
compacted once the head reaches 1,024 entries and at least half the array is
stale. Surplus batch notifications collapse into one retry hint rather than
accumulating credits that would cause repeated empty pulls.
Job state machine
WAITING re-entered when a retryable fail triggers retry
↓
DELAYED delay > 0, becomes ready when runAt is reached
ready delay = 0
↓
ACTIVE on retryable fail, back to WAITING
↓
COMPLETED success
DLQ fail at max retries, or timeout
Dependency resolution Job B, dependsOn: [A]
push B, job with dependencies
1. Push B, check: is A completed?
↓
NO add B to waitingDeps, register B in dependencyIndex[A]
YES push B to active queue
when A completes
1. Add A.id to pendingDepChecks
↓
2. Event-driven flush scheduled on the next microtask, coalescing completions from the same tick; a 30s interval acts as safety fallback only
↓
3. For each completedId, get dependencyIndex[completedId] Set<jobIds>
↓
4. For each waiting job, check all deps in completedJobs, if YES move from waitingDeps to queue
Reverse Index:
Reverse index dependencyIndex: Map<JobId, Set<JobId>>
A
→
{B, C} B and C wait for A
Move to DLQ job fails with attempts >= maxAttempts
DlqEntry
job original job
reason explicit_fail, max_attempts_exceeded, timeout, stalled, ttl_expired, worker_lost, unknown
error error message
attempts full history: attempt, error, duration
enteredAt timestamp
nextRetryAt if autoRetry enabled
expiresAt 7 days default
↓
DLQ maintenance, every 60s
1. Auto-retry eligible entries nextRetryAt <= now && retryCount < maxAutoRetries
2. Purge expired entries expiresAt <= now
3. Enforce maxEntries per queue 10k default, FIFO eviction when full
Pull request rate and concurrency limiting
1. check rate limit, token bucket
Tokens available consume 1, proceed
No tokens return null
↓
2. check concurrency limit
active < limit increment, proceed
At limit return null
↓
3. Pop from priority queue
token bucket
capacity N tokens
refillRate N tokens/sec
tryAcquire() 1. refill based on elapsed time, 2. if tokens >= 1 consume and return true, 3. else return false
Ensures only one job per group processes at a time:
FIFO groups job with groupId: "user-123"
PULL
1. Peek job at queue head, 2. check: is groupId in activeGroups?
↓
YES job stays at the head, this pull returns no job (preserves strict per-group order)
NO pop, add to activeGroups, return job
ACK / FAIL
1. Remove groupId from activeGroups, 2. next job in the same group can now be pulled