@pgpm/database-jobs 0.21.2 → 0.22.0

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/Makefile CHANGED
@@ -1,5 +1,5 @@
1
1
  EXTENSION = pgpm-database-jobs
2
- DATA = sql/pgpm-database-jobs--0.15.3.sql
2
+ DATA = sql/pgpm-database-jobs--0.22.0.sql
3
3
 
4
4
  PG_CONFIG = pg_config
5
5
  PGXS := $(shell $(PG_CONFIG) --pgxs)
package/README.md CHANGED
@@ -87,9 +87,7 @@ pgpm deploy --createdb --database mydb1
87
87
 
88
88
  The `app_jobs.jobs` table stores active jobs with the following key fields:
89
89
  - `id`: Unique job identifier
90
- - `database_id`: Database/tenant identifier (nullable, read from JWT claims internally)
91
- - `actor_id`: User who triggered the job (nullable, read from JWT claims internally)
92
- - `queue_name`: Optional queue name (default: NULL)
90
+ - `database_id`: Database/tenant identifier
93
91
  - `task_identifier`: Job type/handler name
94
92
  - `payload`: JSON data for the job
95
93
  - `priority`: Lower numbers = higher priority (default: 0)
@@ -98,7 +96,6 @@ The `app_jobs.jobs` table stores active jobs with the following key fields:
98
96
  - `max_attempts`: Maximum retry attempts (default: 25)
99
97
  - `locked_by`: Worker ID that locked this job
100
98
  - `locked_at`: When the job was locked
101
- - `is_available`: Generated column (`locked_at IS NULL AND attempts < max_attempts`) — used by partial indexes for fast dequeue
102
99
  - `key`: Optional unique key for upsert semantics
103
100
 
104
101
  ### Scheduled Jobs Table
@@ -109,27 +106,21 @@ The `app_jobs.scheduled_jobs` table stores recurring jobs with cron-style or rul
109
106
 
110
107
  The `app_jobs.job_queues` table tracks queue statistics and locking state.
111
108
 
112
- ### Performance
113
-
114
- The job queue uses partial covering indexes for fast dequeue:
115
- - `jobs_main_index`: `btree (priority, run_at) INCLUDE (id, queue_name) WHERE is_available = true` — only indexes pending jobs
116
- - `jobs_no_queue_index`: `btree (priority, run_at) INCLUDE (id) WHERE is_available = true AND queue_name IS NULL` — optimizes the common case (no named queue)
117
-
118
- The `is_available` generated column automatically maintains index membership as jobs complete. `database_id` and `actor_id` have **no indexes** — they are envelope metadata, not used in the queue's hot path.
119
-
120
109
  ## Usage
121
110
 
122
111
  ### Adding Jobs
123
112
 
124
113
  ```sql
125
- -- Add a simple job (database_id and actor_id are read from JWT claims automatically)
114
+ -- Add a simple job
126
115
  SELECT app_jobs.add_job(
116
+ db_id := '5b720132-17d5-424d-9bcb-ee7b17c13d43'::uuid,
127
117
  identifier := 'send_email',
128
118
  payload := '{"to": "user@example.com", "subject": "Hello"}'::json
129
119
  );
130
120
 
131
121
  -- Add a job with priority and delayed execution
132
122
  SELECT app_jobs.add_job(
123
+ db_id := '5b720132-17d5-424d-9bcb-ee7b17c13d43'::uuid,
133
124
  identifier := 'generate_report',
134
125
  payload := '{"report_id": 123}'::json,
135
126
  run_at := now() + interval '1 hour',
@@ -139,6 +130,7 @@ SELECT app_jobs.add_job(
139
130
 
140
131
  -- Add a job with a unique key (upsert semantics)
141
132
  SELECT app_jobs.add_job(
133
+ db_id := '5b720132-17d5-424d-9bcb-ee7b17c13d43'::uuid,
142
134
  identifier := 'daily_summary',
143
135
  payload := '{"date": "2025-01-15"}'::json,
144
136
  job_key := 'daily_summary_2025_01_15',
@@ -184,9 +176,26 @@ SELECT app_jobs.fail_job(
184
176
  ### Scheduled Jobs
185
177
 
186
178
  ```sql
179
+ -- Schedule a job with cron-style timing
180
+ INSERT INTO app_jobs.scheduled_jobs (
181
+ database_id,
182
+ task_identifier,
183
+ payload,
184
+ schedule_info
185
+ ) VALUES (
186
+ '5b720132-17d5-424d-9bcb-ee7b17c13d43'::uuid,
187
+ 'cleanup_old_data',
188
+ '{"days": 30}'::json,
189
+ '{
190
+ "hour": [2],
191
+ "minute": [0],
192
+ "dayOfWeek": [0, 1, 2, 3, 4, 5, 6]
193
+ }'::json
194
+ );
195
+
187
196
  -- Schedule a job with a rule (every minute for 3 minutes)
