@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 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
- sleepDurationMs;
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)(), sleepDurationMs = 750, readChunkSize = 1000, maxActiveCheckpoints = 10, poll, subscriptionMaintenanceMs = 60 * 1000, webhookHandlerOpts: { splitBy: whSplitBy, ...whHandlerOpts } = {}, getWebhookInfo = () => ({}), tableMaintainanceMs = 5 * 60 * 1000, readNextEvents = queries_ts_1.readNextEvents.run.bind(queries_ts_1.readNextEvents), findEvents, ...batcherOpts }) {
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.sleepDurationMs = sleepDurationMs;
49
+ this.readEventsIntervalMs = readEventsIntervalMs;
49
50
  this.readChunkSize = readChunkSize;
50
- this.#shouldPoll = !!poll;
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.sleepDurationMs);
74
- if (this.#shouldPoll) {
75
- this.#pollTask = this.#startLoop(queries_ts_1.pollForEvents.run.bind(queries_ts_1.pollForEvents, undefined, this.client), this.sleepDurationMs);
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
@@ -17,3 +17,4 @@ Object.defineProperty(exports, "__esModule", { value: true });
17
17
  __exportStar(require("./client.js"), exports);
18
18
  __exportStar(require("./utils.js"), exports);
19
19
  __exportStar(require("./sse.js"), exports);
20
+ __exportStar(require("./queries.js"), exports);
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.5",
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": "tsc",
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": "^8.19.0",
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
+ }