Skip to content

Commit 2c75bf7

Browse files
feat(webhook): distributed job scheduler with lease-based worker claiming (#149)
Replace the single-flight queue processor with a distributed job scheduler (jobScheduler.ts) in which multiple workers claim due webhook jobs under short-lived leases. Lease fencing prevents concurrent workers/replicas from double-delivering the same job, heartbeat renewal keeps healthy long-running deliveries from being stolen, and expired leases are reclaimed by other workers for crash recovery (at-least-once on failure). Worker count is configurable via WEBHOOK_WORKER_COUNT, scheduler metrics are exposed via /metrics, and architecture/deployment/runbook docs are updated. Closes #123 Co-authored-by: elizabetheonoja-art <elizabetheonoja@gmail.com>
1 parent a56d5a8 commit 2c75bf7

11 files changed

Lines changed: 894 additions & 57 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ AGENTS.md
1111
agents/
1212
issue.md
1313
node_modules/
14+
coverage/
1415

1516
# Windows build artifacts
1617
*.exe

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ An enterprise-grade, high-performance off-chain delivery daemon for real-time So
5050
- **Performance**: `< 100ms` P99 ingestion latency target via an asynchronous event-driven memory queue.
5151
- **Robust Security**: Includes HMAC-SHA256 and Ed25519 signature headers, strict replay protection windowing, and thorough SSRF IP/DNS blacklisting.
5252
- **Resiliency**: Built-in exponential backoff retry schedules with full randomized jitter to survive downstream subscriber downtimes and network drops.
53+
- **Distributed Scheduling**: Lease-based worker claiming (`WEBHOOK_WORKER_COUNT`) prevents duplicate deliveries across concurrent workers and replicas, with heartbeat renewal and crash-recovery reclaim.
5354
- **Operational Guides**: See [WEBHOOK_ARCHITECTURE.md](docs/WEBHOOK_ARCHITECTURE.md), [WEBHOOK_DEPLOYMENT.md](docs/WEBHOOK_DEPLOYMENT.md), and [WEBHOOK_RUNBOOK.md](docs/WEBHOOK_RUNBOOK.md).
5455

5556
## Architecture

docs/WEBHOOK_ARCHITECTURE.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,17 @@ Transient network drops, rate limits (HTTP 429), and short-term receiver outages
9292
- **Full Jitter**: Prevents "thundering herd" issues by introducing randomized delay ($t_{jitter} = \text{random}(0, t_{backoff})$).
9393
- **Max Retries**: Defaulted to **5 attempts** before a webhook is classified as failed.
9494

95+
### 3.6 Distributed Job Scheduler with Lease-based Worker Claiming
96+
97+
Queue processing runs on a **distributed job scheduler** (`jobScheduler.ts`) where multiple worker loops compete to claim due webhook jobs under short-lived **leases**:
98+
99+
- **Claim protocol**: A job becomes claimable once its `runAt` time has elapsed. A worker acquires the job by claiming a lease from a shared lease registry (`LeaseStore`); while that lease is valid, no other worker can claim the same job, so concurrent replicas/workers can never double-deliver the same webhook.
100+
- **Fencing across processes**: `LeaseStore.claim` is atomic (synchronous within the Node event loop and serialisable against a shared store such as Redis/etcd in production), which gives cross-process mutual exclusion. Each lease carries a monotonic fencing token.
101+
- **Heartbeat / lease renewal**: while a job is executing, the owning worker renews its lease on a configurable interval, so a healthy long-running delivery is never stolen by a competing worker.
102+
- **Crash recovery**: if a worker dies without renewing, its lease expires and another worker reclaims the job — exactly-once under normal operation, at-least-once on worker failure.
103+
- **Retry via rescheduling**: a failed attempt with retries remaining calls `ctx.reschedule(nextAttemptTime)` (exponential backoff + jitter), returning the job to the claimable pool at a future time.
104+
- **Horizontal scaling**: worker count is controlled by `WEBHOOK_WORKER_COUNT` (default `3`). Scaling replicas or raising the worker count increases delivery concurrency without risking duplicate deliveries.
105+
95106
---
96107

97108
## 4. Monitoring & Metrics
@@ -101,3 +112,9 @@ The service registers Prometheus counters and histograms to measure health indic
101112
- `webhook_delivery_duration_seconds`: Histogram of endpoint response latency.
102113
- `webhook_queue_size_current`: Gauge representing current queue occupancy.
103114
- `webhook_failures_total`: Total dropped or exhausted delivery alerts.
115+
- `webhook_scheduler_workers_current`: Gauge of active worker loops.
116+
- `webhook_scheduler_active_leases_current`: Gauge of jobs currently executing under a worker lease.
117+
- `webhook_scheduler_jobs_submitted_total`: Counter of jobs submitted to the scheduler.
118+
- `webhook_scheduler_jobs_processed_total`: Counter of jobs executed by workers.
119+
- `webhook_scheduler_jobs_failed_total`: Counter of jobs whose execution threw.
120+
- `webhook_scheduler_lease_reclaimed_total`: Counter of expired leases reclaimed by another worker (crash recovery events).

docs/WEBHOOK_DEPLOYMENT.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,12 @@ Once the Green environment passes all sanity tests:
5757
2. Monitor active connections on Blue and allow a **5-minute graceful drain window** to complete any outstanding retry attempts or delivery backlogs.
5858
3. Shut down or idle the Blue infrastructure.
5959

60+
### Delivery Concurrency & Scheduler Workers
61+
Queue processing is performed by a configurable pool of scheduler workers that claim jobs under short-lived leases, so multiple replicas can run concurrently without double-delivering webhooks:
62+
- Set `WEBHOOK_WORKER_COUNT` (default `3`) per deployment to control in-process delivery concurrency.
63+
- To scale out, increase the replica count of the webhook containers; each replica contributes its worker pool and lease-based claiming prevents duplicate deliveries across replicas.
64+
- After scaling, verify `webhook_scheduler_workers_current` and `webhook_scheduler_active_leases_current` in `/metrics` and confirm `webhook_scheduler_lease_reclaimed_total` stays near zero (reclaimed leases indicate workers expiring mid-delivery and warrant investigation).
65+
6066
---
6167

6268
## 3. Canary Analysis Strategy

docs/WEBHOOK_RUNBOOK.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,3 +79,23 @@ To prevent breaking integrations during rotation:
7979
To ensure the safety of the off-chain system, the SSRF (Server-Side Request Forgery) engine must be audited after any networking or DNS upgrades:
8080
1. Verify that the URL parser correctly flags subnets by running integration tests.
8181
2. Inspect server firewalls, ensuring egress traffic is strictly barred from routing to cloud provider private IP ranges and internal Kubernetes API service accounts.
82+
83+
---
84+
85+
## 5. Scheduler & Worker Operations
86+
87+
Queue processing runs on the distributed job scheduler, where workers claim jobs under short-lived leases. Diagnose scheduler health through the `webhook_scheduler_*` metrics on `/metrics` and the scheduler block on `/health`.
88+
89+
### Health Indicators
90+
- `webhook_scheduler_workers_current` **0** → worker loops are not running; the scheduler cannot drain the queue. Restart the service.
91+
- `webhook_scheduler_active_leases_current` sustained at the worker count → all workers are blocked on slow deliveries; inspect downstream endpoint latency and consider scaling out.
92+
- `webhook_scheduler_lease_reclaimed_total` climbing → workers are expiring mid-delivery (lease not renewed). Investigate event-loop blocking / GC pauses, or increase the lease duration.
93+
94+
### Diagnosing duplicate or missed deliveries
95+
1. Confirm workers are healthy: `curl -s http://webhook-service.internal/health` and check `scheduler.workers` is non-empty and `scheduler.pendingCount` is not climbing.
96+
2. Confirm no lease thrash: `curl -s http://webhook-service.internal/metrics | grep webhook_scheduler_lease_reclaimed_total`.
97+
3. If `pendingCount` climbs while `active_leases` stays low, a worker crash loop is likely; scale the deployment and inspect container restart counts.
98+
99+
### Tuning
100+
- Delivery concurrency per instance: `WEBHOOK_WORKER_COUNT` (default `3`).
101+
- Lease duration and heartbeat are configurable in `jobScheduler.ts` (`leaseDurationMs`, `leaseRenewIntervalMs`, `pollIntervalMs`).

webhook-delivery-service/src/delivery.ts

Lines changed: 35 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import axios from 'axios';
22
import { generateSignatures, validateUrlForSsrf } from './security';
33
import { trackDeliveryAttempt, trackQueueSize, trackFailure } from './metrics';
4+
import { JobScheduler, ExecuteContext } from './jobScheduler';
45
import { logger, LogAttributes } from './logger';
56

67
// Structured logging is skipped in tests: Jest's console interception adds
@@ -40,12 +41,16 @@ export interface WebhookDeliveryLog {
4041
lastAttemptTime: number;
4142
}
4243

43-
// In-memory job queue and log storage
44-
const queue: WebhookJob[] = [];
44+
// Delivery log storage (bounded)
4545
const deliveryLogs: WebhookDeliveryLog[] = [];
4646
const MAX_LOGS = 100;
4747

48-
let isProcessing = false;
48+
// Distributed job scheduler: multiple workers claim due webhook jobs under
49+
// short-lived leases so concurrent replicas/workers never double-deliver the
50+
// same webhook. Worker count is configurable for horizontal scaling.
51+
const WORKER_COUNT = parseInt(process.env.WEBHOOK_WORKER_COUNT || '3', 10);
52+
const scheduler = new JobScheduler();
53+
scheduler.start(WORKER_COUNT);
4954

5055
/**
5156
* Enqueue a new webhook delivery job
@@ -70,8 +75,16 @@ export function enqueueWebhook(
7075
nextAttemptTime: Date.now(),
7176
};
7277

73-
queue.push(job);
74-
trackQueueSize(queue.length);
78+
// Submit the job to the distributed scheduler; a worker will claim it under
79+
// a lease as soon as it is due.
80+
scheduler.submit({
81+
id,
82+
runAt: job.nextAttemptTime,
83+
execute: async (ctx: ExecuteContext) => {
84+
await deliverWebhook(job, ctx);
85+
},
86+
});
87+
trackQueueSize(scheduler.getPendingCount());
7588

7689
// Initialize delivery log
7790
addLog({
@@ -84,11 +97,6 @@ export function enqueueWebhook(
8497
lastAttemptTime: Date.now(),
8598
});
8699

87-
// Process queue asynchronously
88-
setImmediate(() => {
89-
processQueue();
90-
});
91-
92100
return id;
93101
}
94102

@@ -100,17 +108,24 @@ export function getDeliveryLogs(): WebhookDeliveryLog[] {
100108
}
101109

102110
/**
103-
* Retrieve queue size
111+
* Retrieve queue size (jobs waiting or due to be claimed by workers)
104112
*/
105113
export function getQueueSize(): number {
106-
return queue.length;
114+
return scheduler.getPendingCount();
115+
}
116+
117+
/**
118+
* Retrieve scheduler status (worker ids, pending jobs, active leases)
119+
*/
120+
export function getSchedulerStatus() {
121+
return scheduler.getStatus();
107122
}
108123

109124
/**
110125
* Clear queue and logs (primarily for testing)
111126
*/
112127
export function clearQueueAndLogs(): void {
113-
queue.length = 0;
128+
scheduler.clear();
114129
deliveryLogs.length = 0;
115130
trackQueueSize(0);
116131
}
@@ -143,46 +158,10 @@ export function calculateRetryDelay(attempt: number, baseDelay = 1000, maxDelay
143158
}
144159

145160
/**
146-
* Background queue processor
147-
*/
148-
async function processQueue() {
149-
if (isProcessing) return;
150-
isProcessing = true;
151-
152-
try {
153-
while (queue.length > 0) {
154-
// Find jobs ready for processing (nextAttemptTime <= now)
155-
const now = Date.now();
156-
const jobIndex = queue.findIndex((job) => job.nextAttemptTime <= now);
157-
158-
if (jobIndex === -1) {
159-
// No jobs are ready right now, wait or break
160-
break;
161-
}
162-
163-
// Extract the job
164-
const [job] = queue.splice(jobIndex, 1);
165-
trackQueueSize(queue.length);
166-
167-
// Process the job
168-
await deliverWebhook(job);
169-
}
170-
} finally {
171-
isProcessing = false;
172-
173-
// If there are still items in the queue, schedule the next check
174-
if (queue.length > 0) {
175-
setTimeout(() => {
176-
processQueue();
177-
}, 200); // Check every 200ms
178-
}
179-
}
180-
}
181-
182-
/**
183-
* Deliver a single webhook job
161+
* Deliver a single webhook job. Runs inside a scheduler worker that holds the
162+
* job's lease; retries are handled by rescheduling the job at a future time.
184163
*/
185-
async function deliverWebhook(job: WebhookJob) {
164+
async function deliverWebhook(job: WebhookJob, ctx: ExecuteContext) {
186165
job.attempts++;
187166
const startTime = Date.now();
188167

@@ -259,11 +238,12 @@ async function deliverWebhook(job: WebhookJob) {
259238
trackDeliveryAttempt(statusCode || 0, duration, job.attempts);
260239

261240
if (job.attempts < job.maxAttempts) {
262-
// Re-queue for retry
241+
// Schedule a retry with exponential backoff; the job is released back to
242+
// the scheduler pool and becomes claimable again at nextAttemptTime.
263243
const delay = calculateRetryDelay(job.attempts);
264244
job.nextAttemptTime = Date.now() + delay;
265-
queue.push(job);
266-
trackQueueSize(queue.length);
245+
ctx.reschedule(job.nextAttemptTime);
246+
trackQueueSize(scheduler.getPendingCount());
267247

268248
logDelivery('warn', 'webhook delivery failed, retrying', {
269249
'webhook.id': job.id,

webhook-delivery-service/src/index.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import express, { Request, Response } from 'express';
2-
import { enqueueWebhook, getDeliveryLogs, getQueueSize } from './delivery';
3-
import { getPrometheusMetrics, getStatsSummary, trackIngestionDuration } from './metrics';
2+
import { enqueueWebhook, getDeliveryLogs, getQueueSize, getSchedulerStatus } from './delivery';
3+
import { getPrometheusMetrics, getStatsSummary } from './metrics';
44
import { logger } from './logger';
55

66
const app = express();
@@ -104,6 +104,7 @@ app.get('/health', (req: Request, res: Response) => {
104104
status: 'UP',
105105
timestamp: Date.now(),
106106
queueSize: getQueueSize(),
107+
scheduler: getSchedulerStatus(),
107108
});
108109
});
109110

0 commit comments

Comments
 (0)