@haathie/pgmb 0.2.5 → 0.2.7
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/README.md +31 -0
- package/lib/client.js +9 -8
- package/lib/index.js +1 -0
- package/lib/utils.js +11 -0
- package/package.json +12 -5
- package/src/abortable-async-iterator.ts +98 -0
- package/src/batcher.ts +90 -0
- package/src/client.ts +699 -0
- package/src/consts.ts +1 -0
- package/src/index.ts +6 -0
- package/src/queries.ts +570 -0
- package/src/query-types.ts +21 -0
- package/src/retry-handler.ts +125 -0
- package/src/sse.ts +148 -0
- package/src/types.ts +267 -0
- package/src/utils.ts +71 -0
- package/src/webhook-handler.ts +91 -0
- package/lib/abortable-async-iterator.d.ts +0 -14
- package/lib/batcher.d.ts +0 -12
- package/lib/client.d.ts +0 -76
- package/lib/consts.d.ts +0 -1
- package/lib/index.d.ts +0 -5
- package/lib/queries.d.ts +0 -453
- package/lib/query-types.d.ts +0 -17
- package/lib/retry-handler.d.ts +0 -11
- package/lib/sse.d.ts +0 -4
- package/lib/types.d.ts +0 -223
- package/lib/utils.d.ts +0 -15
- package/lib/webhook-handler.d.ts +0 -6
package/src/client.ts
ADDED
|
@@ -0,0 +1,699 @@
|
|
|
1
|
+
import assert from 'assert'
|
|
2
|
+
import { type Logger, pino } from 'pino'
|
|
3
|
+
import { setTimeout } from 'timers/promises'
|
|
4
|
+
import { AbortableAsyncIterator } from './abortable-async-iterator.ts'
|
|
5
|
+
import { PGMBEventBatcher } from './batcher.ts'
|
|
6
|
+
import {
|
|
7
|
+
assertGroup,
|
|
8
|
+
assertSubscription,
|
|
9
|
+
deleteSubscriptions,
|
|
10
|
+
maintainEventsTable,
|
|
11
|
+
markSubscriptionsActive,
|
|
12
|
+
pollForEvents,
|
|
13
|
+
readNextEvents as defaultReadNextEvents,
|
|
14
|
+
releaseGroupLock,
|
|
15
|
+
removeExpiredSubscriptions,
|
|
16
|
+
setGroupCursor,
|
|
17
|
+
writeEvents,
|
|
18
|
+
} from './queries.ts'
|
|
19
|
+
import type { PgClientLike, PgReleasableClient } from './query-types.ts'
|
|
20
|
+
import {
|
|
21
|
+
createRetryHandler,
|
|
22
|
+
normaliseRetryEventsInReadEventMap,
|
|
23
|
+
} from './retry-handler.ts'
|
|
24
|
+
import type {
|
|
25
|
+
GetWebhookInfoFn,
|
|
26
|
+
IEphemeralListener,
|
|
27
|
+
IEventData,
|
|
28
|
+
IEventHandler,
|
|
29
|
+
IFindEventsFn,
|
|
30
|
+
IReadEvent,
|
|
31
|
+
IReadNextEventsFn,
|
|
32
|
+
ISplitFn,
|
|
33
|
+
Pgmb2ClientOpts,
|
|
34
|
+
registerReliableHandlerParams,
|
|
35
|
+
RegisterSubscriptionParams,
|
|
36
|
+
} from './types.ts'
|
|
37
|
+
import { getEnvNumber } from './utils.ts'
|
|
38
|
+
import { createWebhookHandler } from './webhook-handler.ts'
|
|
39
|
+
|
|
40
|
+
type IReliableListener<T extends IEventData> = {
|
|
41
|
+
type: 'reliable'
|
|
42
|
+
handler: IEventHandler<T>
|
|
43
|
+
removeOnEmpty?: boolean
|
|
44
|
+
extra?: unknown
|
|
45
|
+
splitBy?: ISplitFn<T>
|
|
46
|
+
queue: {
|
|
47
|
+
item: IReadEvent<T>
|
|
48
|
+
checkpoint: Checkpoint
|
|
49
|
+
}[]
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
type IFireAndForgetListener<T extends IEventData> = {
|
|
53
|
+
type: 'fire-and-forget'
|
|
54
|
+
stream: IEphemeralListener<T>
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
type IListener<T extends IEventData> =
|
|
58
|
+
| IFireAndForgetListener<T>
|
|
59
|
+
| IReliableListener<T>;
|
|
60
|
+
|
|
61
|
+
type Checkpoint = {
|
|
62
|
+
activeTasks: number
|
|
63
|
+
nextCursor: string
|
|
64
|
+
cancelled?: boolean
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export type IListenerStore<T extends IEventData> = {
|
|
68
|
+
values: { [id: string]: IListener<T> }
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export class PgmbClient<
|
|
72
|
+
T extends IEventData = IEventData,
|
|
73
|
+
> extends PGMBEventBatcher<T> {
|
|
74
|
+
readonly client: PgClientLike
|
|
75
|
+
readonly logger: Logger
|
|
76
|
+
readonly groupId: string
|
|
77
|
+
readonly readEventsIntervalMs: number
|
|
78
|
+
readonly pollEventsIntervalMs: number
|
|
79
|
+
readonly readChunkSize: number
|
|
80
|
+
readonly subscriptionMaintenanceMs: number
|
|
81
|
+
readonly tableMaintenanceMs: number
|
|
82
|
+
readonly maxActiveCheckpoints: number
|
|
83
|
+
readonly readNextEvents: IReadNextEventsFn
|
|
84
|
+
readonly findEvents?: IFindEventsFn
|
|
85
|
+
|
|
86
|
+
readonly getWebhookInfo: GetWebhookInfoFn
|
|
87
|
+
readonly webhookHandler: IEventHandler<T>
|
|
88
|
+
|
|
89
|
+
readonly listeners: { [subId: string]: IListenerStore<T> } = {}
|
|
90
|
+
|
|
91
|
+
readonly #webhookHandlerOpts: Partial<{ splitBy?: ISplitFn<T> }>
|
|
92
|
+
|
|
93
|
+
#readClient?: PgReleasableClient
|
|
94
|
+
|
|
95
|
+
#endAc = new AbortController()
|
|
96
|
+
|
|
97
|
+
#readTask?: Promise<void>
|
|
98
|
+
#pollTask?: Promise<void>
|
|
99
|
+
#subMaintainTask?: Promise<void>
|
|
100
|
+
#tableMaintainTask?: Promise<void>
|
|
101
|
+
|
|
102
|
+
#inMemoryCursor: string | null = null
|
|
103
|
+
#activeCheckpoints: Checkpoint[] = []
|
|
104
|
+
|
|
105
|
+
constructor({
|
|
106
|
+
client,
|
|
107
|
+
groupId,
|
|
108
|
+
logger = pino(),
|
|
109
|
+
readEventsIntervalMs = getEnvNumber('PGMB_READ_EVENTS_INTERVAL_MS', 1000),
|
|
110
|
+
readChunkSize = getEnvNumber('PGMB_READ_CHUNK_SIZE', 1000),
|
|
111
|
+
maxActiveCheckpoints = getEnvNumber('PGMB_MAX_ACTIVE_CHECKPOINTS', 10),
|
|
112
|
+
pollEventsIntervalMs = getEnvNumber('PGMB_POLL_EVENTS_INTERVAL_MS', 1000),
|
|
113
|
+
subscriptionMaintenanceMs
|
|
114
|
+
= getEnvNumber('PGMB_SUBSCRIPTION_MAINTENANCE_S', 60) * 1000,
|
|
115
|
+
tableMaintainanceMs
|
|
116
|
+
= getEnvNumber('PGMB_TABLE_MAINTENANCE_M', 15) * 60 * 1000,
|
|
117
|
+
webhookHandlerOpts: {
|
|
118
|
+
splitBy: whSplitBy,
|
|
119
|
+
...whHandlerOpts
|
|
120
|
+
} = {},
|
|
121
|
+
getWebhookInfo = () => ({}),
|
|
122
|
+
readNextEvents = defaultReadNextEvents.run.bind(defaultReadNextEvents),
|
|
123
|
+
findEvents,
|
|
124
|
+
...batcherOpts
|
|
125
|
+
}: Pgmb2ClientOpts<T>) {
|
|
126
|
+
super({
|
|
127
|
+
...batcherOpts,
|
|
128
|
+
logger,
|
|
129
|
+
publish: (...e) => this.publish(e),
|
|
130
|
+
})
|
|
131
|
+
this.client = client
|
|
132
|
+
this.logger = logger
|
|
133
|
+
this.groupId = groupId
|
|
134
|
+
this.readEventsIntervalMs = readEventsIntervalMs
|
|
135
|
+
this.readChunkSize = readChunkSize
|
|
136
|
+
this.pollEventsIntervalMs = pollEventsIntervalMs
|
|
137
|
+
this.subscriptionMaintenanceMs = subscriptionMaintenanceMs
|
|
138
|
+
this.maxActiveCheckpoints = maxActiveCheckpoints
|
|
139
|
+
this.webhookHandler = createWebhookHandler<T>(whHandlerOpts)
|
|
140
|
+
this.#webhookHandlerOpts = { splitBy: whSplitBy }
|
|
141
|
+
this.getWebhookInfo = getWebhookInfo
|
|
142
|
+
this.tableMaintenanceMs = tableMaintainanceMs
|
|
143
|
+
this.readNextEvents = readNextEvents
|
|
144
|
+
this.findEvents = findEvents
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async init() {
|
|
148
|
+
this.#endAc = new AbortController()
|
|
149
|
+
|
|
150
|
+
if('connect' in this.client) {
|
|
151
|
+
this.client.on('remove', this.#onPoolClientRemoved)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// maintain event table
|
|
155
|
+
await maintainEventsTable.run(undefined, this.client)
|
|
156
|
+
this.logger.debug('maintained events table')
|
|
157
|
+
|
|
158
|
+
await assertGroup.run({ id: this.groupId }, this.client)
|
|
159
|
+
this.logger.debug({ groupId: this.groupId }, 'asserted group exists')
|
|
160
|
+
// clean up expired subscriptions on start
|
|
161
|
+
const [{ deleted }] = await removeExpiredSubscriptions.run(
|
|
162
|
+
{ groupId: this.groupId, activeIds: [] },
|
|
163
|
+
this.client,
|
|
164
|
+
)
|
|
165
|
+
this.logger.debug({ deleted }, 'removed expired subscriptions')
|
|
166
|
+
|
|
167
|
+
this.#readTask = this.#startLoop(
|
|
168
|
+
this.readChanges.bind(this),
|
|
169
|
+
this.readEventsIntervalMs,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
if(this.pollEventsIntervalMs) {
|
|
173
|
+
this.#pollTask = this.#startLoop(
|
|
174
|
+
pollForEvents.run.bind(pollForEvents, undefined, this.client),
|
|
175
|
+
this.pollEventsIntervalMs,
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if(this.subscriptionMaintenanceMs) {
|
|
180
|
+
this.#subMaintainTask = this.#startLoop(
|
|
181
|
+
this.#maintainSubscriptions,
|
|
182
|
+
this.subscriptionMaintenanceMs,
|
|
183
|
+
)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if(this.tableMaintenanceMs) {
|
|
187
|
+
this.#tableMaintainTask = this.#startLoop(
|
|
188
|
+
maintainEventsTable.run.bind(
|
|
189
|
+
maintainEventsTable,
|
|
190
|
+
undefined,
|
|
191
|
+
this.client,
|
|
192
|
+
),
|
|
193
|
+
this.tableMaintenanceMs,
|
|
194
|
+
)
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async end() {
|
|
199
|
+
await super.end()
|
|
200
|
+
|
|
201
|
+
this.#endAc.abort()
|
|
202
|
+
|
|
203
|
+
while(this.#activeCheckpoints.length) {
|
|
204
|
+
await setTimeout(100)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
for(const id in this.listeners) {
|
|
208
|
+
delete this.listeners[id]
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
await Promise.all([
|
|
212
|
+
this.#readTask,
|
|
213
|
+
this.#pollTask,
|
|
214
|
+
this.#subMaintainTask,
|
|
215
|
+
this.#tableMaintainTask,
|
|
216
|
+
])
|
|
217
|
+
|
|
218
|
+
await this.#unlockAndReleaseReadClient()
|
|
219
|
+
this.#readTask = undefined
|
|
220
|
+
this.#pollTask = undefined
|
|
221
|
+
this.#subMaintainTask = undefined
|
|
222
|
+
this.#activeCheckpoints = []
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
publish(events: T[], client = this.client) {
|
|
226
|
+
return writeEvents.run(
|
|
227
|
+
{
|
|
228
|
+
topics: events.map((e) => e.topic),
|
|
229
|
+
payloads: events.map((e) => e.payload),
|
|
230
|
+
metadatas: events.map((e) => e.metadata || null),
|
|
231
|
+
},
|
|
232
|
+
client,
|
|
233
|
+
)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async assertSubscription(
|
|
237
|
+
opts: RegisterSubscriptionParams,
|
|
238
|
+
client = this.client,
|
|
239
|
+
) {
|
|
240
|
+
const [rslt] = await assertSubscription.run(
|
|
241
|
+
{ ...opts, groupId: this.groupId },
|
|
242
|
+
client,
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
this.logger.debug({ ...opts, ...rslt }, 'asserted subscription')
|
|
246
|
+
return rslt
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Registers a fire-and-forget handler, returning an async iterator
|
|
251
|
+
* that yields events as they arrive. The client does not wait for event
|
|
252
|
+
* processing acknowledgements. Useful for cases where data is eventually
|
|
253
|
+
* consistent, or when event delivery isn't critical
|
|
254
|
+
* (eg. http SSE, websockets).
|
|
255
|
+
*/
|
|
256
|
+
async registerFireAndForgetHandler(opts: RegisterSubscriptionParams) {
|
|
257
|
+
const { id: subId } = await this.assertSubscription(opts)
|
|
258
|
+
return this.#listenForEvents(subId)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Registers a reliable handler for the given subscription params.
|
|
263
|
+
* If the handler throws an error, client will rollback to the last known
|
|
264
|
+
* good cursor, and re-deliver events.
|
|
265
|
+
* To avoid a full redelivery of a batch, a retry strategy can be provided
|
|
266
|
+
* to retry failed events by the handler itself, allowing for delayed retries
|
|
267
|
+
* with backoff, and without disrupting the overall event flow.
|
|
268
|
+
*/
|
|
269
|
+
async registerReliableHandler(
|
|
270
|
+
{
|
|
271
|
+
retryOpts,
|
|
272
|
+
name = createListenerId(),
|
|
273
|
+
splitBy,
|
|
274
|
+
...opts
|
|
275
|
+
}: registerReliableHandlerParams<T>,
|
|
276
|
+
handler: IEventHandler<T>,
|
|
277
|
+
) {
|
|
278
|
+
const { id: subId } = await this.assertSubscription(opts)
|
|
279
|
+
if(retryOpts) {
|
|
280
|
+
handler = createRetryHandler(retryOpts, handler)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const lts = (this.listeners[subId] ||= { values: {} })
|
|
284
|
+
assert(
|
|
285
|
+
!lts.values[name],
|
|
286
|
+
`Handler with id ${name} already registered for subscription ${subId}.` +
|
|
287
|
+
' Cancel the existing one or use a different id.',
|
|
288
|
+
)
|
|
289
|
+
this.listeners[subId].values[name] = {
|
|
290
|
+
type: 'reliable',
|
|
291
|
+
handler,
|
|
292
|
+
splitBy,
|
|
293
|
+
queue: [],
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return {
|
|
297
|
+
subscriptionId: subId,
|
|
298
|
+
cancel: () => this.#removeListener(subId, name),
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async removeSubscription(subId: string) {
|
|
303
|
+
await deleteSubscriptions.run({ ids: [subId] }, this.client)
|
|
304
|
+
this.logger.debug({ subId }, 'deleted subscription')
|
|
305
|
+
|
|
306
|
+
const existingSubs = this.listeners[subId]?.values
|
|
307
|
+
delete this.listeners[subId]
|
|
308
|
+
if(!existingSubs) {
|
|
309
|
+
return
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
await Promise.allSettled(
|
|
313
|
+
Object.values(existingSubs).map(
|
|
314
|
+
(e) => e.type === 'fire-and-forget' &&
|
|
315
|
+
e.stream.throw(new Error('subscription removed')),
|
|
316
|
+
),
|
|
317
|
+
)
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
#listenForEvents(subId: string): IEphemeralListener<T> {
|
|
321
|
+
const lid = createListenerId()
|
|
322
|
+
const iterator = new AbortableAsyncIterator<IReadEvent<T>>(
|
|
323
|
+
this.#endAc.signal,
|
|
324
|
+
() => this.#removeListener(subId, lid),
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
const stream = iterator as IEphemeralListener<T>
|
|
328
|
+
stream.id = subId
|
|
329
|
+
|
|
330
|
+
this.listeners[subId] ||= { values: {} }
|
|
331
|
+
this.listeners[subId].values[lid] = { type: 'fire-and-forget', stream }
|
|
332
|
+
|
|
333
|
+
return stream
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
#removeListener(subId: string, lid: string) {
|
|
337
|
+
const existingSubs = this.listeners[subId]?.values
|
|
338
|
+
delete existingSubs?.[lid]
|
|
339
|
+
if(existingSubs && Object.keys(existingSubs).length) {
|
|
340
|
+
return
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
delete this.listeners[subId]
|
|
344
|
+
this.logger.debug({ subId }, 'removed last subscriber for sub')
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async #maintainSubscriptions() {
|
|
348
|
+
const activeIds = Object.keys(this.listeners)
|
|
349
|
+
await markSubscriptionsActive.run({ ids: activeIds }, this.client)
|
|
350
|
+
|
|
351
|
+
this.logger.trace(
|
|
352
|
+
{ activeSubscriptions: activeIds.length },
|
|
353
|
+
'marked subscriptions as active',
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
const [{ deleted }] = await removeExpiredSubscriptions.run(
|
|
357
|
+
{ groupId: this.groupId, activeIds },
|
|
358
|
+
this.client,
|
|
359
|
+
)
|
|
360
|
+
this.logger.trace({ deleted }, 'removed expired subscriptions')
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
async readChanges() {
|
|
364
|
+
if(this.#activeCheckpoints.length >= this.maxActiveCheckpoints) {
|
|
365
|
+
return 0
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const now = Date.now()
|
|
369
|
+
await this.#connectReadClient()
|
|
370
|
+
const rows = await this.readNextEvents(
|
|
371
|
+
{
|
|
372
|
+
groupId: this.groupId,
|
|
373
|
+
cursor: this.#inMemoryCursor,
|
|
374
|
+
chunkSize: this.readChunkSize,
|
|
375
|
+
},
|
|
376
|
+
this.#readClient || this.client,
|
|
377
|
+
)
|
|
378
|
+
.catch(async(err) => {
|
|
379
|
+
if(err instanceof Error && err.message.includes('connection error')) {
|
|
380
|
+
await this.#unlockAndReleaseReadClient()
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
throw err
|
|
384
|
+
})
|
|
385
|
+
if(!rows.length) {
|
|
386
|
+
// if nothing is happening and there are no active checkpoints,
|
|
387
|
+
// we can just let the read client go
|
|
388
|
+
if(!this.#activeCheckpoints.length) {
|
|
389
|
+
await this.#unlockAndReleaseReadClient()
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
return 0
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const uqSubIds = Array.from(
|
|
396
|
+
new Set(rows.flatMap((r) => r.subscriptionIds)),
|
|
397
|
+
)
|
|
398
|
+
const webhookSubs = await this.getWebhookInfo(uqSubIds)
|
|
399
|
+
let webhookCount = 0
|
|
400
|
+
|
|
401
|
+
for(const sid in webhookSubs) {
|
|
402
|
+
const webhooks = webhookSubs[sid]
|
|
403
|
+
const lts = (this.listeners[sid] ||= { values: {} })
|
|
404
|
+
for(const wh of webhooks) {
|
|
405
|
+
// add reliable listener for each webhook
|
|
406
|
+
lts.values[wh.id] ||= {
|
|
407
|
+
type: 'reliable',
|
|
408
|
+
queue: [],
|
|
409
|
+
extra: wh,
|
|
410
|
+
removeOnEmpty: true,
|
|
411
|
+
handler: this.webhookHandler,
|
|
412
|
+
...this.#webhookHandlerOpts
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
webhookCount++
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const { map: subToEventMap, retryEvents, retryItemCount }
|
|
420
|
+
= await normaliseRetryEventsInReadEventMap<T>(
|
|
421
|
+
rows, this.client, this.findEvents
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
const subs = Object.entries(subToEventMap)
|
|
425
|
+
const checkpoint: Checkpoint = {
|
|
426
|
+
activeTasks: 0,
|
|
427
|
+
nextCursor: rows[0].nextCursor,
|
|
428
|
+
}
|
|
429
|
+
for(const [subId, evs] of subs) {
|
|
430
|
+
const listeners = this.listeners[subId]?.values
|
|
431
|
+
if(!listeners) {
|
|
432
|
+
continue
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
for(const ev of evs) {
|
|
436
|
+
for(const lid in listeners) {
|
|
437
|
+
if(ev.retry?.handlerName && lid !== ev.retry.handlerName) {
|
|
438
|
+
continue
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const lt = listeners[lid]
|
|
442
|
+
if(lt.type === 'fire-and-forget') {
|
|
443
|
+
lt.stream.enqueue(ev)
|
|
444
|
+
continue
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
this.#enqueueEventInReliableListener(subId, lid, ev, checkpoint)
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
this.#activeCheckpoints.push(checkpoint)
|
|
453
|
+
this.#inMemoryCursor = checkpoint.nextCursor
|
|
454
|
+
|
|
455
|
+
this.logger.debug(
|
|
456
|
+
{
|
|
457
|
+
rowsRead: rows.length,
|
|
458
|
+
subscriptions: subs.length,
|
|
459
|
+
durationMs: Date.now() - now,
|
|
460
|
+
checkpoint,
|
|
461
|
+
activeCheckpoints: this.#activeCheckpoints.length,
|
|
462
|
+
webhookCount,
|
|
463
|
+
retryEvents,
|
|
464
|
+
retryItemCount,
|
|
465
|
+
},
|
|
466
|
+
'read rows',
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
if(!checkpoint.activeTasks && this.#activeCheckpoints.length === 1) {
|
|
470
|
+
await this.#updateCursorFromCompletedCheckpoints()
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
return rows.length
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Runs the reliable listener's handler for each item in its queue,
|
|
478
|
+
* one after the other, till the queue is empty or the client has ended.
|
|
479
|
+
* Any errors are logged, swallowed, and processing continues.
|
|
480
|
+
*/
|
|
481
|
+
async #enqueueEventInReliableListener(
|
|
482
|
+
subId: string,
|
|
483
|
+
lid: string,
|
|
484
|
+
item: IReadEvent<T>,
|
|
485
|
+
checkpoint: Checkpoint,
|
|
486
|
+
) {
|
|
487
|
+
const lt = this.listeners[subId]?.values?.[lid]
|
|
488
|
+
assert(lt?.type === 'reliable', 'invalid listener type: ' + lt.type)
|
|
489
|
+
|
|
490
|
+
const {
|
|
491
|
+
handler, queue, removeOnEmpty, extra,
|
|
492
|
+
splitBy = defaultSplitBy
|
|
493
|
+
} = lt
|
|
494
|
+
|
|
495
|
+
queue.push({ item, checkpoint })
|
|
496
|
+
checkpoint.activeTasks++
|
|
497
|
+
if(queue.length > 1) {
|
|
498
|
+
return
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
while(queue.length) {
|
|
502
|
+
const { item, checkpoint } = queue[0]
|
|
503
|
+
if(checkpoint.cancelled) {
|
|
504
|
+
queue.shift()
|
|
505
|
+
continue
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
const logger = this.logger.child({
|
|
509
|
+
subId,
|
|
510
|
+
items: item.items.map((i) => i.id),
|
|
511
|
+
extra,
|
|
512
|
+
retryNumber: item.retry?.retryNumber,
|
|
513
|
+
})
|
|
514
|
+
|
|
515
|
+
logger.trace(
|
|
516
|
+
{
|
|
517
|
+
cpActiveTasks: checkpoint.activeTasks,
|
|
518
|
+
queue: queue.length,
|
|
519
|
+
},
|
|
520
|
+
'processing handler queue',
|
|
521
|
+
)
|
|
522
|
+
|
|
523
|
+
try {
|
|
524
|
+
for(const batch of splitBy(item)) {
|
|
525
|
+
await handler(batch, {
|
|
526
|
+
client: this.client,
|
|
527
|
+
logger: this.logger.child({
|
|
528
|
+
subId,
|
|
529
|
+
items: batch.items.map(i => i.id),
|
|
530
|
+
extra,
|
|
531
|
+
retryNumber: item.retry?.retryNumber,
|
|
532
|
+
}),
|
|
533
|
+
subscriptionId: subId,
|
|
534
|
+
extra,
|
|
535
|
+
name: lid,
|
|
536
|
+
})
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
checkpoint.activeTasks--
|
|
540
|
+
assert(
|
|
541
|
+
checkpoint.activeTasks >= 0,
|
|
542
|
+
'internal: checkpoint.activeTasks < 0',
|
|
543
|
+
)
|
|
544
|
+
if(!checkpoint.activeTasks) {
|
|
545
|
+
await this.#updateCursorFromCompletedCheckpoints()
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
logger.trace(
|
|
549
|
+
{
|
|
550
|
+
cpActiveTasks: checkpoint.activeTasks,
|
|
551
|
+
queue: queue.length,
|
|
552
|
+
},
|
|
553
|
+
'completed handler task',
|
|
554
|
+
)
|
|
555
|
+
} catch(err) {
|
|
556
|
+
logger.error(
|
|
557
|
+
{ err },
|
|
558
|
+
'error in handler,' +
|
|
559
|
+
'cancelling all active checkpoints' +
|
|
560
|
+
'. Restarting from last known good cursor.',
|
|
561
|
+
)
|
|
562
|
+
this.#cancelAllActiveCheckpoints()
|
|
563
|
+
} finally {
|
|
564
|
+
queue.shift()
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
if(removeOnEmpty) {
|
|
569
|
+
return this.#removeListener(subId, lid)
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* Goes through all checkpoints, and sets the group cursor to the latest
|
|
575
|
+
* completed checkpoint. If a checkpoint has active tasks, stops there.
|
|
576
|
+
* This ensures that we don't accidentally move the cursor forward while
|
|
577
|
+
* there are still pending tasks for earlier checkpoints.
|
|
578
|
+
*/
|
|
579
|
+
async #updateCursorFromCompletedCheckpoints() {
|
|
580
|
+
let latestMaxCursor: string | undefined
|
|
581
|
+
while(this.#activeCheckpoints.length) {
|
|
582
|
+
const cp = this.#activeCheckpoints[0]
|
|
583
|
+
if(cp.activeTasks > 0) {
|
|
584
|
+
break
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
latestMaxCursor = cp.nextCursor
|
|
588
|
+
this.#activeCheckpoints.shift()
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
if(!latestMaxCursor) {
|
|
592
|
+
return
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
const releaseLock = !this.#activeCheckpoints.length
|
|
596
|
+
await setGroupCursor.run(
|
|
597
|
+
{
|
|
598
|
+
groupId: this.groupId,
|
|
599
|
+
cursor: latestMaxCursor,
|
|
600
|
+
releaseLock: releaseLock,
|
|
601
|
+
},
|
|
602
|
+
this.#readClient || this.client,
|
|
603
|
+
)
|
|
604
|
+
this.logger.debug(
|
|
605
|
+
{
|
|
606
|
+
cursor: latestMaxCursor,
|
|
607
|
+
activeCheckpoints: this.#activeCheckpoints.length,
|
|
608
|
+
},
|
|
609
|
+
'set cursor',
|
|
610
|
+
)
|
|
611
|
+
|
|
612
|
+
// if there are no more active checkpoints,
|
|
613
|
+
// clear in-memory cursor, so in case another process takes
|
|
614
|
+
// over, if & when we start reading again, we read from the DB cursor
|
|
615
|
+
if(releaseLock) {
|
|
616
|
+
this.#inMemoryCursor = null
|
|
617
|
+
this.#releaseReadClient()
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
#cancelAllActiveCheckpoints() {
|
|
622
|
+
for(const cp of this.#activeCheckpoints) {
|
|
623
|
+
cp.cancelled = true
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
this.#activeCheckpoints = []
|
|
627
|
+
this.#inMemoryCursor = null
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
async #unlockAndReleaseReadClient() {
|
|
631
|
+
if(!this.#readClient) {
|
|
632
|
+
return
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
try {
|
|
636
|
+
await releaseGroupLock.run({ groupId: this.groupId }, this.#readClient)
|
|
637
|
+
} catch(err) {
|
|
638
|
+
this.logger.error({ err }, 'error releasing read client')
|
|
639
|
+
} finally {
|
|
640
|
+
this.#releaseReadClient()
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
async #connectReadClient() {
|
|
645
|
+
if(!('connect' in this.client)) {
|
|
646
|
+
return false
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
if(this.#readClient) {
|
|
650
|
+
return true
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
this.#readClient = await this.client.connect()
|
|
654
|
+
this.logger.trace('acquired dedicated read client')
|
|
655
|
+
return true
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
#onPoolClientRemoved = async(cl: PgReleasableClient) => {
|
|
659
|
+
if(cl !== this.#readClient) {
|
|
660
|
+
return
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
this.logger.info(
|
|
664
|
+
'dedicated read client disconnected, may have dup event processing',
|
|
665
|
+
)
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
#releaseReadClient() {
|
|
669
|
+
try {
|
|
670
|
+
this.#readClient?.release()
|
|
671
|
+
} catch{}
|
|
672
|
+
|
|
673
|
+
this.#readClient = undefined
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
async #startLoop(fn: Function, sleepDurationMs: number) {
|
|
677
|
+
const signal = this.#endAc.signal
|
|
678
|
+
while(!signal.aborted) {
|
|
679
|
+
try {
|
|
680
|
+
await setTimeout(sleepDurationMs, undefined, { signal })
|
|
681
|
+
await fn.call(this)
|
|
682
|
+
} catch(err) {
|
|
683
|
+
if(err instanceof Error && err.name === 'AbortError') {
|
|
684
|
+
return
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
this.logger.error({ err, fn: fn.name }, 'error in task')
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
function createListenerId() {
|
|
694
|
+
return Math.random().toString(16).slice(2, 10)
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
function defaultSplitBy<T extends IEventData>(e: IReadEvent<T>) {
|
|
698
|
+
return [e]
|
|
699
|
+
}
|
package/src/consts.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const RETRY_EVENT = 'pgmb-retry'
|