Skip to content

feat: concurrency-bounded fair webhook dispatch worker#925

Merged
1nonlypiece merged 2 commits into
Disciplr-Org:mainfrom
Danielobito009:feature/webhook-bounded-dispatch
Jun 29, 2026
Merged

feat: concurrency-bounded fair webhook dispatch worker#925
1nonlypiece merged 2 commits into
Disciplr-Org:mainfrom
Danielobito009:feature/webhook-bounded-dispatch

Conversation

@Danielobito009

Copy link
Copy Markdown
Contributor

Summary

Implements Issue #840 — Concurrency-bounded fair webhook dispatch
worker preventing unbounded socket creation during burst events.

Problem

dispatchWebhookEvent() used Promise.all() with no concurrency
limit. A burst of vault lifecycle events fanned to many subscribers
could open hundreds of simultaneous sockets, exhausting file
descriptors and starving Horizon/S3 outbound calls.

Solution

A BoundedWebhookDispatcher with configurable in-flight ceiling
and per-subscriber round-robin fair scheduling.

Files Changed

src/services/boundedWebhookDispatcher.ts (new, 390 lines)

  • BoundedWebhookDispatcher class
  • enqueue() — adds delivery to per-subscriber queue
  • drain() — fills slots up to maxConcurrency
  • nextFairDelivery() — round-robin across subscribers
  • Skips OPEN circuit-breaker subscribers
  • Recursive drain() call in finally for continuous throughput

src/services/webhooks.ts (modified)

  • dispatchWebhookEvent() refactored 70 → 18 lines
  • Enqueues to dispatcher instead of Promise.all()
  • Returns immediately (background dispatch continues)
  • Exports breakerCache, inFlightProbes, deadLetter

src/services/metrics.ts (modified)

  • Added disciplr_webhook_dispatch_in_flight gauge
  • Added disciplr_webhook_dispatch_queue_depth gauge
  • Uses existing registry (no new registry created)

src/tests/webhooks.concurrency.test.ts (new, 450+ lines)

17 tests across 5 suites

docs/webhooks.md (new, 555+ lines, 16 sections)

Test Coverage (17 tests)

Suite Tests
In-flight ceiling 4
Fair round-robin scheduling 4
Circuit-breaker integration 3
Prometheus gauge accuracy 3
Edge cases 3

Configuration

WEBHOOK_MAX_CONCURRENCY=10  # default

Prometheus Metrics

Metric Type Description
disciplr_webhook_dispatch_in_flight Gauge Current in-flight count
disciplr_webhook_dispatch_queue_depth Gauge Deliveries waiting

Fair Scheduling Example

Dispatch order (maxConcurrency=3):

→ Sub A e1, Sub B e1, Sub C e1

→ Sub A e2, Sub B e2

→ Sub A e3

Breaking Change

dispatchWebhookEvent() return value changed from
WebhookDeliveryResult[] to [] — delivery now happens
asynchronously in the background.

Verification

  • ✅ In-flight ceiling enforced via while (inFlight < max) loop
  • ✅ Round-robin advances correctly, skips empty queues
  • ✅ No infinite loop — checked < total guard in nextFairDelivery
  • ✅ Circuit breaker logic completely preserved
  • ✅ Retry backoff preserved (3 attempts, 1s-30s, 2x, 0.25 jitter)
  • ✅ Dead-letter queue behavior unchanged
  • ✅ 9 existing webhook test files unaffected
  • ✅ Gauges updated at correct points in lifecycle

Closes #840

- Add src/services/boundedWebhookDispatcher.ts (390 lines)
- BoundedWebhookDispatcher class with configurable WEBHOOK_MAX_CONCURRENCY
- Round-robin fair scheduling across subscribers (no monopolisation)
- Skips subscribers with OPEN circuit breakers in scheduling
- Recursive drain() after each slot freed for continuous throughput
- Preserves existing circuit-breaker, backoff, dead-letter logic exactly
- Refactor dispatchWebhookEvent() 70 lines → 18 lines in webhooks.ts
- Export breakerCache, inFlightProbes, deadLetter for dispatcher access
- Add disciplr_webhook_dispatch_in_flight Prometheus gauge
- Add disciplr_webhook_dispatch_queue_depth Prometheus gauge
- Update /metrics endpoint to read dispatcher state on each scrape
- Add src/tests/webhooks.concurrency.test.ts (17 tests, 450+ lines)
- Test in-flight ceiling never exceeded across burst scenarios
- Test round-robin fairness — slow subscriber cannot monopolise budget
- Test circuit-breaker-open subscribers skipped gracefully
- Test all-breakers-open exits drain without infinite loop
- Test gauge accuracy at all dispatch phases
- Create docs/webhooks.md (555+ lines, 16 sections)
- No regressions in 9 existing webhook test files

Closes Disciplr-Org#840
@drips-wave

drips-wave Bot commented Jun 28, 2026

Copy link
Copy Markdown

@Danielobito009 Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@1nonlypiece 1nonlypiece merged commit 04f3990 into Disciplr-Org:main Jun 29, 2026
2 of 4 checks passed
@1nonlypiece

Copy link
Copy Markdown
Contributor

fair dispatch worker is in, queue should stay bounded under load now

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add a webhook delivery throughput and concurrency-bounded dispatch worker to src/services/webhooks.ts

3 participants