diff --git a/docs/current/SPECIFICATION.md b/docs/current/SPECIFICATION.md index a7ebc6b..5b2b0ad 100644 --- a/docs/current/SPECIFICATION.md +++ b/docs/current/SPECIFICATION.md @@ -673,6 +673,63 @@ conditional requests (§3.2.1), compression (§3.2.2) and subscription filters (§5.1.3) first: those remove whole responses and whole deliveries rather than shrinking them. +### 5.2.2 Batched delivery (normative) + +§5.2 mandates one event per `POST`. A high-frequency entity therefore pays a +TLS handshake, a signature computation and a 10-second acknowledgement +round-trip **per event** — the dominant per-delivery cost, and one that does +not shrink with a smaller payload. + +Publishers MAY offer batching; subscribers opt in with `max_batch_size` on the +subscription request. The wire format is the CloudEvents batched JSON +encoding, so this is a media type change rather than an EEP invention: + +```http +POST /hooks/eep +content-type: application/cloudevents-batch+json +webhook-id: msg_batch_01HN3QK7GX +webhook-timestamp: 1708123456 +webhook-signature: v1,… + +[ { "specversion": "1.0", "id": "evt-1", … }, { "specversion": "1.0", "id": "evt-2", … } ] +``` + +- The body is a JSON array of envelopes. A batch MUST NOT be empty. +- `max_batch_size` bounds the array. `1`, or omitted, preserves today's + one-event-per-POST behaviour exactly. +- A subscription that opted into batching MUST always receive + `application/cloudevents-batch+json`, even when only one event is ready. + Switching format based on how many events happened to be available would + give the subscriber two parsing paths and no way to predict which it gets. +- `max_batch_wait_ms` bounds the latency batching adds: the publisher MUST + deliver once it elapses even if the batch is not full. Without it, a + low-traffic subscription could hold an event indefinitely waiting for a + batch that never fills. +- Events within a batch MUST be ordered as they were emitted for a given + `source`. +- All events in one batch MUST belong to one subscription. Batching is a + transport optimisation and MUST NOT become a way to deliver events across + subscription boundaries. + +**Signing.** The signature covers the raw body — the whole array — exactly as +in §5.3. The `webhook-id` identifies the *batch*, not any single event. + +**Acknowledgement is all-or-nothing.** A `2xx` acknowledges every event in the +batch; any other response fails the whole batch, which is retried whole. A +subscriber that cannot process one event in a batch MUST NOT return `2xx` and +then drop it. This is why batching is opt-in: a subscriber must be able to +process a batch atomically, or ask for `max_batch_size: 1`. + +**Deduplication is per event, not per batch.** Each envelope keeps its own +`id`, so the idempotency rule in +[delivery_guarantees.md](./delivery_guarantees.md) §6 applies unchanged: on a +retry a subscriber discards the events it already processed and processes the +rest. + +**Failure accounting.** A failed batch counts as **one** failed delivery +against the §10 counter, not one per event — otherwise a single failure of a +100-event batch would pause a subscription instantly. + ### 5.3 Webhook signature verification The `webhook-signature` header contains an HMAC-SHA256 signature over the concatenation of: diff --git a/packages/@eep-dev/middleware/src/core/eep-server.ts b/packages/@eep-dev/middleware/src/core/eep-server.ts index bb00ea6..630ade2 100644 --- a/packages/@eep-dev/middleware/src/core/eep-server.ts +++ b/packages/@eep-dev/middleware/src/core/eep-server.ts @@ -430,6 +430,14 @@ export class EEPServer { failure_count: 0, expires_at: expiresAt.toISOString(), ...(filter ? { filter } : {}), + ...(typeof body.max_batch_size === "number" && body.max_batch_size > 1 + ? { + max_batch_size: Math.min(Math.floor(body.max_batch_size), 500), + ...(typeof body.max_batch_wait_ms === "number" + ? { max_batch_wait_ms: Math.max(0, Math.min(Math.floor(body.max_batch_wait_ms), 30_000)) } + : {}) + } + : {}), ...(body.delivery_format === "cloudevents/v1.0-binary" ? { delivery_format: "cloudevents/v1.0-binary" as const } : {}), diff --git a/packages/@eep-dev/middleware/src/core/request-handler.ts b/packages/@eep-dev/middleware/src/core/request-handler.ts index 257e127..7f3ea63 100644 --- a/packages/@eep-dev/middleware/src/core/request-handler.ts +++ b/packages/@eep-dev/middleware/src/core/request-handler.ts @@ -94,6 +94,13 @@ export type SubscriptionRecord = { * Defaults to structured. */ delivery_format?: DeliveryFormat; + /** + * Maximum events combined into one delivery (SPECIFICATION.md §5.2.2). + * 1 or absent preserves one-event-per-POST. + */ + max_batch_size?: number; + /** Milliseconds the publisher may hold an event waiting for the batch to fill. */ + max_batch_wait_ms?: number; /** Requested access tier; matched against gate config on delivery. */ tier?: string; created_at: string; diff --git a/packages/@eep-dev/middleware/src/dispatcher/content-mode.ts b/packages/@eep-dev/middleware/src/dispatcher/content-mode.ts index ca9ceeb..470c075 100644 --- a/packages/@eep-dev/middleware/src/dispatcher/content-mode.ts +++ b/packages/@eep-dev/middleware/src/dispatcher/content-mode.ts @@ -66,3 +66,21 @@ export function renderDelivery(event: CloudEvent, format: DeliveryFormat | undef return { body, headers }; } + +/** CloudEvents batched JSON media type (SPECIFICATION.md §5.2.2). */ +export const BATCH_CONTENT_TYPE = "application/cloudevents-batch+json"; + +/** + * Render a batch of events as one delivery. + * + * Batching is structured-mode only: binary content mode maps attributes to + * headers, and a batch has many events with different attribute values, so + * there is nowhere for them to go. A subscriber asking for both gets + * structured batches. + */ +export function renderBatch(events: CloudEvent[]): RenderedDelivery { + return { + body: JSON.stringify(events), + headers: { "content-type": BATCH_CONTENT_TYPE }, + }; +} diff --git a/packages/@eep-dev/middleware/src/dispatcher/webhook-dispatcher.test.ts b/packages/@eep-dev/middleware/src/dispatcher/webhook-dispatcher.test.ts index ac582ac..25be2a2 100644 --- a/packages/@eep-dev/middleware/src/dispatcher/webhook-dispatcher.test.ts +++ b/packages/@eep-dev/middleware/src/dispatcher/webhook-dispatcher.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { EEPSigner, generateSigningKeyPair, verifyEd25519 } from "@eep-dev/signer"; import { WebhookDispatcher, DEFAULT_RETRY_SCHEDULE_MS, type WebhookHttpClient } from "./webhook-dispatcher.js"; +import { BATCH_CONTENT_TYPE } from "./content-mode.js"; import { InMemoryDBAdapter } from "../db/in-memory.js"; import { InMemoryEventBusAdapter } from "../event-bus/in-memory.js"; import { TEST_DELIVERY_EVENT_TYPE } from "../core/request-handler.js"; @@ -59,6 +60,121 @@ describe("WebhookDispatcher", () => { expect(DEFAULT_RETRY_SCHEDULE_MS).toEqual([0, 5_000, 30_000, 120_000, 900_000, 3_600_000, 21_600_000]); }); + // SPECIFICATION.md §5.2.2 — one event per POST means a high-frequency + // entity pays a TLS handshake, a signature and a 10s ack round-trip per + // event. Batching amortises all three. + describe("batched delivery (§5.2.2)", () => { + const setup = async (maxBatchSize: number, script: Array = [200]) => { + const db = new InMemoryDBAdapter(); + await db.saveSubscription(subscription({ max_batch_size: maxBatchSize })); + const { client, calls } = mockClient(script); + const dispatcher = new WebhookDispatcher({ db, httpClient: client, retryScheduleMs: NO_DELAY }); + return { db, dispatcher, calls }; + }; + + const batch = (n: number) => Array.from({ length: n }, (_, i) => event({ id: `evt_${i + 1}` })); + + it("combines events into one POST with the CloudEvents batch media type", async () => { + const { dispatcher, calls } = await setup(10); + const results = await dispatcher.dispatchBatch(batch(3), "sub_1"); + + expect(calls).toHaveLength(1); + expect(calls[0]!.headers["content-type"]).toBe(BATCH_CONTENT_TYPE); + expect(JSON.parse(calls[0]!.body)).toHaveLength(3); + // One result per event, so callers still see per-event outcomes. + expect(results).toHaveLength(3); + expect(results.every((r) => r.delivered)).toBe(true); + }); + + it("keeps each envelope's own id so per-event dedup still works", async () => { + const { dispatcher, calls } = await setup(10); + await dispatcher.dispatchBatch(batch(3), "sub_1"); + const sent = JSON.parse(calls[0]!.body) as Array<{ id: string }>; + expect(sent.map((e) => e.id)).toEqual(["evt_1", "evt_2", "evt_3"]); + }); + + it("signs the whole array once", async () => { + const { dispatcher, calls } = await setup(10); + await dispatcher.dispatchBatch(batch(3), "sub_1"); + const call = calls[0]!; + expect( + new EEPSigner(SECRET).verify( + call.headers["webhook-id"]!, + call.headers["webhook-timestamp"]!, + call.headers["webhook-signature"]!, + call.body + ) + ).toBe(true); + }); + + it("splits into several deliveries at max_batch_size", async () => { + const { dispatcher, calls } = await setup(2, [200, 200]); + const results = await dispatcher.dispatchBatch(batch(4), "sub_1"); + expect(calls).toHaveLength(2); + expect(JSON.parse(calls[0]!.body)).toHaveLength(2); + expect(JSON.parse(calls[1]!.body)).toHaveLength(2); + expect(results).toHaveLength(4); + }); + + // max_batch_size 1 must be byte-identical to the unbatched path. + it("sends a single event unbatched when max_batch_size is 1", async () => { + const { dispatcher, calls } = await setup(1); + await dispatcher.dispatchBatch(batch(1), "sub_1"); + expect(calls[0]!.headers["content-type"]).toBe("application/json"); + expect(JSON.parse(calls[0]!.body)).toMatchObject({ id: "evt_1" }); + }); + + it("applies the subscription filter before batching", async () => { + const db = new InMemoryDBAdapter(); + await db.saveSubscription( + subscription({ + max_batch_size: 10, + filter: { match: "all", conditions: [{ path: "id", op: "eq", value: "evt_2" }] } + }) + ); + const { client, calls } = mockClient([200]); + const dispatcher = new WebhookDispatcher({ db, httpClient: client, retryScheduleMs: NO_DELAY }); + await dispatcher.dispatchBatch(batch(3), "sub_1"); + const sent = JSON.parse(calls[0]!.body) as Array<{ id: string }>; + expect(sent).toHaveLength(1); + expect(sent[0]!.id).toBe("evt_2"); + // Still the batch media type: a subscriber that opted into batching gets + // one parsing path regardless of how many events were ready. + expect(calls[0]!.headers["content-type"]).toBe(BATCH_CONTENT_TYPE); + }); + + // A failed batch is ONE failed delivery, not one per event — otherwise a + // single failure of a large batch would pause a subscription instantly. + it("counts a failed batch as one failed delivery", async () => { + const db = new InMemoryDBAdapter(); + await db.saveSubscription(subscription({ max_batch_size: 10 })); + const { client } = mockClient(["throw"]); + const dispatcher = new WebhookDispatcher({ + db, + httpClient: client, + retryScheduleMs: NO_DELAY, + pauseAfterFailures: 2 + }); + + await dispatcher.dispatchBatch(batch(5), "sub_1"); + const after = await db.getSubscription("sub_1"); + expect(after?.failure_count).toBe(1); + expect(after?.status).toBe("active"); + }); + + it("delivers nothing for an unknown subscription", async () => { + const { dispatcher, calls } = await setup(10); + expect(await dispatcher.dispatchBatch(batch(2), "sub_missing")).toEqual([]); + expect(calls).toHaveLength(0); + }); + + it("delivers nothing for an empty batch", async () => { + const { dispatcher, calls } = await setup(10); + expect(await dispatcher.dispatchBatch([], "sub_1")).toEqual([]); + expect(calls).toHaveLength(0); + }); + }); + // SPECIFICATION.md §5.2.1 — binary content mode relocates attributes to // ce-* headers so a subscriber can route without parsing the body. describe("content modes (§5.2.1)", () => { diff --git a/packages/@eep-dev/middleware/src/dispatcher/webhook-dispatcher.ts b/packages/@eep-dev/middleware/src/dispatcher/webhook-dispatcher.ts index 5bea863..1ffbc47 100644 --- a/packages/@eep-dev/middleware/src/dispatcher/webhook-dispatcher.ts +++ b/packages/@eep-dev/middleware/src/dispatcher/webhook-dispatcher.ts @@ -3,7 +3,7 @@ import { matchesAnyPattern } from "@eep-dev/validator"; import { TEST_DELIVERY_EVENT_TYPE } from "../core/request-handler.js"; import type { EventStore } from "../core/event-store.js"; import { eventMatchesFilter } from "../core/event-filter.js"; -import { renderDelivery } from "./content-mode.js"; +import { renderDelivery, renderBatch } from "./content-mode.js"; import type { CloudEvent, DBAdapter, @@ -40,6 +40,9 @@ export const DEFAULT_PAUSE_AFTER_FAILURES = 5; * Per-attempt HTTP timeout. A response slower than this counts as a failure * (delivery_guarantees.md §2: "No response received within 10 seconds"). */ +/** Upper bound on `max_batch_size`, per SPECIFICATION.md §5.2.2. */ +export const MAX_BATCH_SIZE = 500; + export const DEFAULT_DELIVERY_TIMEOUT_MS = 10_000; /** Minimal HTTP response shape the dispatcher needs to judge an attempt. */ @@ -215,6 +218,49 @@ export class WebhookDispatcher { return Promise.all(targets.map((sub) => this.deliverWithRetry(event, sub))); } + /** + * Deliver several events to one subscription as a single batch (§5.2.2). + * + * Callers accumulate events per subscription — the accumulation policy + * (`max_batch_wait_ms`, flush triggers) belongs to the deployment's queue, + * not to an in-process dispatcher. This method is the delivery half: it + * enforces `max_batch_size`, signs the whole array once, and treats the + * acknowledgement as all-or-nothing. + * + * A failed batch counts as ONE failed delivery against the §10 counter, not + * one per event — otherwise a single failure of a 100-event batch would + * pause a subscription instantly. + */ + async dispatchBatch(events: CloudEvent[], subscriptionId: string): Promise { + if (events.length === 0) return []; + const sub = (await this.db.listSubscriptions()).find( + (s) => s.subscription_id === subscriptionId + ); + if (!sub) return []; + + const eligible = events.filter((event) => this.isTarget(sub, event)); + if (eligible.length === 0) return []; + + const limit = Math.max(1, Math.min(sub.max_batch_size ?? 1, MAX_BATCH_SIZE)); + + // A subscription that opted into batching always receives the batch media + // type, even when a chunk happens to hold one event. Switching format + // based on how many events happened to be ready would give the subscriber + // two parsing paths and no way to predict which it will get. + const batched = limit > 1; + + const results: DeliveryResult[] = []; + for (let i = 0; i < eligible.length; i += limit) { + const chunk = eligible.slice(i, i + limit); + results.push( + ...(batched + ? await this.deliverBatchWithRetry(chunk, sub) + : [await this.deliverWithRetry(chunk[0]!, sub)]) + ); + } + return results; + } + private isTarget(sub: SubscriptionRecord, event: CloudEvent): boolean { const deliverable = sub.delivery_method === "webhook" && @@ -256,6 +302,47 @@ export class WebhookDispatcher { return expiresAt <= Date.now(); } + private async deliverBatchWithRetry( + events: CloudEvent[], + sub: SubscriptionRecord + ): Promise { + // The batch is identified as a whole; each envelope keeps its own `id` so + // per-event deduplication on the subscriber side is unchanged. + const batchId = `msg_batch_${events[0]!.id}_${events.length}`; + let lastStatus: number | undefined; + + const outcomeFor = (delivered: boolean, attempts: number, aborted?: boolean): DeliveryResult[] => + events.map((event) => + this.report({ + subscription_id: sub.subscription_id, + event_id: event.id, + delivered, + attempts, + ...(lastStatus === undefined ? {} : { last_status: lastStatus }), + ...(aborted ? { aborted: true } : {}) + }) + ); + + for (let attempt = 0; attempt < this.retrySchedule.length; attempt++) { + const delay = this.retrySchedule[attempt] ?? 0; + if (delay > 0) await sleep(delay); + if (this.stopped) return outcomeFor(false, attempt, true); + + const outcome = await this.attemptDelivery(events, sub, batchId); + lastStatus = outcome.status; + // All-or-nothing: a 2xx acknowledges every event in the batch, anything + // else fails the whole batch and it is retried whole. + if (outcome.ok) { + await this.recordSuccess(sub.subscription_id); + return outcomeFor(true, attempt + 1); + } + } + + // One failed batch is one failed delivery, not one per event. + await this.recordFailure(sub.subscription_id); + return outcomeFor(false, this.retrySchedule.length); + } + private async deliverWithRetry(event: CloudEvent, sub: SubscriptionRecord): Promise { let lastStatus: number | undefined; @@ -305,8 +392,9 @@ export class WebhookDispatcher { } private async attemptDelivery( - event: CloudEvent, - sub: SubscriptionRecord + event: CloudEvent | CloudEvent[], + sub: SubscriptionRecord, + batchId?: string ): Promise<{ ok: boolean; status?: number }> { const secret = sub.delivery_secret ?? this.fallbackSecret; const url = sub.callback_url; @@ -318,10 +406,15 @@ export class WebhookDispatcher { // §5.2.1 — render in the subscription's content mode. The signature is // computed over whatever body this produces, so a binary-mode subscriber // verifies exactly what it received without reassembling an envelope. - const { body, headers: contentHeaders } = renderDelivery(event, sub.delivery_format); + // Batches (§5.2.2) are structured-mode only: binary mode maps attributes + // to headers, and a batch has many events with different values. + const { body, headers: contentHeaders } = Array.isArray(event) + ? renderBatch(event) + : renderDelivery(event, sub.delivery_format); // Stable across retries so subscribers can deduplicate; the event `id` - // is the idempotency key per delivery_guarantees.md §1. - const webhookId = `msg_${event.id}`; + // is the idempotency key per delivery_guarantees.md §1. For a batch the + // id identifies the batch, and each envelope keeps its own. + const webhookId = batchId ?? `msg_${(event as CloudEvent).id}`; // Re-signed per attempt with a current timestamp so a late retry still // lands inside the subscriber's replay window. const timestamp = Math.floor(Date.now() / 1000).toString(); @@ -360,7 +453,7 @@ export class WebhookDispatcher { // §7.1). Without this the subscriber's spans are orphaned and a // multi-hop agent workflow cannot be correlated back to the // originating event. - ...traceHeaders(event) + ...(Array.isArray(event) ? {} : traceHeaders(event)) }, body, signal: controller.signal diff --git a/packages/@eep-dev/middleware/src/index.ts b/packages/@eep-dev/middleware/src/index.ts index f6b5aa5..1bf4199 100644 --- a/packages/@eep-dev/middleware/src/index.ts +++ b/packages/@eep-dev/middleware/src/index.ts @@ -19,6 +19,7 @@ export { DEFAULT_RETRY_SCHEDULE_MS, DEFAULT_PAUSE_AFTER_FAILURES, DEFAULT_DELIVERY_TIMEOUT_MS, + MAX_BATCH_SIZE, type WebhookDispatcherOptions, type WebhookHttpClient, type WebhookHttpResponse, @@ -48,7 +49,13 @@ export { InMemoryEventBusAdapter } from "./event-bus/in-memory.js"; export { RedisEventBusAdapter, type RedisClientLike } from "./event-bus/redis.js"; export { KafkaEventBusAdapter, type KafkaProducerLike, type KafkaConsumerLike } from "./event-bus/kafka.js"; -export { renderDelivery, toBinaryAttributeName, type RenderedDelivery } from "./dispatcher/content-mode.js"; +export { + renderDelivery, + renderBatch, + toBinaryAttributeName, + BATCH_CONTENT_TYPE, + type RenderedDelivery +} from "./dispatcher/content-mode.js"; export { validateFilter, diff --git a/schemas/v0.1/subscription.request.json b/schemas/v0.1/subscription.request.json index 43813f1..818c95c 100644 --- a/schemas/v0.1/subscription.request.json +++ b/schemas/v0.1/subscription.request.json @@ -124,6 +124,20 @@ ], "default": "cloudevents/v1.0" }, + "max_batch_size": { + "type": "integer", + "description": "Maximum events the publisher may combine into one delivery (SPECIFICATION.md \u00a75.2.2). 1, or omitted, means one event per POST. Batching amortises the TLS handshake, the signature computation and the 10-second acknowledgement round-trip across N events, which is the dominant per-delivery cost for a high-frequency entity.", + "minimum": 1, + "maximum": 500, + "default": 1 + }, + "max_batch_wait_ms": { + "type": "integer", + "description": "How long the publisher may hold an event waiting for the batch to fill. Bounds the latency batching adds; the publisher MUST deliver once this elapses even if the batch is not full. Ignored when `max_batch_size` is 1.", + "minimum": 0, + "maximum": 30000, + "default": 0 + }, "lease_seconds": { "type": "integer", "description": "Optional. Requested subscription lifetime in seconds, starting from successful intent verification (SPECIFICATION.md \u00a710.2). The publisher MAY clamp this to its own policy and reports the value actually granted as `expires_at` on the subscription. Omit to accept the publisher's default lease.", diff --git a/tests/types/eep-schemas.d.ts b/tests/types/eep-schemas.d.ts index 0f5b8c7..f4033fe 100644 --- a/tests/types/eep-schemas.d.ts +++ b/tests/types/eep-schemas.d.ts @@ -5494,6 +5494,14 @@ export interface EEPSubscriptionRequest { * The event envelope format for delivery. `cloudevents/v1.0` is structured mode: the whole envelope is one JSON document in the body. `cloudevents/v1.0-binary` is binary content mode, where context attributes travel as protocol metadata and the body carries only `data` (SPECIFICATION.md §5.2.1). Defaults to structured. */ delivery_format?: 'cloudevents/v1.0' | 'cloudevents/v1.0-binary'; + /** + * Maximum events the publisher may combine into one delivery (SPECIFICATION.md §5.2.2). 1, or omitted, means one event per POST. Batching amortises the TLS handshake, the signature computation and the 10-second acknowledgement round-trip across N events, which is the dominant per-delivery cost for a high-frequency entity. + */ + max_batch_size?: number; + /** + * How long the publisher may hold an event waiting for the batch to fill. Bounds the latency batching adds; the publisher MUST deliver once this elapses even if the batch is not full. Ignored when `max_batch_size` is 1. + */ + max_batch_wait_ms?: number; /** * Optional. Requested subscription lifetime in seconds, starting from successful intent verification (SPECIFICATION.md §10.2). The publisher MAY clamp this to its own policy and reports the value actually granted as `expires_at` on the subscription. Omit to accept the publisher's default lease. */