188
- -- database_id and actor_id are read from JWT claims automatically
189
197
  SELECT app_jobs.add_scheduled_job(
198
+ db_id := '5b720132-17d5-424d-9bcb-ee7b17c13d43'::uuid,
190
199
  identifier := 'heartbeat',
191
200
  payload := '{}'::json,
192
201
  schedule_info := json_build_object(
@@ -204,13 +213,14 @@ SELECT * FROM app_jobs.run_scheduled_job(scheduled_job_id := 1);
204
213
 
205
214
  ### app_jobs.add_job(...)
206
215
 
207
- Adds a new job to the queue or updates an existing job if a key is provided. `database_id` and `actor_id` are read internally from JWT claims — no need to pass them.
216
+ Adds a new job to the queue or updates an existing job if a key is provided.
208
217
 
209
218
  **Parameters:**
219
+ - `db_id` (uuid): Database/tenant identifier
210
220
  - `identifier` (text): Job type/handler name
211
221
  - `payload` (json): Job data (default: `{}`)
212
222
  - `job_key` (text): Optional unique key for upsert (default: NULL)
213
- - `queue_name` (text): Optional queue name (default: NULL)
223
+ - `queue_name` (text): Optional queue name (default: random UUID)
214
224
  - `run_at` (timestamptz): When to run (default: now())
215
225
  - `max_attempts` (integer): Maximum retries (default: 25)
216
226
  - `priority` (integer): Job priority (default: 0)
@@ -224,7 +234,7 @@ Adds a new job to the queue or updates an existing job if a key is provided. `da
224
234
 
225
235
  ### app_jobs.get_job(...)
226
236
 
227
- Fetches and locks the next available job for a worker. Uses partial indexes on `is_available = true` for fast dequeue.
237
+ Fetches and locks the next available job for a worker.
228
238
 
229
239
  **Parameters:**
230
240
  - `worker_id` (text): Unique worker identifier
@@ -234,7 +244,7 @@ Fetches and locks the next available job for a worker. Uses partial indexes on `
234
244
  **Returns:** `app_jobs.jobs` row or NULL
235
245
 
236
246
  **Behavior:**
237
- - Selects jobs by priority, run_at, and id using partial covering indexes
247
+ - Selects jobs by priority, run_at, and id
238
248
  - Locks the job and its queue
239
249
  - Increments attempt counter
240
250
  - Uses `FOR UPDATE SKIP LOCKED` for concurrency
@@ -267,16 +277,17 @@ Marks a job as failed and schedules retry if attempts remain.
267
277
 
268
278
  ### app_jobs.add_scheduled_job(...)
269
279
 
270
- Creates a scheduled job with cron-style or rule-based timing. `database_id` and `actor_id` are read internally from JWT claims.
280
+ Creates a scheduled job with cron-style or rule-based timing.
271
281
 
272
282
  **Parameters:**
283
+ - `db_id` (uuid): Database/tenant identifier
273
284
  - `identifier` (text): Job type/handler name
274
- - `payload` (json): Job data (default: `{}`)
275
- - `schedule_info` (json): Scheduling configuration (default: `{}`)
285
+ - `payload` (json): Job data
286
+ - `schedule_info` (json): Scheduling configuration
276
287
  - `job_key` (text): Optional unique key
277
288
  - `queue_name` (text): Optional queue name
278
- - `max_attempts` (integer): Maximum retries (default: 25)
279
- - `priority` (integer): Job priority (default: 0)
289
+ - `max_attempts` (integer): Maximum retries
290
+ - `priority` (integer): Job priority
280
291
 
281
292
  **Returns:** `app_jobs.scheduled_jobs` row
282
293
 
@@ -314,15 +325,10 @@ END LOOP;
314
325
  The package includes several triggers for automatic management:
315
326
 
316
327
  - **timestamps**: Automatically sets created_at/updated_at
317
- - **notify_worker**: Statement-level NOTIFY trigger sends a single `jobs:insert` notification per statement (not per row)
328
+ - **notify_worker**: Sends LISTEN/NOTIFY events when jobs are added
318
329
  - **increase_job_queue_count**: Updates queue statistics on insert
319
330
  - **decrease_job_queue_count**: Updates queue statistics on delete/update
320
331
 
321
- ### Additional Functions
322
-
323
- - **`app_jobs.remove_job(job_key text)`**: Removes a job by its key
324
- - **`app_jobs.force_unlock_workers(worker_ids text[])`**: Forcefully unlocks all jobs held by the specified workers
325
-
326
332
  ## Dependencies
327
333
 
328
334
  - PGPM roles (anonymous, authenticated, administrator)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pgpm/database-jobs",
3
- "version": "0.21.2",
3
+ "version": "0.22.0",
4
4
  "description": "Database-specific job handling and queue management",
5
5
  "author": "Dan Lynch <pyramation@gmail.com>",
6
6
  "contributors": [
@@ -21,11 +21,11 @@
21
21
  "test:watch": "jest --watch"
22
22
  },
23
23
  "devDependencies": {
24
- "pgpm": "^4.16.6"
24
+ "pgpm": "^4.23.2"
25
25
  },
26
26
  "dependencies": {
27
- "@pgpm/jwt-claims": "0.21.2",
28
- "@pgpm/verify": "0.21.2"
27
+ "@pgpm/jwt-claims": "0.22.0",
28
+ "@pgpm/verify": "0.22.0"
29
29
  },
30
30
  "repository": {
31
31
  "type": "git",
@@ -35,5 +35,5 @@
35
35
  "bugs": {
36
36
  "url": "https://github.com/constructive-io/pgpm-modules/issues"
37
37
  },
38
- "gitHead": "c7d836c99c7ce519e9bb79e6343bee3741781766"
38
+ "gitHead": "96ae0195a8b3a3dd056808c344f0e3c31a199e5e"
39
39
  }
@@ -0,0 +1,882 @@
1
+ \echo Use "CREATE EXTENSION pgpm-database-jobs" to load this file. \quit
2
+ CREATE SCHEMA IF NOT EXISTS app_jobs;
3
+
4
+ GRANT USAGE ON SCHEMA app_jobs TO administrator;
5
+
6
+ ALTER DEFAULT PRIVILEGES IN SCHEMA app_jobs
7
+ GRANT EXECUTE ON FUNCTIONS TO administrator;
8
+
9
+ CREATE FUNCTION app_jobs.tg_update_timestamps() RETURNS trigger AS $EOFCODE$
10
+ BEGIN
11
+ IF TG_OP = 'INSERT' THEN
12
+ NEW.created_at = NOW();
13
+ NEW.updated_at = NOW();
14
+ ELSIF TG_OP = 'UPDATE' THEN
15
+ NEW.created_at = OLD.created_at;
16
+ NEW.updated_at = greatest (now(), OLD.updated_at + interval '1 millisecond');
17
+ END IF;
18
+ RETURN NEW;
19
+ END;
20
+ $EOFCODE$ LANGUAGE plpgsql;
21
+
22
+ CREATE FUNCTION app_jobs.tg_add_job_with_row_id() RETURNS trigger AS $EOFCODE$
23
+ BEGIN
24
+ IF (TG_OP = 'INSERT' OR TG_OP = 'UPDATE') THEN
25
+ PERFORM
26
+ app_jobs.add_job (tg_argv[0], json_build_object('id', NEW.id));
27
+ RETURN NEW;
28
+ END IF;
29
+ IF (TG_OP = 'DELETE') THEN
30
+ PERFORM
31
+ app_jobs.add_job (tg_argv[0], json_build_object('id', OLD.id));
32
+ RETURN OLD;
33
+ END IF;
34
+ END;
35
+ $EOFCODE$ LANGUAGE plpgsql VOLATILE SECURITY DEFINER;
36
+
37
+ COMMENT ON FUNCTION app_jobs.tg_add_job_with_row_id IS 'Useful shortcut to create a job on insert or update. Pass the task name as the trigger argument, and the record id will automatically be available on the JSON payload.';
38
+
39
+ CREATE FUNCTION app_jobs.tg_add_job_with_row() RETURNS trigger AS $EOFCODE$
40
+ BEGIN
41
+ IF (TG_OP = 'INSERT' OR TG_OP = 'UPDATE') THEN
42
+ PERFORM
43
+ app_jobs.add_job (TG_ARGV[0], to_json(NEW));
44
+ RETURN NEW;
45
+ END IF;
46
+ IF (TG_OP = 'DELETE') THEN
47
+ PERFORM
48
+ app_jobs.add_job (TG_ARGV[0], to_json(OLD));
49
+ RETURN OLD;
50
+ END IF;
51
+ END;
52
+ $EOFCODE$ LANGUAGE plpgsql VOLATILE SECURITY DEFINER;
53
+
54
+ COMMENT ON FUNCTION app_jobs.tg_add_job_with_row IS 'Useful shortcut to create a job on insert or update. Pass the task name as the trigger argument, and the record data will automatically be available on the JSON payload.';
55
+
56
+ CREATE FUNCTION app_jobs.json_build_object_apply(arguments text[]) RETURNS pg_catalog.json AS $EOFCODE$
57
+ DECLARE
58
+ arg text;
59
+ _sql text;
60
+ _res json;
61
+ args text[];
62
+ BEGIN
63
+ _sql = 'SELECT json_build_object(';
64
+ FOR arg IN
65
+ SELECT
66
+ unnest(arguments)
67
+ LOOP
68
+ args = array_append(args, format('''%s''', arg));
69
+ END LOOP;
70
+ _sql = _sql || format('%s);', array_to_string(args, ','));
71
+ EXECUTE _sql INTO _res;
72
+ RETURN _res;
73
+ END;
74
+ $EOFCODE$ LANGUAGE plpgsql;
75
+
76
+ CREATE FUNCTION app_jobs.trigger_job_with_fields() RETURNS trigger AS $EOFCODE$
77
+ DECLARE
78
+ arg text;
79
+ fn text;
80
+ i int;
81
+ args text[];
82
+ BEGIN
83
+ FOR i IN
84
+ SELECT
85
+ *
86
+ FROM
87
+ generate_series(1, TG_NARGS) g (i)
88
+ LOOP
89
+ IF (i = 1) THEN
90
+ fn = TG_ARGV[i - 1];
91
+ ELSE
92
+ args = array_append(args, TG_ARGV[i - 1]);
93
+ IF (TG_OP = 'INSERT' OR TG_OP = 'UPDATE') THEN
94
+ EXECUTE format('SELECT ($1).%s::text', TG_ARGV[i - 1])
95
+ USING NEW INTO arg;
96
+ END IF;
97
+ IF (TG_OP = 'DELETE') THEN
98
+ EXECUTE format('SELECT ($1).%s::text', TG_ARGV[i - 1])
99
+ USING OLD INTO arg;
100
+ END IF;
101
+ args = array_append(args, arg);
102
+ END IF;
103
+ END LOOP;
104
+ PERFORM
105
+ app_jobs.add_job (fn, app_jobs.json_build_object_apply (args));
106
+ IF (TG_OP = 'INSERT' OR TG_OP = 'UPDATE') THEN
107
+ RETURN NEW;
108
+ END IF;
109
+ IF (TG_OP = 'DELETE') THEN
110
+ RETURN OLD;
111
+ END IF;
112
+ END;
113
+ $EOFCODE$ LANGUAGE plpgsql VOLATILE SECURITY DEFINER;
114
+
115
+ CREATE TABLE app_jobs.scheduled_jobs (
116
+ id bigserial PRIMARY KEY,
117
+ database_id uuid,
118
+ actor_id uuid,
119
+ queue_name text DEFAULT NULL,
120
+ task_identifier text NOT NULL,
121
+ payload pg_catalog.json DEFAULT '{}'::json NOT NULL,
122
+ priority int DEFAULT 0 NOT NULL,
123
+ max_attempts int DEFAULT 25 NOT NULL,
124
+ key text,
125
+ locked_at timestamptz,
126
+ locked_by text,
127
+ schedule_info pg_catalog.json NOT NULL,
128
+ last_scheduled timestamptz,
129
+ last_scheduled_id bigint,
130
+ CHECK (length(key) < 513),
131
+ CHECK (length(task_identifier) < 127),
132
+ CHECK (max_attempts >= 1),
133
+ CHECK (length(queue_name) < 127),
134
+ CHECK (length(locked_by) > 3),
135
+ UNIQUE (key)
136
+ );
137
+
138
+ COMMENT ON TABLE app_jobs.scheduled_jobs IS 'Recurring/cron-style job definitions: each row spawns jobs on a schedule, optionally scoped to a database';
139
+
140
+ COMMENT ON COLUMN app_jobs.scheduled_jobs.id IS 'Auto-incrementing scheduled job identifier';
141
+
142
+ COMMENT ON COLUMN app_jobs.scheduled_jobs.database_id IS 'Database this scheduled job belongs to (nullable for system-level schedules without tenant context)';
143
+
144
+ COMMENT ON COLUMN app_jobs.scheduled_jobs.actor_id IS 'User who created this scheduled job, read from JWT claims at creation time';
145
+
146
+ COMMENT ON COLUMN app_jobs.scheduled_jobs.queue_name IS 'Name of the queue spawned jobs are placed into';
147
+
148
+ COMMENT ON COLUMN app_jobs.scheduled_jobs.task_identifier IS 'Task type identifier for spawned jobs';
149
+
150
+ COMMENT ON COLUMN app_jobs.scheduled_jobs.payload IS 'JSON payload passed to each spawned job';
151
+
152
+ COMMENT ON COLUMN app_jobs.scheduled_jobs.priority IS 'Priority assigned to spawned jobs (lower = higher priority)';
153
+
154
+ COMMENT ON COLUMN app_jobs.scheduled_jobs.max_attempts IS 'Max retry attempts for spawned jobs';
155
+
156
+ COMMENT ON COLUMN app_jobs.scheduled_jobs.key IS 'Optional unique deduplication key';
157
+
158
+ COMMENT ON COLUMN app_jobs.scheduled_jobs.locked_at IS 'Timestamp when the scheduler locked this record for processing';
159
+
160
+ COMMENT ON COLUMN app_jobs.scheduled_jobs.locked_by IS 'Identifier of the scheduler worker holding the lock';
161
+
162
+ COMMENT ON COLUMN app_jobs.scheduled_jobs.schedule_info IS 'JSON schedule configuration (e.g. cron expression, interval)';
163
+
164
+ COMMENT ON COLUMN app_jobs.scheduled_jobs.last_scheduled IS 'Timestamp when a job was last spawned from this schedule';
165
+
166
+ COMMENT ON COLUMN app_jobs.scheduled_jobs.last_scheduled_id IS 'ID of the last job spawned from this schedule';
167
+
168
+ CREATE FUNCTION app_jobs.do_notify() RETURNS trigger AS $EOFCODE$
169
+ BEGIN
170
+ PERFORM
171
+ pg_notify(TG_ARGV[0], '');
172
+ RETURN NEW;
173
+ END;
174
+ $EOFCODE$ LANGUAGE plpgsql;
175
+
176
+ CREATE TRIGGER _900_notify_scheduled_job
177
+ AFTER INSERT
178
+ ON app_jobs.scheduled_jobs
179
+ FOR EACH ROW
180
+ EXECUTE PROCEDURE app_jobs.do_notify('scheduled_jobs:insert');
181
+
182
+ CREATE INDEX scheduled_jobs_priority_id_idx ON app_jobs.scheduled_jobs (priority, id);
183
+
184
+ CREATE INDEX scheduled_jobs_locked_by_idx ON app_jobs.scheduled_jobs (locked_by);
185
+
186
+ GRANT SELECT, INSERT, UPDATE, DELETE ON app_jobs.scheduled_jobs TO administrator;
187
+
188
+ CREATE TABLE app_jobs.jobs (
189
+ id bigserial PRIMARY KEY,
190
+ database_id uuid,
191
+ actor_id uuid,
192
+ queue_name text DEFAULT NULL,
193
+ task_identifier text NOT NULL,
194
+ payload pg_catalog.json DEFAULT '{}'::json NOT NULL,
195
+ priority int DEFAULT 0 NOT NULL,
196
+ run_at timestamptz DEFAULT now() NOT NULL,
197
+ attempts int DEFAULT 0 NOT NULL,
198
+ max_attempts int DEFAULT 25 NOT NULL,
199
+ key text,
200
+ last_error text,
201
+ locked_at timestamptz,
202
+ locked_by text,
203
+ is_available boolean GENERATED ALWAYS AS (locked_at IS NULL
204
+ AND attempts < max_attempts) STORED NOT NULL,
205
+ CHECK (length(key) < 513),
206
+ CHECK (length(task_identifier) < 127),
207
+ CHECK (max_attempts >= 1),
208
+ CHECK (length(queue_name) < 127),
209
+ CHECK (length(locked_by) > 3),
210
+ UNIQUE (key)
211
+ );
212
+
213
+ COMMENT ON TABLE app_jobs.jobs IS 'Background job queue: each row is a pending or in-progress task, optionally scoped to a database';
214
+
215
+ COMMENT ON COLUMN app_jobs.jobs.id IS 'Auto-incrementing job identifier';
216
+
217
+ COMMENT ON COLUMN app_jobs.jobs.database_id IS 'Database this job belongs to (nullable for system-level jobs without tenant context)';
218
+
219
+ COMMENT ON COLUMN app_jobs.jobs.actor_id IS 'User who triggered this job, read from JWT claims at enqueue time';
220
+
221
+ COMMENT ON COLUMN app_jobs.jobs.queue_name IS 'Name of the queue this job belongs to; used for worker routing and concurrency control';
222
+
223
+ COMMENT ON COLUMN app_jobs.jobs.task_identifier IS 'Identifier for the task type (maps to a worker handler function)';
224
+
225
+ COMMENT ON COLUMN app_jobs.jobs.payload IS 'JSON payload of arguments passed to the task handler';
226
+
227
+ COMMENT ON COLUMN app_jobs.jobs.priority IS 'Execution priority; lower numbers run first (default 0)';
228
+
229
+ COMMENT ON COLUMN app_jobs.jobs.run_at IS 'Earliest time this job should be executed; used for delayed/scheduled execution';
230
+
231
+ COMMENT ON COLUMN app_jobs.jobs.attempts IS 'Number of times this job has been attempted so far';
232
+
233
+ COMMENT ON COLUMN app_jobs.jobs.max_attempts IS 'Maximum retry attempts before the job is considered permanently failed';
234
+
235
+ COMMENT ON COLUMN app_jobs.jobs.key IS 'Optional unique deduplication key; prevents duplicate jobs with the same key';
236
+
237
+ COMMENT ON COLUMN app_jobs.jobs.last_error IS 'Error message from the most recent failed attempt';
238
+
239
+ COMMENT ON COLUMN app_jobs.jobs.locked_at IS 'Timestamp when a worker locked this job for processing';
240
+
241
+ COMMENT ON COLUMN app_jobs.jobs.locked_by IS 'Identifier of the worker that currently holds the lock';
242
+
243
+ COMMENT ON COLUMN app_jobs.jobs.is_available IS 'Generated column: true when job is unlocked and has remaining attempts';
244
+
245
+ ALTER TABLE app_jobs.jobs
246
+ ADD COLUMN created_at timestamptz;
247
+
248
+ ALTER TABLE app_jobs.jobs
249
+ ALTER COLUMN created_at SET DEFAULT now();
250
+
251
+ ALTER TABLE app_jobs.jobs
252
+ ADD COLUMN updated_at timestamptz;
253
+
254
+ ALTER TABLE app_jobs.jobs
255
+ ALTER COLUMN updated_at SET DEFAULT now();
256
+
257
+ CREATE TRIGGER _100_update_jobs_modtime_tg
258
+ BEFORE INSERT OR UPDATE
259
+ ON app_jobs.jobs
260
+ FOR EACH ROW
261
+ EXECUTE PROCEDURE app_jobs.tg_update_timestamps();
262
+
263
+ CREATE FUNCTION app_jobs.tg_increase_job_queue_count() RETURNS trigger AS $EOFCODE$
264
+ BEGIN
265
+ INSERT INTO app_jobs.job_queues (queue_name, job_count)
266
+ VALUES (NEW.queue_name, 1)
267
+ ON CONFLICT (queue_name)
268
+ DO UPDATE SET
269
+ job_count = job_queues.job_count + 1;
270
+ RETURN NEW;
271
+ END;
272
+ $EOFCODE$ LANGUAGE plpgsql VOLATILE;
273
+
274
+ CREATE TRIGGER _500_increase_job_queue_count_on_insert
275
+ AFTER INSERT
276
+ ON app_jobs.jobs
277
+ FOR EACH ROW
278
+ WHEN (new.queue_name IS NOT NULL)
279
+ EXECUTE PROCEDURE app_jobs.tg_increase_job_queue_count();
280
+
281
+ CREATE TRIGGER _500_increase_job_queue_count_on_update
282
+ AFTER UPDATE OF queue_name
283
+ ON app_jobs.jobs
284
+ FOR EACH ROW
285
+ WHEN (new.queue_name IS DISTINCT FROM old.queue_name
286
+ AND new.queue_name IS NOT NULL)
287
+ EXECUTE PROCEDURE app_jobs.tg_increase_job_queue_count();
288
+
289
+ CREATE FUNCTION app_jobs.tg_jobs__after_insert() RETURNS trigger AS $EOFCODE$
290
+ BEGIN
291
+ PERFORM
292
+ pg_notify('jobs:insert', '');
293
+ RETURN NULL;
294
+ END;
295
+ $EOFCODE$ LANGUAGE plpgsql;
296
+
297
+ CREATE TRIGGER _900_after_insert
298
+ AFTER INSERT
299
+ ON app_jobs.jobs
300
+ FOR EACH STATEMENT
301
+ EXECUTE PROCEDURE app_jobs.tg_jobs__after_insert();
302
+
303
+ CREATE FUNCTION app_jobs.tg_decrease_job_queue_count() RETURNS trigger AS $EOFCODE$
304
+ DECLARE
305
+ v_new_job_count int;
306
+ BEGIN
307
+ UPDATE
308
+ app_jobs.job_queues
309
+ SET
310
+ job_count = job_queues.job_count - 1
311
+ WHERE
312
+ queue_name = OLD.queue_name
313
+ RETURNING
314
+ job_count INTO v_new_job_count;
315
+ IF v_new_job_count <= 0 THEN
316
+ DELETE FROM app_jobs.job_queues
317
+ WHERE queue_name = OLD.queue_name
318
+ AND job_count <= 0;
319
+ END IF;
320
+ RETURN OLD;
321
+ END;
322
+ $EOFCODE$ LANGUAGE plpgsql VOLATILE;
323
+
324
+ CREATE TRIGGER decrease_job_queue_count_on_delete
325
+ AFTER DELETE
326
+ ON app_jobs.jobs
327
+ FOR EACH ROW
328
+ WHEN (old.queue_name IS NOT NULL)
329
+ EXECUTE PROCEDURE app_jobs.tg_decrease_job_queue_count();
330
+
331
+ CREATE TRIGGER decrease_job_queue_count_on_update
332
+ AFTER UPDATE OF queue_name
333
+ ON app_jobs.jobs
334
+ FOR EACH ROW
335
+ WHEN (new.queue_name IS DISTINCT FROM old.queue_name
336
+ AND old.queue_name IS NOT NULL)
337
+ EXECUTE PROCEDURE app_jobs.tg_decrease_job_queue_count();
338
+
339
+ CREATE INDEX jobs_main_index ON app_jobs.jobs (priority, run_at) INCLUDE (id, queue_name) WHERE is_available = true;
340
+
341
+ CREATE INDEX jobs_no_queue_index ON app_jobs.jobs (priority, run_at) INCLUDE (id) WHERE is_available = true
342
+ AND queue_name IS NULL;
343
+
344
+ CREATE INDEX jobs_locked_by_idx ON app_jobs.jobs (locked_by);
345
+
346
+ GRANT SELECT, INSERT, UPDATE, DELETE ON app_jobs.jobs TO administrator;
347
+
348
+ CREATE TABLE app_jobs.job_queues (
349
+ queue_name text NOT NULL PRIMARY KEY,
350
+ job_count int DEFAULT 0 NOT NULL,
351
+ locked_at timestamptz,
352
+ locked_by text
353
+ );
354
+
355
+ COMMENT ON TABLE app_jobs.job_queues IS 'Queue metadata: tracks job counts and locking state for each named queue';
356
+
357
+ COMMENT ON COLUMN app_jobs.job_queues.queue_name IS 'Unique name identifying this queue';
358
+
359
+ COMMENT ON COLUMN app_jobs.job_queues.job_count IS 'Number of pending jobs in this queue';
360
+
361
+ COMMENT ON COLUMN app_jobs.job_queues.locked_at IS 'Timestamp when this queue was locked for batch processing';
362
+
363
+ COMMENT ON COLUMN app_jobs.job_queues.locked_by IS 'Identifier of the worker that currently holds the queue lock';
364
+
365
+ CREATE INDEX job_queues_locked_by_idx ON app_jobs.job_queues (locked_by);
366
+
367
+ GRANT SELECT, INSERT, UPDATE, DELETE ON app_jobs.job_queues TO administrator;
368
+
369
+ CREATE FUNCTION app_jobs.run_scheduled_job(id bigint, job_expiry interval DEFAULT '1 hours') RETURNS app_jobs.jobs AS $EOFCODE$
370
+ DECLARE
371
+ j app_jobs.jobs;
372
+ last_id bigint;
373
+ lkd_by text;
374
+ BEGIN
375
+ -- check last scheduled
376
+ SELECT
377
+ last_scheduled_id
378
+ FROM
379
+ app_jobs.scheduled_jobs s
380
+ WHERE
381
+ s.id = run_scheduled_job.id INTO last_id;
382
+
383
+ -- if it's been scheduled check if it's been run
384
+
385
+ IF (last_id IS NOT NULL) THEN
386
+ SELECT
387
+ locked_by
388
+ FROM
389
+ app_jobs.jobs js
390
+ WHERE
391
+ js.id = last_id
392
+ AND (js.locked_at IS NULL -- never been run
393
+ OR js.locked_at >= (NOW() - job_expiry)
394
+ -- still running within a safe interval
395
+ ) INTO lkd_by;
396
+ IF (FOUND) THEN
397
+ RAISE EXCEPTION 'ALREADY_SCHEDULED';
398
+ END IF;
399
+ END IF;
400
+
401
+ -- insert new job
402
+ INSERT INTO app_jobs.jobs (
403
+ database_id,
404
+ actor_id,
405
+ queue_name,
406
+ task_identifier,
407
+ payload,
408
+ priority,
409
+ max_attempts,
410
+ key
411
+ ) SELECT
412
+ database_id,
413
+ actor_id,
414
+ queue_name,
415
+ task_identifier,
416
+ payload,
417
+ priority,
418
+ max_attempts,
419
+ key
420
+ FROM
421
+ app_jobs.scheduled_jobs s
422
+ WHERE
423
+ s.id = run_scheduled_job.id
424
+ RETURNING
425
+ * INTO j;
426
+ -- update the scheduled job
427
+ UPDATE
428
+ app_jobs.scheduled_jobs s
429
+ SET
430
+ last_scheduled = NOW(),
431
+ last_scheduled_id = j.id
432
+ WHERE
433
+ s.id = run_scheduled_job.id;
434
+ RETURN j;
435
+ END;
436
+ $EOFCODE$ LANGUAGE plpgsql VOLATILE;
437
+
438
+ CREATE FUNCTION app_jobs.reschedule_jobs(job_ids bigint[], run_at timestamptz DEFAULT NULL, priority int DEFAULT NULL, attempts int DEFAULT NULL, max_attempts int DEFAULT NULL) RETURNS SETOF app_jobs.jobs LANGUAGE sql AS $EOFCODE$
439
+ UPDATE
440
+ app_jobs.jobs
441
+ SET
442
+ run_at = coalesce(reschedule_jobs.run_at, jobs.run_at),
443
+ priority = coalesce(reschedule_jobs.priority, jobs.priority),
444
+ attempts = coalesce(reschedule_jobs.attempts, jobs.attempts),
445
+ max_attempts = coalesce(reschedule_jobs.max_attempts, jobs.max_attempts)
446
+ WHERE
447
+ id = ANY (job_ids)
448
+ AND (locked_by IS NULL
449
+ OR locked_at < NOW() - interval '4 hours')
450
+ RETURNING
451
+ *;
452
+ $EOFCODE$;
453
+
454
+ CREATE FUNCTION app_jobs.release_scheduled_jobs(worker_id text, ids bigint[] DEFAULT NULL) RETURNS void AS $EOFCODE$
455
+ DECLARE
456
+ BEGIN
457
+ -- clear the scheduled job
458
+ UPDATE
459
+ app_jobs.scheduled_jobs s
460
+ SET
461
+ locked_at = NULL,
462
+ locked_by = NULL
463
+ WHERE
464
+ locked_by = worker_id
465
+ AND (ids IS NULL
466
+ OR s.id = ANY (ids));
467
+ END;
468
+ $EOFCODE$ LANGUAGE plpgsql VOLATILE;
469
+
470
+ CREATE FUNCTION app_jobs.release_jobs(worker_id text) RETURNS void AS $EOFCODE$
471
+ DECLARE
472
+ BEGIN
473
+ -- clear the job
474
+ UPDATE
475
+ app_jobs.jobs
476
+ SET
477
+ locked_at = NULL,
478
+ locked_by = NULL,
479
+ attempts = GREATEST (attempts - 1, 0)
480
+ WHERE
481
+ locked_by = worker_id;
482
+ -- clear the queue
483
+ UPDATE
484
+ app_jobs.job_queues
485
+ SET
486
+ locked_at = NULL,
487
+ locked_by = NULL
488
+ WHERE
489
+ locked_by = worker_id;
490
+ END;
491
+ $EOFCODE$ LANGUAGE plpgsql VOLATILE;
492
+
493
+ CREATE FUNCTION app_jobs.permanently_fail_jobs(job_ids bigint[], error_message text DEFAULT NULL) RETURNS SETOF app_jobs.jobs LANGUAGE sql AS $EOFCODE$
494
+ UPDATE
495
+ app_jobs.jobs
496
+ SET
497
+ last_error = coalesce(error_message, 'Manually marked as failed'),
498
+ attempts = max_attempts
499
+ WHERE
500
+ id = ANY (job_ids)
501
+ AND (locked_by IS NULL
502
+ OR locked_at < NOW() - interval '4 hours')
503
+ RETURNING
504
+ *;
505
+ $EOFCODE$;
506
+
507
+ CREATE FUNCTION app_jobs.get_scheduled_job(worker_id text, task_identifiers text[] DEFAULT NULL) RETURNS app_jobs.scheduled_jobs LANGUAGE plpgsql AS $EOFCODE$
508
+ DECLARE
509
+ v_job_id bigint;
510
+ v_row app_jobs.scheduled_jobs;
511
+ BEGIN
512
+
513
+ --
514
+
515
+ IF worker_id IS NULL THEN
516
+ RAISE exception 'INVALID_WORKER_ID';
517
+ END IF;
518
+
519
+ --
520
+
521
+ SELECT
522
+ scheduled_jobs.id INTO v_job_id
523
+ FROM
524
+ app_jobs.scheduled_jobs
525
+ WHERE (scheduled_jobs.locked_at IS NULL)
526
+ AND (task_identifiers IS NULL
527
+ OR task_identifier = ANY (task_identifiers))
528
+ ORDER BY
529
+ priority ASC,
530
+ id ASC
531
+ LIMIT 1
532
+ FOR UPDATE
533
+ SKIP LOCKED;
534
+
535
+ --
536
+
537
+ IF v_job_id IS NULL THEN
538
+ RETURN NULL;
539
+ END IF;
540
+
541
+ --
542
+
543
+ UPDATE
544
+ app_jobs.scheduled_jobs
545
+ SET
546
+ locked_by = worker_id,
547
+ locked_at = NOW()
548
+ WHERE
549
+ id = v_job_id
550
+ RETURNING
551
+ * INTO v_row;
552
+
553
+ --
554
+
555
+ RETURN v_row;
556
+ END;
557
+ $EOFCODE$;
558
+
559
+ CREATE FUNCTION app_jobs.get_job(worker_id text, task_identifiers text[] DEFAULT NULL, job_expiry interval DEFAULT '4 hours') RETURNS app_jobs.jobs LANGUAGE plpgsql AS $EOFCODE$
560
+ DECLARE
561
+ v_job_id bigint;
562
+ v_queue_name text;
563
+ v_row app_jobs.jobs;
564
+ v_now timestamptz = now();
565
+ BEGIN
566
+ IF worker_id IS NULL THEN
567
+ RAISE EXCEPTION 'INVALID_WORKER_ID';
568
+ END IF;
569
+
570
+ SELECT jobs.queue_name, jobs.id
571
+ INTO v_queue_name, v_job_id
572
+ FROM app_jobs.jobs
573
+ WHERE is_available = true
574
+ AND (jobs.locked_at IS NULL
575
+ OR jobs.locked_at < (v_now - job_expiry))
576
+ AND (jobs.queue_name IS NULL
577
+ OR jobs.queue_name IN (
578
+ SELECT jq.queue_name
579
+ FROM app_jobs.job_queues jq
580
+ WHERE (jq.locked_at IS NULL
581
+ OR jq.locked_at < (v_now - job_expiry))
582
+ FOR UPDATE SKIP LOCKED
583
+ ))
584
+ AND run_at <= v_now
585
+ AND attempts < max_attempts
586
+ AND (task_identifiers IS NULL
587
+ OR task_identifier = ANY (task_identifiers))
588
+ ORDER BY priority ASC, run_at ASC, id ASC
589
+ LIMIT 1
590
+ FOR UPDATE SKIP LOCKED;
591
+
592
+ IF v_job_id IS NULL THEN
593
+ RETURN NULL;
594
+ END IF;
595
+
596
+ IF v_queue_name IS NOT NULL THEN
597
+ UPDATE app_jobs.job_queues
598
+ SET locked_by = worker_id, locked_at = v_now
599
+ WHERE job_queues.queue_name = v_queue_name;
600
+ END IF;
601
+
602
+ UPDATE app_jobs.jobs
603
+ SET
604
+ attempts = attempts + 1,
605
+ locked_by = worker_id,
606
+ locked_at = v_now
607
+ WHERE id = v_job_id
608
+ RETURNING * INTO v_row;
609
+
610
+ RETURN v_row;
611
+ END;
612
+ $EOFCODE$;
613
+
614
+ CREATE FUNCTION app_jobs.fail_job(worker_id text, job_id bigint, error_message text) RETURNS app_jobs.jobs LANGUAGE plpgsql STRICT AS $EOFCODE$
615
+ DECLARE
616
+ v_row app_jobs.jobs;
617
+ BEGIN
618
+ UPDATE
619
+ app_jobs.jobs
620
+ SET
621
+ last_error = error_message,
622
+ run_at = greatest (now(), run_at) + (exp(least (attempts, 10))::text || ' seconds')::interval,
623
+ locked_by = NULL,
624
+ locked_at = NULL
625
+ WHERE
626
+ id = job_id
627
+ AND locked_by = worker_id
628
+ RETURNING
629
+ * INTO v_row;
630
+ IF v_row.queue_name IS NOT NULL THEN
631
+ UPDATE
632
+ app_jobs.job_queues
633
+ SET
634
+ locked_by = NULL,
635
+ locked_at = NULL
636
+ WHERE
637
+ queue_name = v_row.queue_name
638
+ AND locked_by = worker_id;
639
+ END IF;
640
+ RETURN v_row;
641
+ END;
642
+ $EOFCODE$;
643
+
644
+ CREATE FUNCTION app_jobs.complete_jobs(job_ids bigint[]) RETURNS SETOF app_jobs.jobs LANGUAGE sql AS $EOFCODE$
645
+ DELETE FROM app_jobs.jobs
646
+ WHERE id = ANY (job_ids)
647
+ AND (locked_by IS NULL
648
+ OR locked_at < NOW() - interval '4 hours')
649
+ RETURNING
650
+ *;
651
+ $EOFCODE$;
652
+
653
+ CREATE FUNCTION app_jobs.complete_job(worker_id text, job_id bigint) RETURNS app_jobs.jobs LANGUAGE plpgsql AS $EOFCODE$
654
+ DECLARE
655
+ v_row app_jobs.jobs;
656
+ BEGIN
657
+ DELETE FROM app_jobs.jobs
658
+ WHERE id = job_id
659
+ RETURNING
660
+ * INTO v_row;
661
+ IF v_row.queue_name IS NOT NULL THEN
662
+ UPDATE
663
+ app_jobs.job_queues
664
+ SET
665
+ locked_by = NULL,
666
+ locked_at = NULL
667
+ WHERE
668
+ queue_name = v_row.queue_name
669
+ AND locked_by = worker_id;
670
+ END IF;
671
+ RETURN v_row;
672
+ END;
673
+ $EOFCODE$;
674
+
675
+ CREATE FUNCTION app_jobs.add_scheduled_job(identifier text, payload pg_catalog.json DEFAULT '{}'::json, schedule_info pg_catalog.json DEFAULT '{}'::json, job_key text DEFAULT NULL, queue_name text DEFAULT NULL, max_attempts int DEFAULT 25, priority int DEFAULT 0) RETURNS app_jobs.scheduled_jobs AS $EOFCODE$
676
+ DECLARE
677
+ v_job app_jobs.scheduled_jobs;
678
+ v_database_id uuid;
679
+ v_actor_id uuid;
680
+ BEGIN
681
+ v_database_id := jwt_private.current_database_id();
682
+ v_actor_id := jwt_public.current_user_id();
683
+
684
+ IF job_key IS NOT NULL THEN
685
+
686
+ -- Upsert job
687
+ INSERT INTO app_jobs.scheduled_jobs (
688
+ database_id,
689
+ actor_id,
690
+ task_identifier,
691
+ payload,
692
+ queue_name,
693
+ schedule_info,
694
+ max_attempts,
695
+ key,
696
+ priority
697
+ ) VALUES (
698
+ v_database_id,
699
+ v_actor_id,
700
+ identifier,
701
+ coalesce(payload, '{}'::json),
702
+ queue_name,
703
+ schedule_info,
704
+ coalesce(max_attempts, 25),
705
+ job_key,
706
+ coalesce(priority, 0)
707
+ )
708
+ ON CONFLICT (key)
709
+ DO UPDATE SET
710
+ task_identifier = EXCLUDED.task_identifier,
711
+ payload = EXCLUDED.payload,
712
+ queue_name = EXCLUDED.queue_name,
713
+ max_attempts = EXCLUDED.max_attempts,
714
+ schedule_info = EXCLUDED.schedule_info,
715
+ priority = EXCLUDED.priority
716
+ WHERE
717
+ scheduled_jobs.locked_at IS NULL
718
+ RETURNING
719
+ * INTO v_job;
720
+
721
+ -- If upsert succeeded (insert or update), return early
722
+
723
+ IF NOT (v_job IS NULL) THEN
724
+ RETURN v_job;
725
+ END IF;
726
+
727
+ -- Upsert failed -> there must be an existing scheduled job that is locked. Remove
728
+ -- and allow a new one to be inserted
729
+
730
+ DELETE FROM
731
+ app_jobs.scheduled_jobs
732
+ WHERE
733
+ KEY = job_key;
734
+ END IF;
735
+
736
+ INSERT INTO app_jobs.scheduled_jobs (
737
+ database_id,
738
+ actor_id,
739
+ task_identifier,
740
+ payload,
741
+ queue_name,
742
+ schedule_info,
743
+ max_attempts,
744
+ priority
745
+ ) VALUES (
746
+ v_database_id,
747
+ v_actor_id,
748
+ identifier,
749
+ payload,
750
+ queue_name,
751
+ schedule_info,
752
+ max_attempts,
753
+ priority
754
+ ) RETURNING * INTO v_job;
755
+ RETURN v_job;
756
+ END;
757
+ $EOFCODE$ LANGUAGE plpgsql VOLATILE SECURITY DEFINER;
758
+
759
+ CREATE FUNCTION app_jobs.add_job(identifier text, payload pg_catalog.json DEFAULT '{}'::json, job_key text DEFAULT NULL, queue_name text DEFAULT NULL, run_at timestamptz DEFAULT now(), max_attempts int DEFAULT 25, priority int DEFAULT 0) RETURNS app_jobs.jobs AS $EOFCODE$
760
+ DECLARE
761
+ v_job app_jobs.jobs;
762
+ v_database_id uuid;
763
+ v_actor_id uuid;
764
+ BEGIN
765
+ -- Read context from JWT claims
766
+ v_database_id := jwt_private.current_database_id();
767
+ v_actor_id := jwt_public.current_user_id();
768
+
769
+ IF job_key IS NOT NULL THEN
770
+ -- Upsert job
771
+ INSERT INTO app_jobs.jobs (
772
+ database_id,
773
+ actor_id,
774
+ task_identifier,
775
+ payload,
776
+ queue_name,
777
+ run_at,
778
+ max_attempts,
779
+ key,
780
+ priority
781
+ ) VALUES (
782
+ v_database_id,
783
+ v_actor_id,
784
+ identifier,
785
+ coalesce(payload, '{}'::json),
786
+ queue_name,
787
+ coalesce(run_at, now()),
788
+ coalesce(max_attempts, 25),
789
+ job_key,
790
+ coalesce(priority, 0)
791
+ )
792
+ ON CONFLICT (key)
793
+ DO UPDATE SET
794
+ task_identifier = EXCLUDED.task_identifier,
795
+ payload = EXCLUDED.payload,
796
+ queue_name = EXCLUDED.queue_name,
797
+ max_attempts = EXCLUDED.max_attempts,
798
+ run_at = EXCLUDED.run_at,
799
+ priority = EXCLUDED.priority,
800
+ -- always reset error/retry state
801
+ attempts = 0, last_error = NULL
802
+ WHERE
803
+ jobs.locked_at IS NULL
804
+ RETURNING
805
+ * INTO v_job;
806
+
807
+ -- If upsert succeeded (insert or update), return early
808
+ IF NOT (v_job IS NULL) THEN
809
+ RETURN v_job;
810
+ END IF;
811
+
812
+ -- Upsert failed -> there must be an existing job that is locked. Remove
813
+ -- existing key to allow a new one to be inserted, and prevent any
814
+ -- subsequent retries by bumping attempts to the max allowed.
815
+ UPDATE
816
+ app_jobs.jobs
817
+ SET
818
+ key = NULL,
819
+ attempts = jobs.max_attempts
820
+ WHERE
821
+ key = job_key;
822
+ END IF;
823
+
824
+ INSERT INTO app_jobs.jobs (
825
+ database_id,
826
+ actor_id,
827
+ task_identifier,
828
+ payload,
829
+ queue_name,
830
+ run_at,
831
+ max_attempts,
832
+ priority
833
+ ) VALUES (
834
+ v_database_id,
835
+ v_actor_id,
836
+ identifier,
837
+ payload,
838
+ queue_name,
839
+ run_at,
840
+ max_attempts,
841
+ priority
842
+ )
843
+ RETURNING * INTO v_job;
844
+
845
+ RETURN v_job;
846
+ END;
847
+ $EOFCODE$ LANGUAGE plpgsql VOLATILE SECURITY DEFINER;
848
+
849
+ CREATE FUNCTION app_jobs.remove_job(job_key text) RETURNS app_jobs.jobs LANGUAGE plpgsql STRICT AS $EOFCODE$
850
+ DECLARE
851
+ v_job app_jobs.jobs;
852
+ BEGIN
853
+ DELETE FROM app_jobs.jobs
854
+ WHERE key = job_key
855
+ AND (locked_at IS NULL
856
+ OR locked_at < NOW() - interval '4 hours')
857
+ RETURNING * INTO v_job;
858
+
859
+ IF NOT (v_job IS NULL) THEN
860
+ RETURN v_job;
861
+ END IF;
862
+
863
+ UPDATE app_jobs.jobs
864
+ SET
865
+ key = NULL,
866
+ attempts = jobs.max_attempts
867
+ WHERE key = job_key
868
+ RETURNING * INTO v_job;
869
+
870
+ RETURN v_job;
871
+ END;
872
+ $EOFCODE$;
873
+
874
+ CREATE FUNCTION app_jobs.force_unlock_workers(worker_ids text[]) RETURNS void LANGUAGE sql VOLATILE AS $EOFCODE$
875
+ UPDATE app_jobs.jobs
876
+ SET locked_at = NULL, locked_by = NULL
877
+ WHERE locked_by = ANY (worker_ids);
878
+
879
+ UPDATE app_jobs.job_queues
880
+ SET locked_at = NULL, locked_by = NULL
881
+ WHERE locked_by = ANY (worker_ids);
882
+ $EOFCODE$;