@end-close/relay 0.12.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/COMPATIBILITY.md +39 -0
- package/LICENSE +21 -0
- package/README.md +205 -0
- package/dist/config/schema.d.ts +993 -0
- package/dist/config/schema.d.ts.map +1 -0
- package/dist/config/schema.js +156 -0
- package/dist/config/schema.js.map +1 -0
- package/dist/crypto/at-rest.d.ts +6 -0
- package/dist/crypto/at-rest.d.ts.map +1 -0
- package/dist/crypto/at-rest.js +19 -0
- package/dist/crypto/at-rest.js.map +1 -0
- package/dist/crypto/keys.d.ts +2 -0
- package/dist/crypto/keys.d.ts.map +1 -0
- package/dist/crypto/keys.js +12 -0
- package/dist/crypto/keys.js.map +1 -0
- package/dist/engine/codec.d.ts +12 -0
- package/dist/engine/codec.d.ts.map +1 -0
- package/dist/engine/codec.js +21 -0
- package/dist/engine/codec.js.map +1 -0
- package/dist/engine/hooks.d.ts +84 -0
- package/dist/engine/hooks.d.ts.map +1 -0
- package/dist/engine/hooks.js +34 -0
- package/dist/engine/hooks.js.map +1 -0
- package/dist/engine/ingest.d.ts +39 -0
- package/dist/engine/ingest.d.ts.map +1 -0
- package/dist/engine/ingest.js +131 -0
- package/dist/engine/ingest.js.map +1 -0
- package/dist/engine/memory-store.d.ts +35 -0
- package/dist/engine/memory-store.d.ts.map +1 -0
- package/dist/engine/memory-store.js +205 -0
- package/dist/engine/memory-store.js.map +1 -0
- package/dist/engine/relay.d.ts +116 -0
- package/dist/engine/relay.d.ts.map +1 -0
- package/dist/engine/relay.js +180 -0
- package/dist/engine/relay.js.map +1 -0
- package/dist/engine/secrets.d.ts +18 -0
- package/dist/engine/secrets.d.ts.map +1 -0
- package/dist/engine/secrets.js +42 -0
- package/dist/engine/secrets.js.map +1 -0
- package/dist/engine/settings.d.ts +29 -0
- package/dist/engine/settings.d.ts.map +1 -0
- package/dist/engine/settings.js +13 -0
- package/dist/engine/settings.js.map +1 -0
- package/dist/engine/store.d.ts +141 -0
- package/dist/engine/store.d.ts.map +1 -0
- package/dist/engine/store.js +52 -0
- package/dist/engine/store.js.map +1 -0
- package/dist/forward/backoff.d.ts +3 -0
- package/dist/forward/backoff.d.ts.map +1 -0
- package/dist/forward/backoff.js +10 -0
- package/dist/forward/backoff.js.map +1 -0
- package/dist/forward/dispatcher.d.ts +98 -0
- package/dist/forward/dispatcher.d.ts.map +1 -0
- package/dist/forward/dispatcher.js +444 -0
- package/dist/forward/dispatcher.js.map +1 -0
- package/dist/forward/endclose-client.d.ts +42 -0
- package/dist/forward/endclose-client.d.ts.map +1 -0
- package/dist/forward/endclose-client.js +76 -0
- package/dist/forward/endclose-client.js.map +1 -0
- package/dist/forward/enrich.d.ts +39 -0
- package/dist/forward/enrich.d.ts.map +1 -0
- package/dist/forward/enrich.js +77 -0
- package/dist/forward/enrich.js.map +1 -0
- package/dist/forward/mapper.d.ts +50 -0
- package/dist/forward/mapper.d.ts.map +1 -0
- package/dist/forward/mapper.js +141 -0
- package/dist/forward/mapper.js.map +1 -0
- package/dist/index.d.ts +36 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +24 -0
- package/dist/index.js.map +1 -0
- package/dist/ingest/adapters/generic-hmac.d.ts +3 -0
- package/dist/ingest/adapters/generic-hmac.d.ts.map +1 -0
- package/dist/ingest/adapters/generic-hmac.js +60 -0
- package/dist/ingest/adapters/generic-hmac.js.map +1 -0
- package/dist/ingest/adapters/payabli.d.ts +3 -0
- package/dist/ingest/adapters/payabli.d.ts.map +1 -0
- package/dist/ingest/adapters/payabli.js +57 -0
- package/dist/ingest/adapters/payabli.js.map +1 -0
- package/dist/ingest/adapters/registry.d.ts +4 -0
- package/dist/ingest/adapters/registry.d.ts.map +1 -0
- package/dist/ingest/adapters/registry.js +16 -0
- package/dist/ingest/adapters/registry.js.map +1 -0
- package/dist/ingest/adapters/types.d.ts +28 -0
- package/dist/ingest/adapters/types.d.ts.map +1 -0
- package/dist/ingest/adapters/types.js +5 -0
- package/dist/ingest/adapters/types.js.map +1 -0
- package/dist/logger.d.ts +12 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +14 -0
- package/dist/logger.js.map +1 -0
- package/dist/mask/defaults.d.ts +7 -0
- package/dist/mask/defaults.d.ts.map +1 -0
- package/dist/mask/defaults.js +51 -0
- package/dist/mask/defaults.js.map +1 -0
- package/dist/mask/paths.d.ts +7 -0
- package/dist/mask/paths.d.ts.map +1 -0
- package/dist/mask/paths.js +36 -0
- package/dist/mask/paths.js.map +1 -0
- package/dist/util/payload-shape.d.ts +6 -0
- package/dist/util/payload-shape.d.ts.map +1 -0
- package/dist/util/payload-shape.js +19 -0
- package/dist/util/payload-shape.js.map +1 -0
- package/dist/util/strings.d.ts +3 -0
- package/dist/util/strings.d.ts.map +1 -0
- package/dist/util/strings.js +7 -0
- package/dist/util/strings.js.map +1 -0
- package/package.json +57 -0
- package/src/config/schema.ts +177 -0
- package/src/crypto/at-rest.ts +21 -0
- package/src/crypto/keys.ts +13 -0
- package/src/engine/codec.ts +30 -0
- package/src/engine/hooks.ts +76 -0
- package/src/engine/ingest.ts +194 -0
- package/src/engine/memory-store.ts +232 -0
- package/src/engine/relay.ts +294 -0
- package/src/engine/secrets.ts +51 -0
- package/src/engine/settings.ts +42 -0
- package/src/engine/store.ts +188 -0
- package/src/forward/backoff.ts +10 -0
- package/src/forward/dispatcher.ts +504 -0
- package/src/forward/endclose-client.ts +102 -0
- package/src/forward/enrich.ts +106 -0
- package/src/forward/mapper.ts +191 -0
- package/src/index.ts +47 -0
- package/src/ingest/adapters/generic-hmac.ts +70 -0
- package/src/ingest/adapters/payabli.ts +67 -0
- package/src/ingest/adapters/registry.ts +21 -0
- package/src/ingest/adapters/types.ts +33 -0
- package/src/logger.ts +26 -0
- package/src/mask/defaults.ts +56 -0
- package/src/mask/paths.ts +36 -0
- package/src/util/payload-shape.ts +19 -0
- package/src/util/strings.ts +7 -0
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
import type { EventEmitter } from 'node:events'
|
|
2
|
+
import {
|
|
3
|
+
storeOp,
|
|
4
|
+
type ControlStore,
|
|
5
|
+
type EventRecord,
|
|
6
|
+
type EventStore,
|
|
7
|
+
type RouteProvider,
|
|
8
|
+
} from '../engine/store.js'
|
|
9
|
+
import type { PayloadCodec } from '../engine/codec.js'
|
|
10
|
+
import type { DispatchSettings, RetentionSettings } from '../engine/settings.js'
|
|
11
|
+
import type { RouteConfig } from '../config/schema.js'
|
|
12
|
+
import type { Json } from '../mask/paths.js'
|
|
13
|
+
import { RelayHooks } from '../engine/hooks.js'
|
|
14
|
+
import { noopLogger, type Logger } from '../logger.js'
|
|
15
|
+
import { jsonTopLevelKeys } from '../util/payload-shape.js'
|
|
16
|
+
import { sleep } from '../util/strings.js'
|
|
17
|
+
import { nextAttemptAt } from './backoff.js'
|
|
18
|
+
import { mapEvent, MappingError, type EndCloseRecord, type MappedEvent } from './mapper.js'
|
|
19
|
+
import {
|
|
20
|
+
EnrichmentError,
|
|
21
|
+
validateEnrichedValue,
|
|
22
|
+
withTimeout,
|
|
23
|
+
type EnrichContext,
|
|
24
|
+
type Enrichment,
|
|
25
|
+
} from './enrich.js'
|
|
26
|
+
import {
|
|
27
|
+
EndCloseClient,
|
|
28
|
+
PermanentHttpError,
|
|
29
|
+
TransientHttpError,
|
|
30
|
+
type BulkResultItem,
|
|
31
|
+
} from './endclose-client.js'
|
|
32
|
+
|
|
33
|
+
const IN_REQUEST_RETRIES = 2
|
|
34
|
+
const RESULT_POLL_ATTEMPTS = 5
|
|
35
|
+
const RESULT_POLL_DELAY_MS = 1000
|
|
36
|
+
const PRUNE_INTERVAL_MS = 3600_000
|
|
37
|
+
const PRUNE_BATCH = 50
|
|
38
|
+
const PRUNE_YIELD_MS = 25
|
|
39
|
+
|
|
40
|
+
export interface DispatcherDeps {
|
|
41
|
+
store: EventStore
|
|
42
|
+
control: ControlStore
|
|
43
|
+
routes: RouteProvider
|
|
44
|
+
dispatch: DispatchSettings
|
|
45
|
+
/** null disables retention pruning. */
|
|
46
|
+
retention: RetentionSettings | null
|
|
47
|
+
client: EndCloseClient
|
|
48
|
+
codec: PayloadCodec
|
|
49
|
+
maskingKey: Buffer
|
|
50
|
+
/** Lease owner recorded on claimed rows; a stable id lets a restarted instance reclaim its own. */
|
|
51
|
+
instanceId: string
|
|
52
|
+
signal: EventEmitter
|
|
53
|
+
hooks?: RelayHooks
|
|
54
|
+
logger?: Logger
|
|
55
|
+
/** Host functions routes may name with `enrich:`; run per event after mapping. */
|
|
56
|
+
enrichments?: Record<string, Enrichment>
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface DispatchCounts {
|
|
60
|
+
delivered: number
|
|
61
|
+
retried: number
|
|
62
|
+
parked: number
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** What one dispatch cycle did, and what it deliberately left alone. */
|
|
66
|
+
export interface CycleSummary extends DispatchCounts {
|
|
67
|
+
/** Routes that had due events when the cycle started. */
|
|
68
|
+
due: string[]
|
|
69
|
+
/** Due routes the cycle did not touch, and why. */
|
|
70
|
+
skipped: { routeId: string; reason: 'paused' | 'unknown_route' }[]
|
|
71
|
+
/** True when the global killswitch stopped the cycle before any forwarding. */
|
|
72
|
+
halted: boolean
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
type Abort = () => boolean
|
|
76
|
+
|
|
77
|
+
export class Dispatcher {
|
|
78
|
+
private log: Logger
|
|
79
|
+
private hooks: RelayHooks
|
|
80
|
+
private running = false
|
|
81
|
+
private wakeRequested = false
|
|
82
|
+
// Rows this instance left 'delivering' (a crash, or a failed release) are reclaimed
|
|
83
|
+
// by owner on the next cycle regardless of lease. Expired leases left by anyone are
|
|
84
|
+
// swept every recoverIntervalMs, so a long-lived instance also covers crashed peers.
|
|
85
|
+
private reclaimOwner: string | undefined
|
|
86
|
+
private lastSweepAt = 0
|
|
87
|
+
private inFlight: Promise<void> = Promise.resolve()
|
|
88
|
+
private pruneWork: Promise<unknown> = Promise.resolve()
|
|
89
|
+
private timer: NodeJS.Timeout | undefined
|
|
90
|
+
private pruneTimer: NodeJS.Timeout | undefined
|
|
91
|
+
private stopped: Abort = () => !this.running
|
|
92
|
+
|
|
93
|
+
constructor(private deps: DispatcherDeps) {
|
|
94
|
+
this.log = deps.logger ?? noopLogger
|
|
95
|
+
this.hooks = deps.hooks ?? new RelayHooks()
|
|
96
|
+
this.reclaimOwner = deps.instanceId
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
start(): void {
|
|
100
|
+
if (this.running) return
|
|
101
|
+
this.running = true
|
|
102
|
+
this.deps.signal.on('event', this.onSignal)
|
|
103
|
+
this.timer = setInterval(() => this.wake(), this.deps.dispatch.pollIntervalMs)
|
|
104
|
+
if (this.deps.retention) {
|
|
105
|
+
this.pruneTimer = setInterval(() => this.schedulePrune(), PRUNE_INTERVAL_MS)
|
|
106
|
+
this.schedulePrune()
|
|
107
|
+
}
|
|
108
|
+
this.wake()
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private onSignal = () => this.wake()
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Run exactly one dispatch cycle, serialised with the background loop if it is running.
|
|
115
|
+
* Errors propagate to the caller (they are also emitted as hooks).
|
|
116
|
+
*/
|
|
117
|
+
runOnce(): Promise<CycleSummary> {
|
|
118
|
+
const run = this.inFlight.then(() => this.cycle().catch((err) => {
|
|
119
|
+
this.reportCycleError(err)
|
|
120
|
+
throw err
|
|
121
|
+
}))
|
|
122
|
+
this.inFlight = run.then(() => {}, () => {})
|
|
123
|
+
return run
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Run retention pruning to completion, serialised with any scheduled prune. */
|
|
127
|
+
pruneNow(): Promise<{ wiped: number; deleted: number }> {
|
|
128
|
+
const run = this.pruneWork.then(() => this.runPrune())
|
|
129
|
+
this.pruneWork = run.then(() => {}, () => {})
|
|
130
|
+
return run
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Stop accepting new work and wait for the in-flight cycle and prune to drain. */
|
|
134
|
+
async stop(): Promise<void> {
|
|
135
|
+
this.running = false
|
|
136
|
+
this.deps.signal.off('event', this.onSignal)
|
|
137
|
+
if (this.timer) clearInterval(this.timer)
|
|
138
|
+
if (this.pruneTimer) clearInterval(this.pruneTimer)
|
|
139
|
+
await this.inFlight
|
|
140
|
+
await this.pruneWork
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private wake(): void {
|
|
144
|
+
if (!this.running) return
|
|
145
|
+
this.wakeRequested = true
|
|
146
|
+
this.inFlight = this.inFlight.then(async () => {
|
|
147
|
+
while (this.wakeRequested && this.running) {
|
|
148
|
+
this.wakeRequested = false
|
|
149
|
+
try {
|
|
150
|
+
await this.cycle(this.stopped)
|
|
151
|
+
} catch (err) {
|
|
152
|
+
this.reportCycleError(err)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
})
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private reportCycleError(err: unknown): void {
|
|
159
|
+
const op = storeOp(err)
|
|
160
|
+
this.log.error('dispatch cycle failed', { error: (err as Error).message, ...(op ? { op } : {}) })
|
|
161
|
+
this.hooks.emit('error', { kind: 'dispatch_cycle', error: err, ...(op ? { op } : {}) })
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
private async cycle(abort: Abort = () => false): Promise<CycleSummary> {
|
|
165
|
+
const now = new Date().toISOString()
|
|
166
|
+
const summary: CycleSummary = { delivered: 0, retried: 0, parked: 0, due: [], skipped: [], halted: false }
|
|
167
|
+
|
|
168
|
+
const sweepDue = Date.now() - this.lastSweepAt >= this.deps.dispatch.recoverIntervalMs
|
|
169
|
+
if (this.reclaimOwner !== undefined || sweepDue) {
|
|
170
|
+
try {
|
|
171
|
+
// The owner clause is only safe as a one-off (nothing of ours is in flight yet);
|
|
172
|
+
// the periodic sweep relies on lease expiry alone.
|
|
173
|
+
const recovered = await this.deps.store.recoverDelivering(now, this.reclaimOwner)
|
|
174
|
+
this.reclaimOwner = undefined
|
|
175
|
+
if (recovered > 0) this.log.info('recovered in-flight events', { count: recovered })
|
|
176
|
+
if (sweepDue) {
|
|
177
|
+
// Same cadence for the other housekeeping write; parkAfterMs is days, not seconds.
|
|
178
|
+
await this.deps.store.parkExpired(now, this.deps.dispatch.parkAfterMs)
|
|
179
|
+
this.lastSweepAt = Date.now()
|
|
180
|
+
}
|
|
181
|
+
} catch (err) {
|
|
182
|
+
this.log.error('recover delivering failed', { error: (err as Error).message })
|
|
183
|
+
this.hooks.emit('error', { kind: 'recover_delivering', error: err })
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if ((await this.deps.control.getKillswitch()) !== 'none') {
|
|
188
|
+
summary.halted = true
|
|
189
|
+
return summary // pause/panic: buffer, do not forward
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
summary.due = await this.deps.store.routesWithDueEvents(now)
|
|
193
|
+
for (const routeId of summary.due) {
|
|
194
|
+
if (abort()) break
|
|
195
|
+
const [route, paused] = await Promise.all([
|
|
196
|
+
this.deps.routes.get(routeId),
|
|
197
|
+
this.deps.control.isRoutePaused(routeId),
|
|
198
|
+
])
|
|
199
|
+
// An unknown route is the stronger condition: a pause flag left behind by a removed
|
|
200
|
+
// route must not disguise it as merely paused.
|
|
201
|
+
if (!route) {
|
|
202
|
+
summary.skipped.push({ routeId, reason: 'unknown_route' })
|
|
203
|
+
continue
|
|
204
|
+
}
|
|
205
|
+
if (paused) {
|
|
206
|
+
summary.skipped.push({ routeId, reason: 'paused' })
|
|
207
|
+
continue
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const claimed = await this.deps.store.claimDue(routeId, now, this.deps.dispatch.batchMax, {
|
|
211
|
+
owner: this.deps.instanceId,
|
|
212
|
+
until: new Date(Date.now() + this.deps.dispatch.leaseMs).toISOString(),
|
|
213
|
+
})
|
|
214
|
+
if (claimed.length === 0) continue
|
|
215
|
+
try {
|
|
216
|
+
await this.deliverBatch(route, claimed, summary)
|
|
217
|
+
} finally {
|
|
218
|
+
await this.releaseLeftover(claimed, 'left delivering after batch')
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return summary
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
private async deliverBatch(route: RouteConfig, claimed: EventRecord[], counts: DispatchCounts): Promise<void> {
|
|
225
|
+
const routeId = route.id
|
|
226
|
+
// Map and enrich each event independently; an event that cannot be mapped parks, one
|
|
227
|
+
// whose enrichment fails transiently retries, and neither wedges the batch.
|
|
228
|
+
const records: EndCloseRecord[] = []
|
|
229
|
+
const mapped: EventRecord[] = []
|
|
230
|
+
for (const event of claimed) {
|
|
231
|
+
let payloadKeys: string | undefined
|
|
232
|
+
let bodyBytes: number | undefined
|
|
233
|
+
let stage: 'mapping' | 'enrichment' = 'mapping'
|
|
234
|
+
try {
|
|
235
|
+
const plaintext = this.deps.codec.decode(event.payload, event.payload_iv)
|
|
236
|
+
bodyBytes = plaintext.length
|
|
237
|
+
const payload = JSON.parse(plaintext.toString('utf8')) as Json
|
|
238
|
+
payloadKeys = jsonTopLevelKeys(payload)
|
|
239
|
+
const result = mapEvent(route, payload, event.received_at, this.deps.maskingKey)
|
|
240
|
+
let { record } = result
|
|
241
|
+
if (result.pending.length > 0) {
|
|
242
|
+
stage = 'enrichment'
|
|
243
|
+
record = await this.resolveEnrichments(event, payload, result)
|
|
244
|
+
}
|
|
245
|
+
records.push(record)
|
|
246
|
+
mapped.push(event)
|
|
247
|
+
} catch (err) {
|
|
248
|
+
if (err instanceof MappingError || err instanceof EnrichmentError) {
|
|
249
|
+
const reason = `${stage} failed: ${err.message}`
|
|
250
|
+
await this.deps.store.markParked([event.id], reason)
|
|
251
|
+
this.recordParked([event], reason, counts)
|
|
252
|
+
this.log.warn(`event parked: ${stage} failed`, {
|
|
253
|
+
route: routeId,
|
|
254
|
+
event_id: event.event_id,
|
|
255
|
+
body_bytes: bodyBytes,
|
|
256
|
+
payload_keys: payloadKeys,
|
|
257
|
+
})
|
|
258
|
+
} else if (stage === 'enrichment') {
|
|
259
|
+
// The host's lookup threw or timed out: fixable on their side, so retry later.
|
|
260
|
+
await this.retryOne(event, `enrichment failed: ${(err as Error).message}`, counts)
|
|
261
|
+
} else {
|
|
262
|
+
throw err
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
if (records.length === 0) return
|
|
267
|
+
|
|
268
|
+
try {
|
|
269
|
+
const accepted = await this.postWithRetries(records)
|
|
270
|
+
await this.settleResults(accepted.id, mapped, counts)
|
|
271
|
+
this.log.info('batch forwarded', {
|
|
272
|
+
route: routeId,
|
|
273
|
+
events: mapped.length,
|
|
274
|
+
bulk_request_id: accepted.id,
|
|
275
|
+
})
|
|
276
|
+
this.hooks.emit('batch.forwarded', { routeId, events: mapped.length, bulkRequestId: accepted.id })
|
|
277
|
+
} catch (err) {
|
|
278
|
+
if (err instanceof PermanentHttpError && (err.status === 400 || err.status === 422)) {
|
|
279
|
+
const reason = `${err.message}: ${err.body}`
|
|
280
|
+
await this.deps.store.markParked(mapped.map((e) => e.id), reason)
|
|
281
|
+
this.recordParked(mapped, reason, counts)
|
|
282
|
+
this.log.error('batch parked: permanent rejection', { route: routeId, status: err.status })
|
|
283
|
+
this.hooks.emit('batch.parked', { routeId, status: err.status, events: mapped.length })
|
|
284
|
+
} else {
|
|
285
|
+
// Transient network failure, 5xx, or auth problem (fixable server-side or in
|
|
286
|
+
// config): schedule redelivery with backoff. attempts is per-event.
|
|
287
|
+
const maxAttempts = Math.max(...mapped.map((e) => e.attempts))
|
|
288
|
+
const next = nextAttemptAt(maxAttempts, this.deps.dispatch.backoffBaseMs, this.deps.dispatch.backoffCapMs)
|
|
289
|
+
const message = (err as Error).message
|
|
290
|
+
await this.deps.store.markFailed(mapped.map((e) => e.id), next, message)
|
|
291
|
+
counts.retried += mapped.length
|
|
292
|
+
this.hooks.emit('forward', { routeId, result: 'retried', count: mapped.length })
|
|
293
|
+
for (const e of mapped) this.hooks.emit('settled', { id: e.id, routeId, result: 'retried', error: message })
|
|
294
|
+
this.log.warn('batch delivery failed, will retry', {
|
|
295
|
+
route: routeId,
|
|
296
|
+
events: mapped.length,
|
|
297
|
+
error: message,
|
|
298
|
+
next_attempt_at: next,
|
|
299
|
+
})
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Fill the fields mapEvent left to the host, one enrichment at a time. Throws
|
|
306
|
+
* EnrichmentError for a bad reference or return value (park); rethrows anything else
|
|
307
|
+
* the host threw, including a timeout (retry).
|
|
308
|
+
*/
|
|
309
|
+
private async resolveEnrichments(event: EventRecord, payload: Json, mapped: MappedEvent): Promise<EndCloseRecord> {
|
|
310
|
+
const routeId = event.route_id
|
|
311
|
+
const record: EndCloseRecord = { ...mapped.record, metadata: { ...mapped.record.metadata } }
|
|
312
|
+
for (const { field, enrichment, input } of mapped.pending) {
|
|
313
|
+
const emit = (result: 'applied' | 'omitted' | 'failed' | 'rejected', error?: string) =>
|
|
314
|
+
this.hooks.emit('enrich', { routeId, id: event.id, field, enrichment, result, ...(error ? { error } : {}) })
|
|
315
|
+
const fn = this.deps.enrichments?.[enrichment]
|
|
316
|
+
if (!fn) {
|
|
317
|
+
const err = new EnrichmentError(`${field} references unknown enrichment "${enrichment}"`)
|
|
318
|
+
emit('rejected', err.message)
|
|
319
|
+
throw err
|
|
320
|
+
}
|
|
321
|
+
const ctx: EnrichContext = {
|
|
322
|
+
routeId,
|
|
323
|
+
source: event.source,
|
|
324
|
+
eventId: event.event_id,
|
|
325
|
+
eventType: event.event_type,
|
|
326
|
+
receivedAt: event.received_at,
|
|
327
|
+
field,
|
|
328
|
+
payload,
|
|
329
|
+
}
|
|
330
|
+
let value: Json | undefined
|
|
331
|
+
try {
|
|
332
|
+
const returned: unknown = await withTimeout(() => fn(input, ctx), this.deps.dispatch.enrichTimeoutMs)
|
|
333
|
+
value = validateEnrichedValue(returned, field)
|
|
334
|
+
if (field === 'description' && value !== undefined && typeof value !== 'string') {
|
|
335
|
+
throw new EnrichmentError(`enriched description must be a string`)
|
|
336
|
+
}
|
|
337
|
+
} catch (err) {
|
|
338
|
+
const message = (err as Error).message
|
|
339
|
+
emit(err instanceof EnrichmentError ? 'rejected' : 'failed', `${enrichment}: ${message}`)
|
|
340
|
+
const wrapped = `${field} (${enrichment}): ${message}`
|
|
341
|
+
throw err instanceof EnrichmentError
|
|
342
|
+
? new EnrichmentError(wrapped, { cause: err })
|
|
343
|
+
: new Error(wrapped, { cause: err })
|
|
344
|
+
}
|
|
345
|
+
if (value === undefined) {
|
|
346
|
+
emit('omitted')
|
|
347
|
+
continue
|
|
348
|
+
}
|
|
349
|
+
if (field === 'description') record.description = value as string
|
|
350
|
+
else record.metadata[field.slice('metadata.'.length)] = value
|
|
351
|
+
emit('applied')
|
|
352
|
+
}
|
|
353
|
+
return record
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** Schedule one event for redelivery with backoff, leaving the rest of its batch alone. */
|
|
357
|
+
private async retryOne(event: EventRecord, message: string, counts: DispatchCounts): Promise<void> {
|
|
358
|
+
const next = nextAttemptAt(event.attempts, this.deps.dispatch.backoffBaseMs, this.deps.dispatch.backoffCapMs)
|
|
359
|
+
await this.deps.store.markFailed([event.id], next, message)
|
|
360
|
+
counts.retried += 1
|
|
361
|
+
this.hooks.emit('forward', { routeId: event.route_id, result: 'retried', count: 1 })
|
|
362
|
+
this.hooks.emit('settled', { id: event.id, routeId: event.route_id, result: 'retried', error: message })
|
|
363
|
+
this.log.warn('event enrichment failed, will retry', {
|
|
364
|
+
route: event.route_id,
|
|
365
|
+
event_id: event.event_id,
|
|
366
|
+
error: message,
|
|
367
|
+
next_attempt_at: next,
|
|
368
|
+
})
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
private async postWithRetries(records: EndCloseRecord[]) {
|
|
372
|
+
let lastErr: unknown
|
|
373
|
+
for (let attempt = 0; attempt <= IN_REQUEST_RETRIES; attempt++) {
|
|
374
|
+
try {
|
|
375
|
+
return await this.deps.client.bulkCreateRecords(records)
|
|
376
|
+
} catch (err) {
|
|
377
|
+
lastErr = err
|
|
378
|
+
if (!(err instanceof TransientHttpError)) throw err
|
|
379
|
+
await sleep(500 * (attempt + 1))
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
throw lastErr
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Bulk processing is async server-side: poll briefly for per-row results. Rows the API
|
|
387
|
+
* rejected park; everything else is delivered. If results don't settle in time, mark
|
|
388
|
+
* delivered with the bulk_request_id recorded for later inspection.
|
|
389
|
+
*/
|
|
390
|
+
private async settleResults(bulkRequestId: string, mapped: EventRecord[], counts: DispatchCounts): Promise<void> {
|
|
391
|
+
const deliveredAt = new Date().toISOString()
|
|
392
|
+
for (let i = 0; i < RESULT_POLL_ATTEMPTS; i++) {
|
|
393
|
+
let status
|
|
394
|
+
try {
|
|
395
|
+
status = await this.deps.client.getBulkRequest(bulkRequestId)
|
|
396
|
+
} catch {
|
|
397
|
+
break // polling is best-effort; the batch was accepted
|
|
398
|
+
}
|
|
399
|
+
if (status.status === 'pending' || status.status === 'processing') {
|
|
400
|
+
await sleep(RESULT_POLL_DELAY_MS)
|
|
401
|
+
continue
|
|
402
|
+
}
|
|
403
|
+
const failed = new Set(
|
|
404
|
+
(status.results ?? [])
|
|
405
|
+
.filter((r: BulkResultItem) => r.status === 'failed' || r.status === 'rejected')
|
|
406
|
+
.map((r: BulkResultItem) => r.external_id ?? String(r.index)),
|
|
407
|
+
)
|
|
408
|
+
if (failed.size === 0) break
|
|
409
|
+
const parked: EventRecord[] = []
|
|
410
|
+
const ok: EventRecord[] = []
|
|
411
|
+
mapped.forEach((event, index) => {
|
|
412
|
+
const key = extResultKey(event, index)
|
|
413
|
+
;(failed.has(key.externalId) || failed.has(key.index) ? parked : ok).push(event)
|
|
414
|
+
})
|
|
415
|
+
const reason = 'rejected by End Close bulk processing'
|
|
416
|
+
await this.deps.store.markParked(parked.map((e) => e.id), reason)
|
|
417
|
+
await this.deps.store.markDelivered(ok.map((e) => e.id), deliveredAt, bulkRequestId)
|
|
418
|
+
this.recordDelivered(ok, deliveredAt, counts)
|
|
419
|
+
this.recordParked(parked, reason, counts)
|
|
420
|
+
return
|
|
421
|
+
}
|
|
422
|
+
await this.deps.store.markDelivered(mapped.map((e) => e.id), deliveredAt, bulkRequestId)
|
|
423
|
+
this.recordDelivered(mapped, deliveredAt, counts)
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
private recordDelivered(events: EventRecord[], deliveredAt: string, counts: DispatchCounts): void {
|
|
427
|
+
if (events.length === 0) return
|
|
428
|
+
counts.delivered += events.length
|
|
429
|
+
this.hooks.emit('forward', { routeId: events[0]!.route_id, result: 'delivered', count: events.length })
|
|
430
|
+
for (const e of events) {
|
|
431
|
+
this.hooks.emit('delivered', { routeId: e.route_id, receivedAt: e.received_at, deliveredAt })
|
|
432
|
+
this.hooks.emit('settled', { id: e.id, routeId: e.route_id, result: 'delivered' })
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
private recordParked(events: EventRecord[], reason: string, counts: DispatchCounts): void {
|
|
437
|
+
if (events.length === 0) return
|
|
438
|
+
counts.parked += events.length
|
|
439
|
+
this.hooks.emit('forward', { routeId: events[0]!.route_id, result: 'parked', count: events.length })
|
|
440
|
+
for (const e of events) {
|
|
441
|
+
this.hooks.emit('settled', { id: e.id, routeId: e.route_id, result: 'parked', error: reason })
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
private schedulePrune(): void {
|
|
446
|
+
// Prune must not block forwarding: failures are logged and emitted, never propagated.
|
|
447
|
+
this.pruneWork = this.pruneWork.then(() => this.runPrune(this.stopped)).catch(() => {})
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** Bounded batches with a yield between them so ingest and dispatch keep running. */
|
|
451
|
+
private async runPrune(abort: Abort = () => false): Promise<{ wiped: number; deleted: number }> {
|
|
452
|
+
const { retention } = this.deps
|
|
453
|
+
if (!retention) return { wiped: 0, deleted: 0 }
|
|
454
|
+
const now = new Date().toISOString()
|
|
455
|
+
let wiped = 0
|
|
456
|
+
let deleted = 0
|
|
457
|
+
try {
|
|
458
|
+
while (!abort()) {
|
|
459
|
+
const batch = await this.deps.store.pruneBatch(
|
|
460
|
+
now,
|
|
461
|
+
retention.deliveredDays,
|
|
462
|
+
retention.ledgerDays,
|
|
463
|
+
PRUNE_BATCH,
|
|
464
|
+
)
|
|
465
|
+
wiped += batch.wiped
|
|
466
|
+
deleted += batch.deleted
|
|
467
|
+
this.hooks.emit('prune', { wiped: batch.wiped, deleted: batch.deleted })
|
|
468
|
+
if (batch.wiped === 0 && batch.deleted === 0) break
|
|
469
|
+
await sleep(PRUNE_YIELD_MS)
|
|
470
|
+
}
|
|
471
|
+
if (wiped || deleted) this.log.info('retention prune', { wiped, deleted })
|
|
472
|
+
} catch (err) {
|
|
473
|
+
this.log.error('retention prune failed', { error: (err as Error).message })
|
|
474
|
+
this.hooks.emit('error', { kind: 'prune', error: err })
|
|
475
|
+
throw err
|
|
476
|
+
}
|
|
477
|
+
return { wiped, deleted }
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
private async releaseLeftover(claimed: EventRecord[], error: string): Promise<void> {
|
|
481
|
+
const ids = claimed.map((e) => e.id)
|
|
482
|
+
try {
|
|
483
|
+
const n = await this.deps.store.releaseDelivering(ids, new Date().toISOString(), error)
|
|
484
|
+
if (n > 0) {
|
|
485
|
+
this.log.warn('released leftover delivering events', { count: n, error })
|
|
486
|
+
}
|
|
487
|
+
} catch (err) {
|
|
488
|
+
this.reclaimOwner = this.deps.instanceId
|
|
489
|
+
this.log.error('failed to release leftover delivering events', {
|
|
490
|
+
error: (err as Error).message,
|
|
491
|
+
count: ids.length,
|
|
492
|
+
})
|
|
493
|
+
this.hooks.emit('error', { kind: 'recover_delivering', error: err })
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function extResultKey(event: EventRecord, index: number): { externalId: string; index: string } {
|
|
499
|
+
// Result rows may be keyed by external_id or by index depending on API version.
|
|
500
|
+
const externalId = event.event_id.includes(':')
|
|
501
|
+
? event.event_id.slice(event.event_id.indexOf(':') + 1)
|
|
502
|
+
: event.event_id
|
|
503
|
+
return { externalId, index: String(index) }
|
|
504
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import type { EndCloseRecord } from './mapper.js'
|
|
3
|
+
|
|
4
|
+
export interface BulkRequestSummary {
|
|
5
|
+
id: string
|
|
6
|
+
status: string
|
|
7
|
+
total_items?: number
|
|
8
|
+
failed_items?: number
|
|
9
|
+
skipped_items?: number
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface BulkResultItem {
|
|
13
|
+
index?: number
|
|
14
|
+
external_id?: string
|
|
15
|
+
status: string
|
|
16
|
+
error?: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class TransientHttpError extends Error {
|
|
20
|
+
constructor(
|
|
21
|
+
message: string,
|
|
22
|
+
readonly status?: number,
|
|
23
|
+
) {
|
|
24
|
+
super(message)
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class PermanentHttpError extends Error {
|
|
29
|
+
constructor(
|
|
30
|
+
message: string,
|
|
31
|
+
readonly status: number,
|
|
32
|
+
readonly body: string,
|
|
33
|
+
) {
|
|
34
|
+
super(message)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const TRANSIENT_STATUSES = new Set([408, 429, 500, 502, 503, 504])
|
|
39
|
+
|
|
40
|
+
export class EndCloseClient {
|
|
41
|
+
constructor(
|
|
42
|
+
private baseUrl: string,
|
|
43
|
+
private apiKey: string,
|
|
44
|
+
private fetchImpl: typeof fetch = fetch,
|
|
45
|
+
) {}
|
|
46
|
+
|
|
47
|
+
/** Deterministic per-batch idempotency key so a retried POST returns the original bulk request. */
|
|
48
|
+
static idempotencyKey(records: EndCloseRecord[]): string {
|
|
49
|
+
const h = createHash('sha256')
|
|
50
|
+
for (const r of records) h.update(`${r.data_stream_key}:${r.external_id}\n`)
|
|
51
|
+
return 'relay-' + h.digest('hex').slice(0, 40)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async bulkCreateRecords(records: EndCloseRecord[]): Promise<BulkRequestSummary> {
|
|
55
|
+
return this.request('POST', '/records/bulk', {
|
|
56
|
+
idempotencyKey: EndCloseClient.idempotencyKey(records),
|
|
57
|
+
body: { on_conflict: 'skip', records },
|
|
58
|
+
}) as Promise<BulkRequestSummary>
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async getBulkRequest(id: string): Promise<BulkRequestSummary & { results?: BulkResultItem[] }> {
|
|
62
|
+
return this.request('GET', `/bulk_requests/${id}`, {}) as Promise<
|
|
63
|
+
BulkRequestSummary & { results?: BulkResultItem[] }
|
|
64
|
+
>
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Operational call-home. Failures must never affect ingest or dispatch. */
|
|
68
|
+
async postRelayEvent(event: { name: string; properties: Record<string, unknown> }): Promise<void> {
|
|
69
|
+
await this.request('POST', '/relays/events', { body: event, timeoutMs: 5_000 })
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private async request(
|
|
73
|
+
method: string,
|
|
74
|
+
path: string,
|
|
75
|
+
opts: { idempotencyKey?: string; body?: unknown; timeoutMs?: number },
|
|
76
|
+
): Promise<unknown> {
|
|
77
|
+
const headers: Record<string, string> = {
|
|
78
|
+
'X-API-KEY': this.apiKey,
|
|
79
|
+
'Content-Type': 'application/json',
|
|
80
|
+
}
|
|
81
|
+
if (opts.idempotencyKey) headers['Idempotency-Key'] = opts.idempotencyKey
|
|
82
|
+
|
|
83
|
+
let res: Response
|
|
84
|
+
try {
|
|
85
|
+
res = await this.fetchImpl(`${this.baseUrl}${path}`, {
|
|
86
|
+
method,
|
|
87
|
+
headers,
|
|
88
|
+
body: opts.body === undefined ? null : JSON.stringify(opts.body),
|
|
89
|
+
signal: AbortSignal.timeout(opts.timeoutMs ?? 30_000),
|
|
90
|
+
})
|
|
91
|
+
} catch (err) {
|
|
92
|
+
throw new TransientHttpError(`network error: ${(err as Error).message}`)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const text = await res.text()
|
|
96
|
+
if (res.ok) return text ? JSON.parse(text) : {}
|
|
97
|
+
if (TRANSIENT_STATUSES.has(res.status)) {
|
|
98
|
+
throw new TransientHttpError(`HTTP ${res.status}`, res.status)
|
|
99
|
+
}
|
|
100
|
+
throw new PermanentHttpError(`HTTP ${res.status}`, res.status, text.slice(0, 500))
|
|
101
|
+
}
|
|
102
|
+
}
|