@haathie/pgmb 0.2.6 → 0.2.8

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,30 @@ 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 have the `pg_cron` extension installed, `pgmb` will automatically setup these functions to run periodically via `pg_cron` on initialization. The default intervals are:
517
+ - `poll_for_events()` -- every `1 second`
518
+ - `maintain_events_table()` -- on every `30th minute`
519
+
520
+ These can be easily configured by changing the values in the `config` table in the `pgmb` schema. Changes automatically get applied to the underlying cron jobs via triggers.
521
+
522
+ If you do not have `pg_cron` installed, PGMB will run these functions in the same process as the client itself, at the above intervals. This is okay for development & small deployments.
523
+
497
524
  ## General Notes
498
525
 
499
526
  - **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);
@@ -62,24 +63,31 @@ class PgmbClient extends batcher_ts_1.PGMBEventBatcher {
62
63
  if ('connect' in this.client) {
63
64
  this.client.on('remove', this.#onPoolClientRemoved);
64
65
  }
65
- // maintain event table
66
- await queries_ts_1.maintainEventsTable.run(undefined, this.client);
67
- this.logger.debug('maintained events table');
66
+ const [pgCronRslt] = await queries_ts_1.getConfigValue
67
+ .run({ key: 'use_pg_cron' }, this.client);
68
+ const isPgCronEnabled = pgCronRslt?.value === 'true';
69
+ if (!isPgCronEnabled) {
70
+ // maintain event table
71
+ await queries_ts_1.maintainEventsTable.run(undefined, this.client);
72
+ this.logger.debug('maintained events table');
73
+ if (this.pollEventsIntervalMs) {
74
+ this.#pollTask = this.#startLoop(queries_ts_1.pollForEvents.run.bind(queries_ts_1.pollForEvents, undefined, this.client), this.pollEventsIntervalMs);
75
+ }
76
+ if (this.tableMaintenanceMs) {
77
+ this.#tableMaintainTask = this.#startLoop(queries_ts_1.maintainEventsTable.run
78
+ .bind(queries_ts_1.maintainEventsTable, undefined, this.client), this.tableMaintenanceMs);
79
+ }
80
+ }
68
81
  await queries_ts_1.assertGroup.run({ id: this.groupId }, this.client);
69
82
  this.logger.debug({ groupId: this.groupId }, 'asserted group exists');
70
83
  // clean up expired subscriptions on start
71
84
  const [{ deleted }] = await queries_ts_1.removeExpiredSubscriptions.run({ groupId: this.groupId, activeIds: [] }, this.client);
72
85
  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);
76
- }
86
+ this.#readTask = this.#startLoop(this.readChanges.bind(this), this.readEventsIntervalMs);
77
87
  if (this.subscriptionMaintenanceMs) {
78
88
  this.#subMaintainTask = this.#startLoop(this.#maintainSubscriptions, this.subscriptionMaintenanceMs);
79
89
  }
80
- if (this.tableMaintenanceMs) {
81
- this.#tableMaintainTask = this.#startLoop(queries_ts_1.maintainEventsTable.run.bind(queries_ts_1.maintainEventsTable, undefined, this.client), this.tableMaintenanceMs);
82
- }
90
+ this.logger.info({ isPgCronEnabled }, 'pgmb client initialised');
83
91
  }
