@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/README.md
CHANGED
|
@@ -32,6 +32,9 @@ Install PGMB by running the following command:
|
|
|
32
32
|
npm install @haathie/pgmb
|
|
33
33
|
```
|
|
34
34
|
|
|
35
|
+
Note: PGMB directly exports typescript files, so if you're using a bundler -- ensure it can handle typescript files. NodeJs (v22+), Deno, Bun can all run typescript files natively -- so about time we utilised this!
|
|
36
|
+
For commonjs compatibility, the compiled JS files are also exported.
|
|
37
|
+
|
|
35
38
|
Before using PGMB, you'll need to run the setup script to create the required tables, functions & triggers in your database. You can do this by running:
|
|
36
39
|
|
|
37
40
|
```sh
|
|
@@ -494,6 +497,34 @@ const pgmb = new PgmbClient({
|
|
|
494
497
|
})
|
|
495
498
|
```
|
|
496
499
|
|
|
500
|
+
## Configuring Knobs
|
|
501
|
+
|
|
502
|
+
PGMB provides a number of configuration options to tune its behaviour (eg. how often to poll for events, read events, expire subscriptions, etc.). These can be configured via relevant env vars too, the names for which can be found [here](src/client.ts#L105)
|
|
503
|
+
``` ts
|
|
504
|
+
// poll every 500ms
|
|
505
|
+
process.env.PGMB_READ_EVENTS_INTERVAL_MS = '500' // default: 1000
|
|
506
|
+
const pgmb = new PgmbClient(opts)
|
|
507
|
+
```
|
|
508
|
+
|
|
509
|
+
## Production Considerations
|
|
510
|
+
|
|
511
|
+
PGMB relies on 2 functions that need to be run periodically & only once globally to ensure smooth operation, i.e.
|
|
512
|
+
1. `poll_for_events()` -- finds unread events & assigns them to relevant subscriptions.
|
|
513
|
+
It's okay if this runs simultaneously in multiple processes, but that can create unnecessary contention on the `unread_events` table, which can bubble up to other tables.
|
|
514
|
+
2. `maintain_events_table()` -- removes old partitions & creates new partitions for the events table. It's also okay if this runs simultaneously in multiple processes, as it has advisory locks to ensure only a single process is maintaining the events table at any time, but running this too frequently can cause unnecessary overhead.
|
|
515
|
+
|
|
516
|
+
If you're only running a single instance of your service, you can simply run these functions in the same process as your PGMB client (default behaviour).
|
|
517
|
+
However, for larger deployments with multiple instances of your service, it's recommended to run these functions in a separate process to avoid contention, and disable polling & table maintainence:
|
|
518
|
+
``` ts
|
|
519
|
+
const pgmb = new PgmbClient({
|
|
520
|
+
pollEventsIntervalMs: 0,
|
|
521
|
+
tableMaintainanceMs: 0,
|
|
522
|
+
...otherOpts
|
|
523
|
+
})
|
|
524
|
+
```
|
|
525
|
+
|
|
526
|
+
Something like [pg_cron](https://github.com/citusdata/pg_cron) is a good option.
|
|
527
|
+
|
|
497
528
|
## General Notes
|
|
498
529
|
|
|
499
530
|
- **Does the client automatically reconnect on errors & temporary network issues?**
|
package/lib/client.js
CHANGED
|
@@ -11,12 +11,14 @@ const abortable_async_iterator_ts_1 = require("./abortable-async-iterator.js");
|
|
|
11
11
|
const batcher_ts_1 = require("./batcher.js");
|
|
12
12
|
const queries_ts_1 = require("./queries.js");
|
|
13
13
|
const retry_handler_ts_1 = require("./retry-handler.js");
|
|
14
|
+
const utils_ts_1 = require("./utils.js");
|
|
14
15
|
const webhook_handler_ts_1 = require("./webhook-handler.js");
|
|
15
16
|
class PgmbClient extends batcher_ts_1.PGMBEventBatcher {
|
|
16
17
|
client;
|
|
17
18
|
logger;
|
|
18
19
|
groupId;
|
|
19
|
-
|
|
20
|
+
readEventsIntervalMs;
|
|
21
|
+
pollEventsIntervalMs;
|
|
20
22
|
readChunkSize;
|
|
21
23
|
subscriptionMaintenanceMs;
|
|
22
24
|
tableMaintenanceMs;
|
|
@@ -29,14 +31,13 @@ class PgmbClient extends batcher_ts_1.PGMBEventBatcher {
|
|
|
29
31
|
#webhookHandlerOpts;
|
|
30
32
|
#readClient;
|
|
31
33
|
#endAc = new AbortController();
|
|
32
|
-
#shouldPoll;
|
|
33
34
|
#readTask;
|
|
34
35
|
#pollTask;
|
|
35
36
|
#subMaintainTask;
|
|
36
37
|
#tableMaintainTask;
|
|
37
38
|
#inMemoryCursor = null;
|
|
38
39
|
#activeCheckpoints = [];
|
|
39
|
-
constructor({ client, groupId, logger = (0, pino_1.pino)(),
|
|
40
|
+
constructor({ client, groupId, logger = (0, pino_1.pino)(), readEventsIntervalMs = (0, utils_ts_1.getEnvNumber)('PGMB_READ_EVENTS_INTERVAL_MS', 1000), readChunkSize = (0, utils_ts_1.getEnvNumber)('PGMB_READ_CHUNK_SIZE', 1000), maxActiveCheckpoints = (0, utils_ts_1.getEnvNumber)('PGMB_MAX_ACTIVE_CHECKPOINTS', 10), pollEventsIntervalMs = (0, utils_ts_1.getEnvNumber)('PGMB_POLL_EVENTS_INTERVAL_MS', 1000), subscriptionMaintenanceMs = (0, utils_ts_1.getEnvNumber)('PGMB_SUBSCRIPTION_MAINTENANCE_S', 60) * 1000, tableMaintainanceMs = (0, utils_ts_1.getEnvNumber)('PGMB_TABLE_MAINTENANCE_M', 15) * 60 * 1000, webhookHandlerOpts: { splitBy: whSplitBy, ...whHandlerOpts } = {}, getWebhookInfo = () => ({}), readNextEvents = queries_ts_1.readNextEvents.run.bind(queries_ts_1.readNextEvents), findEvents, ...batcherOpts }) {
|
|
40
41
|
super({
|
|
41
42
|
...batcherOpts,
|
|
42
43
|
logger,
|
|
@@ -45,9 +46,9 @@ class PgmbClient extends batcher_ts_1.PGMBEventBatcher {
|
|
|
45
46
|
this.client = client;
|
|
46
47
|
this.logger = logger;
|
|
47
48
|
this.groupId = groupId;
|
|
48
|
-
this.
|
|
49
|
+
this.readEventsIntervalMs = readEventsIntervalMs;
|
|
49
50
|
this.readChunkSize = readChunkSize;
|
|
50
|
-
this
|
|
51
|
+
this.pollEventsIntervalMs = pollEventsIntervalMs;
|
|
51
52
|
this.subscriptionMaintenanceMs = subscriptionMaintenanceMs;
|
|
52
53
|
this.maxActiveCheckpoints = maxActiveCheckpoints;
|
|
53
54
|
this.webhookHandler = (0, webhook_handler_ts_1.createWebhookHandler)(whHandlerOpts);
|
|
@@ -70,9 +71,9 @@ class PgmbClient extends batcher_ts_1.PGMBEventBatcher {
|
|
|
70
71
|
// clean up expired subscriptions on start
|
|
71
72
|
const [{ deleted }] = await queries_ts_1.removeExpiredSubscriptions.run({ groupId: this.groupId, activeIds: [] }, this.client);
|
|
72
73
|
this.logger.debug({ deleted }, 'removed expired subscriptions');
|
|
73
|
-
this.#readTask = this.#startLoop(this.readChanges.bind(this), this.
|
|
74
|
-
if (this
|
|
75
|
-
this.#pollTask = this.#startLoop(queries_ts_1.pollForEvents.run.bind(queries_ts_1.pollForEvents, undefined, this.client), this.
|
|
74
|
+
this.#readTask = this.#startLoop(this.readChanges.bind(this), this.readEventsIntervalMs);
|
|
75
|
+
if (this.pollEventsIntervalMs) {
|
|
76
|
+
this.#pollTask = this.#startLoop(queries_ts_1.pollForEvents.run.bind(queries_ts_1.pollForEvents, undefined, this.client), this.pollEventsIntervalMs);
|
|
76
77
|
}
|
|
77
78
|
if (this.subscriptionMaintenanceMs) {
|
|
78
79
|
this.#subMaintainTask = this.#startLoop(this.#maintainSubscriptions, this.subscriptionMaintenanceMs);
|
package/lib/index.js
CHANGED
package/lib/utils.js
CHANGED
|
@@ -6,6 +6,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
6
6
|
exports.getDateFromMessageId = getDateFromMessageId;
|
|
7
7
|
exports.getCreateDateFromSubscriptionId = getCreateDateFromSubscriptionId;
|
|
8
8
|
exports.createTopicalSubscriptionParams = createTopicalSubscriptionParams;
|
|
9
|
+
exports.getEnvNumber = getEnvNumber;
|
|
9
10
|
const node_assert_1 = __importDefault(require("node:assert"));
|
|
10
11
|
/**
|
|
11
12
|
* Extract the date from a message ID, same as the PG function
|
|
@@ -50,3 +51,13 @@ function createTopicalSubscriptionParams({ topics, partition, additionalFilters
|
|
|
50
51
|
...rest
|
|
51
52
|
};
|
|
52
53
|
}
|
|
54
|
+
/**
|
|
55
|
+
* Get an environment variable as a number
|
|
56
|
+
*/
|
|
57
|
+
function getEnvNumber(key, defaultValue = 0) {
|
|
58
|
+
const num = +(process.env[key] || defaultValue);
|
|
59
|
+
if (isNaN(num) || !isFinite(num)) {
|
|
60
|
+
return defaultValue;
|
|
61
|
+
}
|
|
62
|
+
return num;
|
|
63
|
+
}
|
package/package.json
CHANGED
|
@@ -1,17 +1,23 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@haathie/pgmb",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.7",
|
|
4
4
|
"description": "PG message broker, with a type-safe typescript client with built-in webhook & SSE support.",
|
|
5
|
-
"main": "lib/index.js",
|
|
6
5
|
"publishConfig": {
|
|
7
6
|
"registry": "https://registry.npmjs.org",
|
|
8
7
|
"access": "public"
|
|
9
8
|
},
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"import": "./src/index.ts",
|
|
12
|
+
"types": "./src/index.ts",
|
|
13
|
+
"require": "./lib/index.js"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
10
16
|
"repository": "https://github.com/haathie/pgmb",
|
|
11
17
|
"scripts": {
|
|
12
18
|
"test": "TZ=UTC NODE_ENV=test node --env-file ./.env.test --test tests/*.test.ts",
|
|
13
|
-
"prepare": "
|
|
14
|
-
"build": "tsc",
|
|
19
|
+
"prepare": "npm run build",
|
|
20
|
+
"build": "tsc -p tsconfig.build.json",
|
|
15
21
|
"lint": "eslint ./ --ext .js,.ts,.jsx,.tsx",
|
|
16
22
|
"lint:fix": "eslint ./ --fix --ext .js,.ts,.jsx,.tsx",
|
|
17
23
|
"benchmark": "TZ=utc node --env-file ./.env.test src/benchmark/run.ts",
|
|
@@ -27,13 +33,14 @@
|
|
|
27
33
|
"@types/pg": "^8.11.14",
|
|
28
34
|
"amqplib": "^0.10.7",
|
|
29
35
|
"chance": "^1.1.12",
|
|
30
|
-
"eslint": "^
|
|
36
|
+
"eslint": "^9.0.0",
|
|
31
37
|
"eventsource": "^4.1.0",
|
|
32
38
|
"pg": "^8.16.3",
|
|
33
39
|
"typescript": "^5.0.0"
|
|
34
40
|
},
|
|
35
41
|
"files": [
|
|
36
42
|
"lib",
|
|
43
|
+
"src",
|
|
37
44
|
"sql"
|
|
38
45
|
],
|
|
39
46
|
"keywords": [
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import assert from 'assert'
|
|
2
|
+
|
|
3
|
+
type AAResult<T> = IteratorResult<T>
|
|
4
|
+
|
|
5
|
+
export class AbortableAsyncIterator<T> implements AsyncIterableIterator<T> {
|
|
6
|
+
readonly signal: AbortSignal
|
|
7
|
+
readonly onEnd: () => void
|
|
8
|
+
|
|
9
|
+
ended = false
|
|
10
|
+
|
|
11
|
+
#resolve: (() => void) | undefined
|
|
12
|
+
#reject: ((reason?: unknown) => void) | undefined
|
|
13
|
+
#queue: T[] = []
|
|
14
|
+
#locked = false
|
|
15
|
+
|
|
16
|
+
constructor(signal: AbortSignal, onEnd: () => void = () => {}) {
|
|
17
|
+
this.signal = signal
|
|
18
|
+
this.onEnd = onEnd
|
|
19
|
+
signal.addEventListener('abort', this.#onAbort)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async next(): Promise<AAResult<T>> {
|
|
23
|
+
assert(!this.ended, 'Iterator has already been completed')
|
|
24
|
+
assert(!this.#locked, 'Concurrent calls to next() are not allowed')
|
|
25
|
+
|
|
26
|
+
let nextItem = this.#queue.shift()
|
|
27
|
+
if(nextItem) {
|
|
28
|
+
return { value: nextItem, done: false }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
this.#locked = true
|
|
32
|
+
try {
|
|
33
|
+
await this.#setupNextPromise()
|
|
34
|
+
} finally {
|
|
35
|
+
this.#locked = false
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
nextItem = this.#queue.shift()
|
|
39
|
+
if(nextItem) {
|
|
40
|
+
return { value: nextItem, done: false }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return { value: undefined, done: true }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
enqueue(value: T) {
|
|
47
|
+
assert(!this.ended, 'Iterator has already been completed')
|
|
48
|
+
this.#queue.push(value)
|
|
49
|
+
this.#resolve?.()
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
throw(reason?: unknown): Promise<AAResult<T>> {
|
|
53
|
+
this.signal.throwIfAborted()
|
|
54
|
+
this.#reject?.(reason)
|
|
55
|
+
this.#end()
|
|
56
|
+
return Promise.resolve({ done: true, value: undefined })
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return(value?: any): Promise<AAResult<T>> {
|
|
60
|
+
this.#resolve?.()
|
|
61
|
+
this.#end()
|
|
62
|
+
return Promise.resolve({ done: true, value })
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
#setupNextPromise() {
|
|
66
|
+
return new Promise<void>((resolve, reject) => {
|
|
67
|
+
this.#resolve = () => {
|
|
68
|
+
resolve()
|
|
69
|
+
this.#cleanupTask()
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
this.#reject = err => {
|
|
73
|
+
reject(err)
|
|
74
|
+
this.#cleanupTask()
|
|
75
|
+
}
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
#cleanupTask() {
|
|
80
|
+
this.#resolve = undefined
|
|
81
|
+
this.#reject = undefined
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
#onAbort = (reason: any) => {
|
|
85
|
+
this.#reject?.(reason)
|
|
86
|
+
this.#end()
|
|
87
|
+
this.ended = true
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
#end() {
|
|
91
|
+
this.signal.removeEventListener('abort', this.#onAbort)
|
|
92
|
+
this.onEnd()
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
[Symbol.asyncIterator]() {
|
|
96
|
+
return this
|
|
97
|
+
}
|
|
98
|
+
}
|
package/src/batcher.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { IEventData, PGMBEventBatcherOpts } from './types.ts'
|
|
2
|
+
|
|
3
|
+
type Batch<T> = {
|
|
4
|
+
messages: T[]
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export class PGMBEventBatcher<T extends IEventData> {
|
|
8
|
+
|
|
9
|
+
#publish: PGMBEventBatcherOpts<T>['publish']
|
|
10
|
+
#flushIntervalMs: number | undefined
|
|
11
|
+
#maxBatchSize: number
|
|
12
|
+
#currentBatch: Batch<T> = { messages: [] }
|
|
13
|
+
#flushTimeout: NodeJS.Timeout | undefined
|
|
14
|
+
#flushTask: Promise<void> | undefined
|
|
15
|
+
#logger: PGMBEventBatcherOpts<T>['logger']
|
|
16
|
+
#shouldLog?: PGMBEventBatcherOpts<T>['shouldLog']
|
|
17
|
+
#batch = 0
|
|
18
|
+
|
|
19
|
+
constructor({
|
|
20
|
+
shouldLog,
|
|
21
|
+
publish,
|
|
22
|
+
flushIntervalMs,
|
|
23
|
+
maxBatchSize = 2500,
|
|
24
|
+
logger
|
|
25
|
+
}: PGMBEventBatcherOpts<T>) {
|
|
26
|
+
this.#publish = publish
|
|
27
|
+
this.#flushIntervalMs = flushIntervalMs
|
|
28
|
+
this.#maxBatchSize = maxBatchSize
|
|
29
|
+
this.#logger = logger
|
|
30
|
+
this.#shouldLog = shouldLog
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async end() {
|
|
34
|
+
clearTimeout(this.#flushTimeout)
|
|
35
|
+
await this.#flushTask
|
|
36
|
+
await this.flush()
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Enqueue a message to be published, will be flushed to the database
|
|
41
|
+
* when flush() is called (either manually or via interval)
|
|
42
|
+
*/
|
|
43
|
+
enqueue(msg: T) {
|
|
44
|
+
this.#currentBatch.messages.push(msg)
|
|
45
|
+
if(this.#currentBatch.messages.length >= this.#maxBatchSize) {
|
|
46
|
+
this.flush()
|
|
47
|
+
return
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if(this.#flushTimeout || !this.#flushIntervalMs) {
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
this.#flushTimeout = setTimeout(() => this.flush(), this.#flushIntervalMs)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async flush() {
|
|
58
|
+
if(!this.#currentBatch.messages.length) {
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const batch = this.#currentBatch
|
|
63
|
+
this.#currentBatch = { messages: [] }
|
|
64
|
+
clearTimeout(this.#flushTimeout)
|
|
65
|
+
this.#flushTimeout = undefined
|
|
66
|
+
|
|
67
|
+
await this.#flushTask
|
|
68
|
+
|
|
69
|
+
this.#flushTask = this.#publishBatch(batch)
|
|
70
|
+
return this.#flushTask
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async #publishBatch({ messages }: Batch<T>) {
|
|
74
|
+
const batch = ++this.#batch
|
|
75
|
+
try {
|
|
76
|
+
const ids = await this.#publish(...messages)
|
|
77
|
+
for(const [i, { id }] of ids.entries()) {
|
|
78
|
+
if(this.#shouldLog && !this.#shouldLog(messages[i])) {
|
|
79
|
+
continue
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
this.#logger
|
|
83
|
+
?.info({ batch, id, message: messages[i] }, 'published message')
|
|
84
|
+
}
|
|
85
|
+
} catch(err) {
|
|
86
|
+
this.#logger
|
|
87
|
+
?.error({ batch, err, msgs: messages }, 'failed to publish messages')
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|