@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 +27 -0
- package/lib/client.js +23 -15
- package/lib/queries.js +20 -1
- package/lib/utils.js +11 -0
- package/package.json +13 -6
- package/sql/pgmb-0.2.0-0.2.8.sql +81 -0
- package/sql/pgmb.sql +90 -35
- package/sql/queries.sql +9 -0
- package/src/abortable-async-iterator.ts +98 -0
- package/src/batcher.ts +90 -0
- package/src/client.ts +704 -0
- package/src/consts.ts +1 -0
- package/src/index.ts +6 -0
- package/src/queries.ts +630 -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 -6
- 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,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
|
-
|
|
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);
|
|
@@ -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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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": "
|
|
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
|
-
"
|
|
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": "^
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|
+
}
|