84
92
  async end() {
85
93
  await super.end();
package/lib/queries.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.maintainEventsTable = exports.removeExpiredSubscriptions = exports.findEvents = exports.scheduleEventRetry = exports.writeScheduledEvents = exports.writeEvents = exports.releaseGroupLock = exports.setGroupCursor = exports.replayEvents = exports.readNextEventsText = exports.readNextEvents = exports.pollForEvents = exports.markSubscriptionsActive = exports.deleteSubscriptions = exports.assertSubscription = exports.assertGroup = void 0;
3
+ exports.maintainEventsTable = exports.updateConfigValue = exports.getConfigValue = exports.removeExpiredSubscriptions = exports.findEvents = exports.scheduleEventRetry = exports.writeScheduledEvents = exports.writeEvents = exports.releaseGroupLock = exports.setGroupCursor = exports.replayEvents = exports.readNextEventsText = exports.readNextEvents = exports.pollForEvents = exports.markSubscriptionsActive = exports.deleteSubscriptions = exports.assertSubscription = exports.assertGroup = void 0;
4
4
  /** Types generated for queries found in "sql/queries.sql" */
5
5
  const runtime_1 = require("@pgtyped/runtime");
6
6
  const assertGroupIR = { "usedParamSet": { "id": true }, "params": [{ "name": "id", "required": true, "transform": { "type": "scalar" }, "locs": [{ "a": 50, "b": 53 }] }], "statement": "INSERT INTO pgmb.subscription_groups (id)\nVALUES (:id!)\nON CONFLICT DO NOTHING" };
@@ -225,6 +225,25 @@ const removeExpiredSubscriptionsIR = { "usedParamSet": { "groupId": true, "activ
225
225
  * ```
226
226
  */
227
227
  exports.removeExpiredSubscriptions = new runtime_1.PreparedQuery(removeExpiredSubscriptionsIR);
228
+ const getConfigValueIR = { "usedParamSet": { "key": true }, "params": [{ "name": "key", "required": true, "transform": { "type": "scalar" }, "locs": [{ "a": 29, "b": 33 }] }], "statement": "SELECT pgmb.get_config_value(:key!::pgmb.config_type) AS \"value\"" };
229
+ /**
230
+ * Query generated from SQL:
231
+ * ```
232
+ * SELECT pgmb.get_config_value(:key!::pgmb.config_type) AS "value"
233
+ * ```
234
+ */
235
+ exports.getConfigValue = new runtime_1.PreparedQuery(getConfigValueIR);
236
+ const updateConfigValueIR = { "usedParamSet": { "value": true, "key": true }, "params": [{ "name": "value", "required": true, "transform": { "type": "scalar" }, "locs": [{ "a": 31, "b": 37 }] }, { "name": "key", "required": true, "transform": { "type": "scalar" }, "locs": [{ "a": 56, "b": 60 }] }], "statement": "UPDATE pgmb.config\nSET value = :value!::TEXT\nWHERE id = :key!::pgmb.config_type\nRETURNING 1 AS \"updated!\"" };
237
+ /**
238
+ * Query generated from SQL:
239
+ * ```
240
+ * UPDATE pgmb.config
241
+ * SET value = :value!::TEXT
242
+ * WHERE id = :key!::pgmb.config_type
243
+ * RETURNING 1 AS "updated!"
244
+ * ```
245
+ */
246
+ exports.updateConfigValue = new runtime_1.PreparedQuery(updateConfigValueIR);
228
247
  const maintainEventsTableIR = { "usedParamSet": {}, "params": [], "statement": "SELECT pgmb.maintain_events_table()" };
229
248
  /**
230
249
  * Query generated from SQL:
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,21 +1,27 @@
1
1
  {
2
2
  "name": "@haathie/pgmb",
3
- "version": "0.2.6",
3
+ "version": "0.2.8",
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",
18
- "gen:pgtyped": "pgtyped --config ./pgtyped.config.json"
24
+ "pg:typegen": "pgtyped --config ./pgtyped.config.json"
19
25
  },
20
26
  "devDependencies": {
21
27
  "@adiwajshing/eslint-config": "git+https://github.com/adiwajshing/eslint-config",
@@ -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,81 @@
1
+ SET search_path TO pgmb;
2
+
3
+ ALTER TYPE config_type ADD VALUE 'use_pg_cron';
4
+ ALTER TYPE config_type ADD VALUE 'pg_cron_poll_for_events_cron';
5
+ ALTER TYPE config_type ADD VALUE 'pg_cron_partition_maintenance_cron';
6
+
7
+ INSERT INTO config(id, value) VALUES
8
+ ('poll_chunk_size', '10000'),
9
+ ('pg_cron_poll_for_events_cron', '1 second'),
10
+ -- every 30 minutes
11
+ ('pg_cron_partition_maintenance_cron', '*/30 * * * *');
12
+
13
+ CREATE OR REPLACE FUNCTION manage_cron_jobs_trigger_fn()
14
+ RETURNS TRIGGER AS $$
15
+ DECLARE
16
+ poll_job_name CONSTANT TEXT := 'pgmb_poll';
17
+ maintain_job_name CONSTANT TEXT := 'pgmb_maintain_table_partitions';
18
+ BEGIN
19
+ IF get_config_value('use_pg_cron') = 'true' THEN
20
+ -- Schedule/update event polling job
21
+ PERFORM cron.schedule(
22
+ poll_job_name,
23
+ get_config_value('pg_cron_poll_for_events_cron'),
24
+ $CMD$
25
+ -- ensure we don't accidentally run for too long
26
+ SET SESSION statement_timeout = '10s';
27
+ SELECT pgmb.poll_for_events();
28
+ $CMD$
29
+ );
30
+ RAISE LOG 'Scheduled pgmb polling job: %', poll_job_name;
31
+
32
+ -- Schedule/update partition maintenance job
33
+ PERFORM cron.schedule(
34
+ 'pgmb_maintain_table_partitions',
35
+ get_config_value('pg_cron_partition_maintenance_cron'),
36
+ $CMD$ SELECT pgmb.maintain_events_table(); $CMD$
37
+ );
38
+
39
+ RAISE LOG 'Scheduled pgmb partition maintenance job: %',
40
+ maintain_job_name;
41
+ ELSIF (SELECT 1 FROM pg_namespace WHERE nspname = 'cron') THEN
42
+ RAISE LOG 'Unscheduling pgmb cron jobs.';
43
+ -- Unschedule jobs. cron.unschedule does not fail if job does not exist.
44
+ PERFORM cron.unschedule(poll_job_name);
45
+ PERFORM cron.unschedule(maintain_job_name);
46
+ END IF;
47
+
48
+ RETURN NULL;
49
+ END;
50
+ $$ LANGUAGE plpgsql VOLATILE PARALLEL UNSAFE SECURITY DEFINER
51
+ SET search_path TO pgmb;
52
+
53
+ CREATE TRIGGER manage_cron_jobs_trigger
54
+ AFTER INSERT OR UPDATE OF value ON config
55
+ FOR EACH ROW
56
+ WHEN (
57
+ NEW.id IN (
58
+ 'use_pg_cron',
59
+ 'pg_cron_poll_for_events_cron',
60
+ 'pg_cron_partition_maintenance_cron'
61
+ )
62
+ )
63
+ EXECUTE FUNCTION manage_cron_jobs_trigger_fn();
64
+
65
+ DO $$
66
+ BEGIN
67
+ IF (
68
+ SELECT EXISTS (
69
+ SELECT 1
70
+ FROM pg_available_extensions
71
+ WHERE name = 'pg_cron'
72
+ )
73
+ ) THEN
74
+ CREATE EXTENSION IF NOT EXISTS pg_cron;
75
+ INSERT INTO config(id, value) VALUES ('use_pg_cron', 'true');
76
+ ELSE
77
+ RAISE LOG 'pg_cron extension not available. Skipping pg_cron setup.';
78
+ INSERT INTO config(id, value) VALUES ('use_pg_cron', 'false');
79
+ END IF;
80
+ END
81
+ $$;
package/sql/pgmb.sql CHANGED
@@ -22,7 +22,10 @@ CREATE TYPE config_type AS ENUM(
22
22
  -- how far into the future to create partitions
23
23
  'future_intervals_to_create',
24
24
  'partition_interval',
25
- 'poll_chunk_size'
25
+ 'poll_chunk_size',
26
+ 'use_pg_cron',
27
+ 'pg_cron_poll_for_events_cron',
28
+ 'pg_cron_partition_maintenance_cron'
26
29
  );
27
30
 
28
31
  CREATE TABLE IF NOT EXISTS config(
@@ -37,13 +40,15 @@ CREATE OR REPLACE FUNCTION get_config_value(
37
40
  SELECT value FROM config WHERE id = config_id
38
41
  $$ LANGUAGE sql STRICT STABLE PARALLEL SAFE SET SEARCH_PATH TO pgmb;
39
42
 
40
- INSERT INTO config(id, value)
41
- VALUES
42
- ('plugin_version', '0.2.0'),
43
- ('partition_retention_period', '60 minutes'),
44
- ('future_intervals_to_create', '120 minutes'),
45
- ('partition_interval', '30 minutes'),
46
- ('poll_chunk_size', '10000');
43
+ INSERT INTO config(id, value) VALUES
44
+ ('plugin_version', '0.2.0'),
45
+ ('partition_retention_period', '60 minutes'),
46
+ ('future_intervals_to_create', '120 minutes'),
47
+ ('partition_interval', '30 minutes'),
48
+ ('poll_chunk_size', '10000'),
49
+ ('pg_cron_poll_for_events_cron', '1 second'),
50
+ -- every 30 minutes
51
+ ('pg_cron_partition_maintenance_cron', '*/30 * * * *');
47
52
 
48
53
  -- we'll create the events table next & its functions ---------------
49
54
 
@@ -365,33 +370,11 @@ CREATE INDEX ON subscriptions(
365
370
  add_interval_imm(last_active_at, expiry_interval)
366
371
  ) WHERE expiry_interval IS NOT NULL;
367
372
 
368
- DO $$
369
- DECLARE
370
- has_btree_gin BOOLEAN;
371
- BEGIN
372
- has_btree_gin := (
373
- SELECT EXISTS (
374
- SELECT 1
375
- FROM pg_available_extensions
376
- WHERE name = 'btree_gin'
377
- )
378
- );
379
- -- create btree_gin extension if not exists, if the extension
380
- -- is not available, we create a simpler regular GIN index instead.
381
- IF has_btree_gin THEN
382
- CREATE EXTENSION IF NOT EXISTS btree_gin;
383
- -- fastupdate=false, slows down subscription creation, but ensures the costlier
384
- -- "poll_for_events" function is executed faster.
385
- CREATE INDEX "sub_gin" ON subscriptions USING GIN(conditions_sql, params)
386
- WITH (fastupdate = false);
387
- ELSE
388
- RAISE NOTICE 'btree_gin extension is not available, using
389
- regular GIN index for subscriptions.params';
390
- CREATE INDEX "sub_gin" ON subscriptions USING GIN(params)
391
- WITH (fastupdate = false);
392
- END IF;
393
- END
394
- $$;
373
+ -- fastupdate=false, slows down subscription creation, but ensures the costlier
374
+ -- "poll_for_events" function is executed faster.
375
+ CREATE EXTENSION IF NOT EXISTS btree_gin;
376
+ CREATE INDEX "sub_gin" ON subscriptions
377
+ USING GIN(conditions_sql, params) WITH (fastupdate = false);
395
378
 
396
379
  -- materialized view to hold distinct conditions_sql statements.
397
380
  -- We utilise changes in this view to determine when to prepare the
@@ -863,6 +846,78 @@ SET search_path TO pgmb;
863
846
  -- create the initial partitions
864
847
  SELECT maintain_events_table();
865
848
 
849
+ -- setup pg_cron if it's available ----------------
850
+
851
+ CREATE OR REPLACE FUNCTION manage_cron_jobs_trigger_fn()
852
+ RETURNS TRIGGER AS $$
853
+ DECLARE
854
+ poll_job_name CONSTANT TEXT := 'pgmb_poll';
855
+ maintain_job_name CONSTANT TEXT := 'pgmb_maintain_table_partitions';
856
+ BEGIN
857
+ IF get_config_value('use_pg_cron') = 'true' THEN
858
+ -- Schedule/update event polling job
859
+ PERFORM cron.schedule(
860
+ poll_job_name,
861
+ get_config_value('pg_cron_poll_for_events_cron'),
862
+ $CMD$
863
+ -- ensure we don't accidentally run for too long
864
+ SET SESSION statement_timeout = '10s';
865
+ SELECT pgmb.poll_for_events();
866
+ $CMD$
867
+ );
868
+ RAISE LOG 'Scheduled pgmb polling job: %', poll_job_name;
869
+
870
+ -- Schedule/update partition maintenance job
871
+ PERFORM cron.schedule(
872
+ 'pgmb_maintain_table_partitions',
873
+ get_config_value('pg_cron_partition_maintenance_cron'),
874
+ $CMD$ SELECT pgmb.maintain_events_table(); $CMD$
875
+ );
876
+
877
+ RAISE LOG 'Scheduled pgmb partition maintenance job: %',
878
+ maintain_job_name;
879
+ ELSIF (SELECT 1 FROM pg_namespace WHERE nspname = 'cron') THEN
880
+ RAISE LOG 'Unscheduling pgmb cron jobs.';
881
+ -- Unschedule jobs. cron.unschedule does not fail if job does not exist.
882
+ PERFORM cron.unschedule(poll_job_name);
883
+ PERFORM cron.unschedule(maintain_job_name);
884
+ END IF;
885
+
886
+ RETURN NULL;
887
+ END;
888
+ $$ LANGUAGE plpgsql VOLATILE PARALLEL UNSAFE SECURITY DEFINER
889
+ SET search_path TO pgmb;
890
+
891
+ CREATE TRIGGER manage_cron_jobs_trigger
892
+ AFTER INSERT OR UPDATE OF value ON config
893
+ FOR EACH ROW
894
+ WHEN (
895
+ NEW.id IN (
896
+ 'use_pg_cron',
897
+ 'pg_cron_poll_for_events_cron',
898
+ 'pg_cron_partition_maintenance_cron'
899
+ )
900
+ )
901
+ EXECUTE FUNCTION manage_cron_jobs_trigger_fn();
902
+
903
+ DO $$
904
+ BEGIN
905
+ IF (
906
+ SELECT EXISTS (
907
+ SELECT 1
908
+ FROM pg_available_extensions
909
+ WHERE name = 'pg_cron'
910
+ )
911
+ ) THEN
912
+ CREATE EXTENSION IF NOT EXISTS pg_cron;
913
+ INSERT INTO config(id, value) VALUES ('use_pg_cron', 'true');
914
+ ELSE
915
+ RAISE LOG 'pg_cron extension not available. Skipping pg_cron setup.';
916
+ INSERT INTO config(id, value) VALUES ('use_pg_cron', 'false');
917
+ END IF;
918
+ END
919
+ $$;
920
+
866
921
  -- triggers to add events for specific tables ---------------------------
867
922
 
868
923
  -- Function to create a topic string for subscriptions.
package/sql/queries.sql CHANGED
@@ -150,5 +150,14 @@ WITH deleted AS (
150
150
  )
151
151
  SELECT COUNT(*) AS "deleted!" FROM deleted;
152
152
 
153
+ /* @name getConfigValue */
154
+ SELECT pgmb.get_config_value(:key!::pgmb.config_type) AS "value";
155
+
156
+ /* @name updateConfigValue */
157
+ UPDATE pgmb.config
158
+ SET value = :value!::TEXT
159
+ WHERE id = :key!::pgmb.config_type
160
+ RETURNING 1 AS "updated!";
161
+
153
162
  /* @name maintainEventsTable */
154
163
  SELECT pgmb.maintain_events_table();
@@ -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
+ }