@objectstack/plugin-webhooks 5.1.0 → 6.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +35 -13
- package/CHANGELOG.md +17 -33
- package/dist/chunk-33LYZT7O.js +184 -0
- package/dist/chunk-33LYZT7O.js.map +1 -0
- package/dist/chunk-BS2QTZH3.js +256 -0
- package/dist/chunk-BS2QTZH3.js.map +1 -0
- package/dist/chunk-FA66GQEO.cjs +256 -0
- package/dist/chunk-FA66GQEO.cjs.map +1 -0
- package/dist/chunk-MJZGD37S.cjs +184 -0
- package/dist/chunk-MJZGD37S.cjs.map +1 -0
- package/dist/index.cjs +908 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +469 -0
- package/dist/index.d.ts +435 -70
- package/dist/index.js +872 -217
- package/dist/index.js.map +1 -1
- package/dist/outbox-CIn7LSyB.d.cts +155 -0
- package/dist/outbox-CIn7LSyB.d.ts +155 -0
- package/dist/schema.cjs +9 -0
- package/dist/schema.cjs.map +1 -0
- package/dist/schema.d.cts +4787 -0
- package/dist/schema.d.ts +4787 -0
- package/dist/schema.js +9 -0
- package/dist/schema.js.map +1 -0
- package/dist/sql-outbox.cjs +8 -0
- package/dist/sql-outbox.cjs.map +1 -0
- package/dist/sql-outbox.d.cts +55 -0
- package/dist/sql-outbox.d.ts +55 -0
- package/dist/sql-outbox.js +8 -0
- package/dist/sql-outbox.js.map +1 -0
- package/package.json +30 -10
- package/src/auto-enqueuer.test.ts +391 -0
- package/src/auto-enqueuer.ts +335 -0
- package/src/dispatcher.test.ts +324 -0
- package/src/dispatcher.ts +218 -0
- package/src/http-sender.ts +187 -0
- package/src/index.ts +49 -12
- package/src/memory-outbox.test.ts +86 -0
- package/src/memory-outbox.ts +155 -0
- package/src/outbox.ts +175 -0
- package/src/partition.ts +19 -0
- package/src/retention.test.ts +116 -0
- package/src/retention.ts +144 -0
- package/src/schema.ts +22 -0
- package/src/sql-outbox.test.ts +490 -0
- package/src/sql-outbox.ts +343 -0
- package/src/sys-webhook-delivery.object.ts +224 -0
- package/src/webhook-outbox-plugin.ts +442 -0
- package/tsconfig.json +5 -13
- package/tsup.config.ts +14 -0
- package/dist/index.d.mts +0 -104
- package/dist/index.mjs +0 -216
- package/dist/index.mjs.map +0 -1
- package/src/webhooks-plugin.test.ts +0 -218
- package/src/webhooks-plugin.ts +0 -294
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
+
|
|
3
|
+
import type { IDataEngine, IRealtimeService, RealtimeEventPayload } from '@objectstack/spec/contracts';
|
|
4
|
+
import type { IWebhookOutbox } from './outbox.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Optional logger interface (subset of console / kernel logger).
|
|
8
|
+
*/
|
|
9
|
+
interface OptionalLogger {
|
|
10
|
+
info?(msg: string, meta?: unknown): void;
|
|
11
|
+
warn?(msg: string, meta?: unknown): void;
|
|
12
|
+
debug?(msg: string, meta?: unknown): void;
|
|
13
|
+
error?(msg: string, err?: unknown, meta?: unknown): void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Per-row subscription cached in memory. Mirrors a subset of the
|
|
18
|
+
* `sys_webhook` object — only what the auto-enqueuer needs to match an
|
|
19
|
+
* event and build an `EnqueueInput`.
|
|
20
|
+
*/
|
|
21
|
+
interface CachedSubscription {
|
|
22
|
+
id: string;
|
|
23
|
+
name: string;
|
|
24
|
+
objectName: string | undefined; // empty = matches all objects (manual-only is filtered out earlier)
|
|
25
|
+
triggers: Set<'create' | 'update' | 'delete' | 'undelete'>;
|
|
26
|
+
url: string;
|
|
27
|
+
method?: string;
|
|
28
|
+
headers?: Record<string, string>;
|
|
29
|
+
secret?: string;
|
|
30
|
+
timeoutMs?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface AutoEnqueuerOptions {
|
|
34
|
+
/**
|
|
35
|
+
* Object name holding webhook subscriptions. Defaults to `sys_webhook`,
|
|
36
|
+
* the platform-objects schema authored in apps.
|
|
37
|
+
*/
|
|
38
|
+
subscriptionsObject?: string;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Periodic full-cache refresh interval (ms). Belt-and-braces in case
|
|
42
|
+
* the subscription-change event is missed. Default 60s.
|
|
43
|
+
*/
|
|
44
|
+
refreshIntervalMs?: number;
|
|
45
|
+
|
|
46
|
+
logger?: OptionalLogger;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Bridge between `IRealtimeService` (`data.record.*` events emitted by
|
|
51
|
+
* the engine) and `IWebhookOutbox` (durable delivery rows the dispatcher
|
|
52
|
+
* picks up).
|
|
53
|
+
*
|
|
54
|
+
* ## Why a separate class
|
|
55
|
+
* Keeps `WebhookOutboxPlugin` lean: the plugin wires services, this
|
|
56
|
+
* class owns the runtime fan-out logic + subscription cache.
|
|
57
|
+
*
|
|
58
|
+
* ## Hot path
|
|
59
|
+
* Every `engine.insert/update/delete` fires a `data.record.*` event.
|
|
60
|
+
* The handler:
|
|
61
|
+
* 1. Looks up matching subscriptions in an in-memory `Map<object, sub[]>`
|
|
62
|
+
* — O(1) per event, no DB hit on the write path.
|
|
63
|
+
* 2. Calls `outbox.enqueue()` fire-and-forget for each match. The
|
|
64
|
+
* enqueue itself is a single INSERT, which runs *after* the user's
|
|
65
|
+
* request has already returned.
|
|
66
|
+
*
|
|
67
|
+
* Net cost on the write path: one synchronous Map lookup (~microseconds).
|
|
68
|
+
*
|
|
69
|
+
* ## Cache freshness
|
|
70
|
+
* The cache is rebuilt:
|
|
71
|
+
* 1. Once on `start()`.
|
|
72
|
+
* 2. On every `data.record.{created,updated,deleted}` event whose
|
|
73
|
+
* object is `sys_webhook` (self-healing — when a user toggles a
|
|
74
|
+
* webhook, the handler refreshes the cache before returning).
|
|
75
|
+
* 3. Periodically (default 60s) as belt-and-braces.
|
|
76
|
+
*
|
|
77
|
+
* For multi-node clusters this is *eventually consistent* — node B may
|
|
78
|
+
* not see node A's edit for up to one cycle. That's acceptable for
|
|
79
|
+
* webhook configuration changes (humans don't expect millisecond
|
|
80
|
+
* propagation) and matches Hasura's behaviour.
|
|
81
|
+
*
|
|
82
|
+
* ## Determinism
|
|
83
|
+
* `eventId` is computed from `${object}:${recordId}:${type}:${timestamp}`
|
|
84
|
+
* so the outbox dedup index catches duplicates that could arise from
|
|
85
|
+
* upstream replay or buggy producers — and is stable across nodes.
|
|
86
|
+
*/
|
|
87
|
+
export class AutoEnqueuer {
|
|
88
|
+
private readonly subscriptions = new Map<string, CachedSubscription[]>();
|
|
89
|
+
private readonly subscriptionsObject: string;
|
|
90
|
+
private readonly refreshIntervalMs: number;
|
|
91
|
+
private readonly logger: OptionalLogger;
|
|
92
|
+
private subId: string | undefined;
|
|
93
|
+
private subIdSelfHeal: string | undefined;
|
|
94
|
+
private refreshTimer: ReturnType<typeof setInterval> | undefined;
|
|
95
|
+
private running = false;
|
|
96
|
+
private refreshing: Promise<void> | undefined;
|
|
97
|
+
|
|
98
|
+
constructor(
|
|
99
|
+
private readonly engine: IDataEngine,
|
|
100
|
+
private readonly realtime: IRealtimeService,
|
|
101
|
+
private readonly outbox: IWebhookOutbox,
|
|
102
|
+
opts: AutoEnqueuerOptions = {},
|
|
103
|
+
) {
|
|
104
|
+
this.subscriptionsObject = opts.subscriptionsObject ?? 'sys_webhook';
|
|
105
|
+
this.refreshIntervalMs = opts.refreshIntervalMs ?? 60_000;
|
|
106
|
+
this.logger = opts.logger ?? {};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Load the subscription cache and start listening for events.
|
|
111
|
+
*/
|
|
112
|
+
async start(): Promise<void> {
|
|
113
|
+
if (this.running) return;
|
|
114
|
+
this.running = true;
|
|
115
|
+
|
|
116
|
+
await this.refresh();
|
|
117
|
+
|
|
118
|
+
// Main subscription: every data event → match → enqueue.
|
|
119
|
+
this.subId = await this.realtime.subscribe(
|
|
120
|
+
'webhook-auto-enqueuer',
|
|
121
|
+
(event) => this.handleEvent(event),
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
// Self-healing: any change to sys_webhook refreshes the cache.
|
|
125
|
+
this.subIdSelfHeal = await this.realtime.subscribe(
|
|
126
|
+
'webhook-auto-enqueuer-self-heal',
|
|
127
|
+
(event) => this.handleSelfHealEvent(event),
|
|
128
|
+
{ object: this.subscriptionsObject },
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
if (this.refreshIntervalMs > 0) {
|
|
132
|
+
this.refreshTimer = setInterval(() => {
|
|
133
|
+
this.refresh().catch((err) =>
|
|
134
|
+
this.logger.warn?.('[webhook-auto-enqueuer] periodic refresh failed', err),
|
|
135
|
+
);
|
|
136
|
+
}, this.refreshIntervalMs);
|
|
137
|
+
// Don't keep the process alive solely for this timer.
|
|
138
|
+
this.refreshTimer.unref?.();
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async stop(): Promise<void> {
|
|
143
|
+
if (!this.running) return;
|
|
144
|
+
this.running = false;
|
|
145
|
+
if (this.subId) await this.realtime.unsubscribe(this.subId);
|
|
146
|
+
if (this.subIdSelfHeal) await this.realtime.unsubscribe(this.subIdSelfHeal);
|
|
147
|
+
if (this.refreshTimer) clearInterval(this.refreshTimer);
|
|
148
|
+
this.subId = undefined;
|
|
149
|
+
this.subIdSelfHeal = undefined;
|
|
150
|
+
this.refreshTimer = undefined;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Force-refresh the subscription cache from storage. Concurrent
|
|
155
|
+
* callers share a single in-flight refresh.
|
|
156
|
+
*/
|
|
157
|
+
async refresh(): Promise<void> {
|
|
158
|
+
if (this.refreshing) return this.refreshing;
|
|
159
|
+
this.refreshing = this.doRefresh().finally(() => {
|
|
160
|
+
this.refreshing = undefined;
|
|
161
|
+
});
|
|
162
|
+
return this.refreshing;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
private async doRefresh(): Promise<void> {
|
|
166
|
+
let rows: any[];
|
|
167
|
+
try {
|
|
168
|
+
rows = await this.engine.find(this.subscriptionsObject, {
|
|
169
|
+
where: { active: true },
|
|
170
|
+
});
|
|
171
|
+
} catch (err) {
|
|
172
|
+
this.logger.warn?.(
|
|
173
|
+
`[webhook-auto-enqueuer] failed to load ${this.subscriptionsObject}`,
|
|
174
|
+
err,
|
|
175
|
+
);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const next = new Map<string, CachedSubscription[]>();
|
|
180
|
+
for (const row of rows) {
|
|
181
|
+
const sub = this.parseRow(row);
|
|
182
|
+
if (!sub) continue;
|
|
183
|
+
// Empty objectName == "any object" → indexed under '*'.
|
|
184
|
+
const key = sub.objectName ?? '*';
|
|
185
|
+
const arr = next.get(key) ?? [];
|
|
186
|
+
arr.push(sub);
|
|
187
|
+
next.set(key, arr);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
this.subscriptions.clear();
|
|
191
|
+
for (const [k, v] of next) this.subscriptions.set(k, v);
|
|
192
|
+
|
|
193
|
+
this.logger.debug?.('[webhook-auto-enqueuer] cache refreshed', {
|
|
194
|
+
objects: this.subscriptions.size,
|
|
195
|
+
rows: rows.length,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private parseRow(row: any): CachedSubscription | null {
|
|
200
|
+
if (!row?.id || !row?.url) return null;
|
|
201
|
+
const triggersField = (row.triggers ?? '') as string;
|
|
202
|
+
const triggers = new Set(
|
|
203
|
+
triggersField
|
|
204
|
+
.split(',')
|
|
205
|
+
.map((s: string) => s.trim().toLowerCase())
|
|
206
|
+
.filter(Boolean) as Array<'create' | 'update' | 'delete' | 'undelete'>,
|
|
207
|
+
);
|
|
208
|
+
if (triggers.size === 0) {
|
|
209
|
+
// Manual-only webhook (no triggers) — skip auto-enqueue.
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// The "definition_json" field carries advanced config (headers,
|
|
214
|
+
// secret, timeout); attempt a best-effort parse. Fall back to
|
|
215
|
+
// top-level fields where present.
|
|
216
|
+
let defn: Record<string, any> = {};
|
|
217
|
+
if (typeof row.definition_json === 'string' && row.definition_json.length > 0) {
|
|
218
|
+
try {
|
|
219
|
+
defn = JSON.parse(row.definition_json) ?? {};
|
|
220
|
+
} catch {
|
|
221
|
+
defn = {};
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return {
|
|
226
|
+
id: row.id as string,
|
|
227
|
+
name: (row.name as string) ?? row.id,
|
|
228
|
+
objectName: row.object_name ? String(row.object_name) : undefined,
|
|
229
|
+
triggers,
|
|
230
|
+
url: String(row.url),
|
|
231
|
+
method: row.method ?? defn.method ?? 'POST',
|
|
232
|
+
headers: defn.headers,
|
|
233
|
+
secret: defn.secret,
|
|
234
|
+
timeoutMs: defn.timeoutMs,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Handler for the firehose subscription.
|
|
240
|
+
*
|
|
241
|
+
* NOTE: we intentionally `void` the inner enqueue() so the realtime
|
|
242
|
+
* publisher (and therefore the user's request) is never blocked on
|
|
243
|
+
* webhook persistence.
|
|
244
|
+
*/
|
|
245
|
+
private handleEvent(event: RealtimeEventPayload): void {
|
|
246
|
+
if (!event.type?.startsWith('data.record.')) return;
|
|
247
|
+
if (!event.object) return;
|
|
248
|
+
if (event.object === this.subscriptionsObject) return; // self-heal handles its own
|
|
249
|
+
|
|
250
|
+
const action = event.type.slice('data.record.'.length) as
|
|
251
|
+
| 'created' | 'updated' | 'deleted' | 'undeleted' | string;
|
|
252
|
+
const trigger = mapActionToTrigger(action);
|
|
253
|
+
if (!trigger) return;
|
|
254
|
+
|
|
255
|
+
const subs = [
|
|
256
|
+
...(this.subscriptions.get(event.object) ?? []),
|
|
257
|
+
...(this.subscriptions.get('*') ?? []),
|
|
258
|
+
];
|
|
259
|
+
if (subs.length === 0) return;
|
|
260
|
+
|
|
261
|
+
const payload = event.payload ?? {};
|
|
262
|
+
const recordId =
|
|
263
|
+
(payload as any).recordId ??
|
|
264
|
+
(payload as any).id ??
|
|
265
|
+
(payload as any).after?.id ??
|
|
266
|
+
(payload as any).before?.id ??
|
|
267
|
+
'unknown';
|
|
268
|
+
|
|
269
|
+
// Deterministic eventId — same input on any node → same id.
|
|
270
|
+
// Includes timestamp so two distinct updates to the same record
|
|
271
|
+
// don't accidentally dedup.
|
|
272
|
+
const eventId = `${event.object}:${recordId}:${action}:${event.timestamp}`;
|
|
273
|
+
|
|
274
|
+
for (const sub of subs) {
|
|
275
|
+
if (!sub.triggers.has(trigger)) continue;
|
|
276
|
+
|
|
277
|
+
// Fire-and-forget — never await on the hot path.
|
|
278
|
+
void this.outbox
|
|
279
|
+
.enqueue({
|
|
280
|
+
webhookId: sub.id,
|
|
281
|
+
eventId,
|
|
282
|
+
eventType: event.type,
|
|
283
|
+
url: sub.url,
|
|
284
|
+
method: sub.method,
|
|
285
|
+
headers: sub.headers,
|
|
286
|
+
secret: sub.secret,
|
|
287
|
+
timeoutMs: sub.timeoutMs,
|
|
288
|
+
payload: {
|
|
289
|
+
object: event.object,
|
|
290
|
+
recordId,
|
|
291
|
+
action,
|
|
292
|
+
timestamp: event.timestamp,
|
|
293
|
+
...payload,
|
|
294
|
+
},
|
|
295
|
+
})
|
|
296
|
+
.catch((err) =>
|
|
297
|
+
this.logger.warn?.('[webhook-auto-enqueuer] enqueue failed', {
|
|
298
|
+
webhook: sub.name,
|
|
299
|
+
eventId,
|
|
300
|
+
err: (err as Error)?.message ?? err,
|
|
301
|
+
}),
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
private handleSelfHealEvent(event: RealtimeEventPayload): void {
|
|
307
|
+
if (event.object !== this.subscriptionsObject) return;
|
|
308
|
+
if (!event.type?.startsWith('data.record.')) return;
|
|
309
|
+
this.refresh().catch((err) =>
|
|
310
|
+
this.logger.warn?.('[webhook-auto-enqueuer] self-heal refresh failed', err),
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** Test / admin accessor. */
|
|
315
|
+
snapshot(): ReadonlyMap<string, ReadonlyArray<CachedSubscription>> {
|
|
316
|
+
return this.subscriptions;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function mapActionToTrigger(
|
|
321
|
+
action: string,
|
|
322
|
+
): 'create' | 'update' | 'delete' | 'undelete' | null {
|
|
323
|
+
switch (action) {
|
|
324
|
+
case 'created':
|
|
325
|
+
return 'create';
|
|
326
|
+
case 'updated':
|
|
327
|
+
return 'update';
|
|
328
|
+
case 'deleted':
|
|
329
|
+
return 'delete';
|
|
330
|
+
case 'undeleted':
|
|
331
|
+
return 'undelete';
|
|
332
|
+
default:
|
|
333
|
+
return null;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Cross-node webhook dispatcher contract test.
|
|
5
|
+
*
|
|
6
|
+
* Builds two `WebhookDispatcher` instances that share one in-memory outbox
|
|
7
|
+
* AND one cluster `ILock`/`IPubSub` (simulating two nodes sharing one
|
|
8
|
+
* Redis/Postgres). Asserts:
|
|
9
|
+
*
|
|
10
|
+
* 1. Every enqueued delivery is POSTed *exactly once* (no double-fire).
|
|
11
|
+
* 2. Work is distributed across both nodes (no starvation).
|
|
12
|
+
* 3. 5xx responses are retried per the Stripe-style schedule.
|
|
13
|
+
* 4. 4xx (permanent) responses go straight to `dead`.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { describe, expect, it } from 'vitest';
|
|
17
|
+
import {
|
|
18
|
+
ComposedClusterService,
|
|
19
|
+
MemoryCounter,
|
|
20
|
+
MemoryKV,
|
|
21
|
+
MemoryLock,
|
|
22
|
+
MemoryPubSub,
|
|
23
|
+
} from '@objectstack/service-cluster';
|
|
24
|
+
import type { IClusterService } from '@objectstack/spec/contracts';
|
|
25
|
+
import { WebhookDispatcher } from './dispatcher.js';
|
|
26
|
+
import type { FetchImpl } from './http-sender.js';
|
|
27
|
+
import { MemoryWebhookOutbox } from './memory-outbox.js';
|
|
28
|
+
import { hashPartition } from './partition.js';
|
|
29
|
+
|
|
30
|
+
interface SharedCluster {
|
|
31
|
+
nodeA: IClusterService;
|
|
32
|
+
nodeB: IClusterService;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function makeSharedCluster(): SharedCluster {
|
|
36
|
+
// ONE lock + pubsub shared by both "nodes" — this is what makes the test
|
|
37
|
+
// a realistic cross-node simulation.
|
|
38
|
+
const lock = new MemoryLock();
|
|
39
|
+
const pubsub = new MemoryPubSub();
|
|
40
|
+
const kv = new MemoryKV();
|
|
41
|
+
const counter = new MemoryCounter();
|
|
42
|
+
return {
|
|
43
|
+
nodeA: new ComposedClusterService('node-A', 'memory', pubsub, lock, kv, counter),
|
|
44
|
+
nodeB: new ComposedClusterService('node-B', 'memory', pubsub, lock, kv, counter),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function makeFetchImpl(opts: {
|
|
49
|
+
status?: number;
|
|
50
|
+
log?: { url: string; deliveryId: string }[];
|
|
51
|
+
}): FetchImpl {
|
|
52
|
+
const status = opts.status ?? 200;
|
|
53
|
+
return async (url, init) => {
|
|
54
|
+
opts.log?.push({
|
|
55
|
+
url,
|
|
56
|
+
deliveryId: init.headers['X-Objectstack-Delivery'] ?? '',
|
|
57
|
+
});
|
|
58
|
+
return {
|
|
59
|
+
ok: status >= 200 && status < 300,
|
|
60
|
+
status,
|
|
61
|
+
async text() {
|
|
62
|
+
return '';
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function flushTicks(...dispatchers: WebhookDispatcher[]): Promise<void> {
|
|
69
|
+
// Run several rounds with the nodes ticking *concurrently* so they
|
|
70
|
+
// genuinely contend for the cluster lock — sequential ticks would let
|
|
71
|
+
// whichever node ran first drain every partition.
|
|
72
|
+
for (let round = 0; round < 6; round++) {
|
|
73
|
+
await Promise.all(dispatchers.map((d) => d.tick()));
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
describe('WebhookDispatcher cross-node', () => {
|
|
78
|
+
it('exactly-once: 50 deliveries across 2 nodes → 50 POSTs total', async () => {
|
|
79
|
+
const cluster = makeSharedCluster();
|
|
80
|
+
const outbox = new MemoryWebhookOutbox();
|
|
81
|
+
const log: { url: string; deliveryId: string }[] = [];
|
|
82
|
+
const fetchImpl = makeFetchImpl({ status: 200, log });
|
|
83
|
+
|
|
84
|
+
const partitionCount = 4;
|
|
85
|
+
const a = new WebhookDispatcher({
|
|
86
|
+
nodeId: 'node-A',
|
|
87
|
+
cluster: cluster.nodeA,
|
|
88
|
+
outbox,
|
|
89
|
+
fetchImpl,
|
|
90
|
+
partitionCount,
|
|
91
|
+
intervalMs: 1_000_000, // disable timer; we drive with tick()
|
|
92
|
+
});
|
|
93
|
+
const b = new WebhookDispatcher({
|
|
94
|
+
nodeId: 'node-B',
|
|
95
|
+
cluster: cluster.nodeB,
|
|
96
|
+
outbox,
|
|
97
|
+
fetchImpl,
|
|
98
|
+
partitionCount,
|
|
99
|
+
intervalMs: 1_000_000,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
for (let i = 0; i < 50; i++) {
|
|
103
|
+
await outbox.enqueue({
|
|
104
|
+
webhookId: `wh-${i % 10}`, // 10 webhooks → spread across 4 partitions
|
|
105
|
+
eventId: `evt-${i}`,
|
|
106
|
+
eventType: 'data.record.created',
|
|
107
|
+
url: `https://example.test/${i}`,
|
|
108
|
+
payload: { i },
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
await flushTicks(a, b);
|
|
113
|
+
|
|
114
|
+
expect(log).toHaveLength(50);
|
|
115
|
+
const uniqueIds = new Set(log.map((l) => l.deliveryId));
|
|
116
|
+
expect(uniqueIds.size).toBe(50);
|
|
117
|
+
|
|
118
|
+
const success = await outbox.list({ status: 'success' });
|
|
119
|
+
expect(success).toHaveLength(50);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('partition affinity: dispatcher only claims rows for partitions it locked', async () => {
|
|
123
|
+
const cluster = makeSharedCluster();
|
|
124
|
+
const outbox = new MemoryWebhookOutbox();
|
|
125
|
+
const partitionCount = 8;
|
|
126
|
+
|
|
127
|
+
// For each attempt, record (nodeId, partitionForWebhook).
|
|
128
|
+
const observed: { nodeId: string; partition: number }[] = [];
|
|
129
|
+
|
|
130
|
+
const make = (nodeId: string, c: IClusterService) =>
|
|
131
|
+
new WebhookDispatcher({
|
|
132
|
+
nodeId,
|
|
133
|
+
cluster: c,
|
|
134
|
+
outbox,
|
|
135
|
+
fetchImpl: makeFetchImpl({ status: 200 }),
|
|
136
|
+
partitionCount,
|
|
137
|
+
intervalMs: 1_000_000,
|
|
138
|
+
onAttempt: (delivery) => {
|
|
139
|
+
observed.push({
|
|
140
|
+
nodeId,
|
|
141
|
+
partition: hashPartition(delivery.webhookId, partitionCount),
|
|
142
|
+
});
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
const a = make('node-A', cluster.nodeA);
|
|
147
|
+
const b = make('node-B', cluster.nodeB);
|
|
148
|
+
|
|
149
|
+
for (let i = 0; i < 30; i++) {
|
|
150
|
+
await outbox.enqueue({
|
|
151
|
+
webhookId: `wh-${i % 5}`,
|
|
152
|
+
eventId: `evt-${i}`,
|
|
153
|
+
eventType: 't',
|
|
154
|
+
url: 'https://example.test/x',
|
|
155
|
+
payload: { i },
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
await flushTicks(a, b);
|
|
159
|
+
|
|
160
|
+
expect(observed).toHaveLength(30);
|
|
161
|
+
// Each row's partition came from hash(webhookId, 8) — only 5 distinct
|
|
162
|
+
// webhook ids → at most 5 distinct partitions.
|
|
163
|
+
const partitionsTouched = new Set(observed.map((o) => o.partition));
|
|
164
|
+
expect(partitionsTouched.size).toBeLessThanOrEqual(5);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('load distribution: both nodes process some rows', async () => {
|
|
168
|
+
const cluster = makeSharedCluster();
|
|
169
|
+
const outbox = new MemoryWebhookOutbox();
|
|
170
|
+
const partitionCount = 8;
|
|
171
|
+
const counts: Record<string, number> = { 'node-A': 0, 'node-B': 0 };
|
|
172
|
+
|
|
173
|
+
const make = (nodeId: string, c: IClusterService) =>
|
|
174
|
+
new WebhookDispatcher({
|
|
175
|
+
nodeId,
|
|
176
|
+
cluster: c,
|
|
177
|
+
outbox,
|
|
178
|
+
fetchImpl: makeFetchImpl({ status: 200 }),
|
|
179
|
+
partitionCount,
|
|
180
|
+
intervalMs: 1_000_000,
|
|
181
|
+
onAttempt: () => {
|
|
182
|
+
counts[nodeId] += 1;
|
|
183
|
+
},
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
const a = make('node-A', cluster.nodeA);
|
|
187
|
+
const b = make('node-B', cluster.nodeB);
|
|
188
|
+
|
|
189
|
+
// Lots of distinct webhookIds → spreads work across many partitions.
|
|
190
|
+
for (let i = 0; i < 200; i++) {
|
|
191
|
+
await outbox.enqueue({
|
|
192
|
+
webhookId: `wh-${i}`,
|
|
193
|
+
eventId: `evt-${i}`,
|
|
194
|
+
eventType: 't',
|
|
195
|
+
url: 'https://example.test/x',
|
|
196
|
+
payload: { i },
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
await flushTicks(a, b);
|
|
200
|
+
|
|
201
|
+
// Each node should have processed at least one row — proving the
|
|
202
|
+
// rotation/offset isn't pinning all work to node A.
|
|
203
|
+
expect(counts['node-A']).toBeGreaterThan(0);
|
|
204
|
+
expect(counts['node-B']).toBeGreaterThan(0);
|
|
205
|
+
expect(counts['node-A'] + counts['node-B']).toBe(200);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('5xx is retried: row stays pending with future nextRetryAt', async () => {
|
|
209
|
+
const cluster = makeSharedCluster();
|
|
210
|
+
const outbox = new MemoryWebhookOutbox();
|
|
211
|
+
const a = new WebhookDispatcher({
|
|
212
|
+
nodeId: 'node-A',
|
|
213
|
+
cluster: cluster.nodeA,
|
|
214
|
+
outbox,
|
|
215
|
+
fetchImpl: makeFetchImpl({ status: 503 }),
|
|
216
|
+
partitionCount: 1,
|
|
217
|
+
intervalMs: 1_000_000,
|
|
218
|
+
rng: () => 0.5,
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
await outbox.enqueue({
|
|
222
|
+
webhookId: 'wh-x',
|
|
223
|
+
eventId: 'evt-x',
|
|
224
|
+
eventType: 't',
|
|
225
|
+
url: 'https://example.test/fail',
|
|
226
|
+
payload: {},
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
await a.tick();
|
|
230
|
+
const rows = await outbox.list();
|
|
231
|
+
expect(rows[0].status).toBe('pending');
|
|
232
|
+
expect(rows[0].attempts).toBe(1);
|
|
233
|
+
expect(rows[0].nextRetryAt).toBeGreaterThan(Date.now());
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('4xx is permanent: row moves to dead', async () => {
|
|
237
|
+
const cluster = makeSharedCluster();
|
|
238
|
+
const outbox = new MemoryWebhookOutbox();
|
|
239
|
+
const a = new WebhookDispatcher({
|
|
240
|
+
nodeId: 'node-A',
|
|
241
|
+
cluster: cluster.nodeA,
|
|
242
|
+
outbox,
|
|
243
|
+
fetchImpl: makeFetchImpl({ status: 404 }),
|
|
244
|
+
partitionCount: 1,
|
|
245
|
+
intervalMs: 1_000_000,
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
await outbox.enqueue({
|
|
249
|
+
webhookId: 'wh-x',
|
|
250
|
+
eventId: 'evt-x',
|
|
251
|
+
eventType: 't',
|
|
252
|
+
url: 'https://example.test/missing',
|
|
253
|
+
payload: {},
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
await a.tick();
|
|
257
|
+
const dead = await outbox.list({ status: 'dead' });
|
|
258
|
+
expect(dead).toHaveLength(1);
|
|
259
|
+
expect(dead[0].responseCode).toBe(404);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('dedup: identical (eventId, webhookId) enqueues collapse to one row', async () => {
|
|
263
|
+
const outbox = new MemoryWebhookOutbox();
|
|
264
|
+
const id1 = await outbox.enqueue({
|
|
265
|
+
webhookId: 'wh-1',
|
|
266
|
+
eventId: 'evt-dup',
|
|
267
|
+
eventType: 't',
|
|
268
|
+
url: 'https://example.test/',
|
|
269
|
+
payload: {},
|
|
270
|
+
});
|
|
271
|
+
const id2 = await outbox.enqueue({
|
|
272
|
+
webhookId: 'wh-1',
|
|
273
|
+
eventId: 'evt-dup',
|
|
274
|
+
eventType: 't',
|
|
275
|
+
url: 'https://example.test/',
|
|
276
|
+
payload: {},
|
|
277
|
+
});
|
|
278
|
+
expect(id1).toBe(id2);
|
|
279
|
+
const rows = await outbox.list();
|
|
280
|
+
expect(rows).toHaveLength(1);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it('lock prevents same partition being claimed twice in a tick', async () => {
|
|
284
|
+
const cluster = makeSharedCluster();
|
|
285
|
+
const outbox = new MemoryWebhookOutbox();
|
|
286
|
+
const log: { url: string; deliveryId: string }[] = [];
|
|
287
|
+
const fetchImpl = makeFetchImpl({ status: 200, log });
|
|
288
|
+
|
|
289
|
+
// Single partition → both nodes contend for the same lock.
|
|
290
|
+
const a = new WebhookDispatcher({
|
|
291
|
+
nodeId: 'node-A',
|
|
292
|
+
cluster: cluster.nodeA,
|
|
293
|
+
outbox,
|
|
294
|
+
fetchImpl,
|
|
295
|
+
partitionCount: 1,
|
|
296
|
+
intervalMs: 1_000_000,
|
|
297
|
+
});
|
|
298
|
+
const b = new WebhookDispatcher({
|
|
299
|
+
nodeId: 'node-B',
|
|
300
|
+
cluster: cluster.nodeB,
|
|
301
|
+
outbox,
|
|
302
|
+
fetchImpl,
|
|
303
|
+
partitionCount: 1,
|
|
304
|
+
intervalMs: 1_000_000,
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
for (let i = 0; i < 5; i++) {
|
|
308
|
+
await outbox.enqueue({
|
|
309
|
+
webhookId: 'wh-1',
|
|
310
|
+
eventId: `evt-${i}`,
|
|
311
|
+
eventType: 't',
|
|
312
|
+
url: 'https://example.test/',
|
|
313
|
+
payload: { i },
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Fire both ticks "simultaneously" — only one should claim the partition.
|
|
318
|
+
await Promise.all([a.tick(), b.tick()]);
|
|
319
|
+
|
|
320
|
+
expect(log).toHaveLength(5);
|
|
321
|
+
const uniqueIds = new Set(log.map((l) => l.deliveryId));
|
|
322
|
+
expect(uniqueIds.size).toBe(5);
|
|
323
|
+
});
|
|
324
|
+
});
|