@pgpm/database-jobs 0.20.2 → 0.21.2
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 +29 -35
- package/deploy/schemas/app_jobs/procedures/add_job.sql +38 -34
- package/deploy/schemas/app_jobs/procedures/add_scheduled_job.sql +31 -21
- package/deploy/schemas/app_jobs/procedures/force_unlock_workers.sql +20 -0
- package/deploy/schemas/app_jobs/procedures/get_job.sql +27 -51
- package/deploy/schemas/app_jobs/procedures/remove_job.sql +34 -0
- package/deploy/schemas/app_jobs/procedures/run_scheduled_job.sql +2 -0
- package/deploy/schemas/app_jobs/tables/jobs/indexes/priority_run_at_id_idx.sql +11 -2
- package/deploy/schemas/app_jobs/tables/jobs/table.sql +9 -5
- package/deploy/schemas/app_jobs/tables/jobs/triggers/notify_worker.sql +15 -5
- package/deploy/schemas/app_jobs/tables/scheduled_jobs/table.sql +7 -5
- package/deploy/schemas/app_jobs/triggers/tg_add_job_with_fields.sql +1 -1
- package/deploy/schemas/app_jobs/triggers/tg_add_job_with_row.sql +2 -2
- package/deploy/schemas/app_jobs/triggers/tg_add_job_with_row_id.sql +2 -2
- package/package.json +5 -4
- package/pgpm-database-jobs.control +2 -2
- package/pgpm.plan +4 -2
- package/revert/schemas/app_jobs/procedures/force_unlock_workers.sql +7 -0
- package/revert/schemas/app_jobs/procedures/remove_job.sql +7 -0
- package/revert/schemas/app_jobs/tables/jobs/indexes/priority_run_at_id_idx.sql +3 -1
- package/revert/schemas/app_jobs/tables/jobs/triggers/notify_worker.sql +3 -2
- package/verify/schemas/app_jobs/procedures/force_unlock_workers.sql +7 -0
- package/verify/schemas/app_jobs/procedures/remove_job.sql +7 -0
- package/verify/schemas/app_jobs/tables/jobs/indexes/priority_run_at_id_idx.sql +2 -1
- package/verify/schemas/app_jobs/tables/jobs/triggers/notify_worker.sql +1 -2
- package/sql/pgpm-database-jobs--0.15.3.sql +0 -805
package/README.md
CHANGED
|
@@ -87,7 +87,9 @@ 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
|
|
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)
|
|
91
93
|
- `task_identifier`: Job type/handler name
|
|
92
94
|
- `payload`: JSON data for the job
|
|
93
95
|
- `priority`: Lower numbers = higher priority (default: 0)
|
|
@@ -96,6 +98,7 @@ The `app_jobs.jobs` table stores active jobs with the following key fields:
|
|
|
96
98
|
- `max_attempts`: Maximum retry attempts (default: 25)
|
|
97
99
|
- `locked_by`: Worker ID that locked this job
|
|
98
100
|
- `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
|
|
99
102
|
- `key`: Optional unique key for upsert semantics
|
|
100
103
|
|
|
101
104
|
### Scheduled Jobs Table
|
|
@@ -106,21 +109,27 @@ The `app_jobs.scheduled_jobs` table stores recurring jobs with cron-style or rul
|
|
|
106
109
|
|
|
107
110
|
The `app_jobs.job_queues` table tracks queue statistics and locking state.
|
|
108
111
|
|
|
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
|
+
|
|
109
120
|
## Usage
|
|
110
121
|
|
|
111
122
|
### Adding Jobs
|
|
112
123
|
|
|
113
124
|
```sql
|
|
114
|
-
-- Add a simple job
|
|
125
|
+
-- Add a simple job (database_id and actor_id are read from JWT claims automatically)
|
|
115
126
|
SELECT app_jobs.add_job(
|
|
116
|
-
db_id := '5b720132-17d5-424d-9bcb-ee7b17c13d43'::uuid,
|
|
117
127
|
identifier := 'send_email',
|
|
118
128
|
payload := '{"to": "user@example.com", "subject": "Hello"}'::json
|
|
119
129
|
);
|
|
120
130
|
|
|
121
131
|
-- Add a job with priority and delayed execution
|
|
122
132
|
SELECT app_jobs.add_job(
|
|
123
|
-
db_id := '5b720132-17d5-424d-9bcb-ee7b17c13d43'::uuid,
|
|
124
133
|
identifier := 'generate_report',
|
|
125
134
|
payload := '{"report_id": 123}'::json,
|
|
126
135
|
run_at := now() + interval '1 hour',
|
|
@@ -130,7 +139,6 @@ SELECT app_jobs.add_job(
|
|
|
130
139
|
|
|
131
140
|
-- Add a job with a unique key (upsert semantics)
|
|
132
141
|
SELECT app_jobs.add_job(
|
|
133
|
-
db_id := '5b720132-17d5-424d-9bcb-ee7b17c13d43'::uuid,
|
|
134
142
|
identifier := 'daily_summary',
|
|
135
143
|
payload := '{"date": "2025-01-15"}'::json,
|
|
136
144
|
job_key := 'daily_summary_2025_01_15',
|
|
@@ -176,26 +184,9 @@ SELECT app_jobs.fail_job(
|
|
|
176
184
|
### Scheduled Jobs
|
|
177
185
|
|
|
178
186
|
```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
|
-
|
|
196
187
|
-- Schedule a job with a rule (every minute for 3 minutes)
|
|
188
|
+
-- database_id and actor_id are read from JWT claims automatically
|
|
197
189
|
SELECT app_jobs.add_scheduled_job(
|
|
198
|
-
db_id := '5b720132-17d5-424d-9bcb-ee7b17c13d43'::uuid,
|
|
199
190
|
identifier := 'heartbeat',
|
|
200
191
|
payload := '{}'::json,
|
|
201
192
|
schedule_info := json_build_object(
|
|
@@ -213,14 +204,13 @@ SELECT * FROM app_jobs.run_scheduled_job(scheduled_job_id := 1);
|
|
|
213
204
|
|
|
214
205
|
### app_jobs.add_job(...)
|
|
215
206
|
|
|
216
|
-
Adds a new job to the queue or updates an existing job if a key is provided.
|
|
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.
|
|
217
208
|
|
|
218
209
|
**Parameters:**
|
|
219
|
-
- `db_id` (uuid): Database/tenant identifier
|
|
220
210
|
- `identifier` (text): Job type/handler name
|
|
221
211
|
- `payload` (json): Job data (default: `{}`)
|
|
222
212
|
- `job_key` (text): Optional unique key for upsert (default: NULL)
|
|
223
|
-
- `queue_name` (text): Optional queue name (default:
|
|
213
|
+
- `queue_name` (text): Optional queue name (default: NULL)
|
|
224
214
|
- `run_at` (timestamptz): When to run (default: now())
|
|
225
215
|
- `max_attempts` (integer): Maximum retries (default: 25)
|
|
226
216
|
- `priority` (integer): Job priority (default: 0)
|
|
@@ -234,7 +224,7 @@ Adds a new job to the queue or updates an existing job if a key is provided.
|
|
|
234
224
|
|
|
235
225
|
### app_jobs.get_job(...)
|
|
236
226
|
|
|
237
|
-
Fetches and locks the next available job for a worker.
|
|
227
|
+
Fetches and locks the next available job for a worker. Uses partial indexes on `is_available = true` for fast dequeue.
|
|
238
228
|
|
|
239
229
|
**Parameters:**
|
|
240
230
|
- `worker_id` (text): Unique worker identifier
|
|
@@ -244,7 +234,7 @@ Fetches and locks the next available job for a worker.
|
|
|
244
234
|
**Returns:** `app_jobs.jobs` row or NULL
|
|
245
235
|
|
|
246
236
|
**Behavior:**
|
|
247
|
-
- Selects jobs by priority, run_at, and id
|
|
237
|
+
- Selects jobs by priority, run_at, and id using partial covering indexes
|
|
248
238
|
- Locks the job and its queue
|
|
249
239
|
- Increments attempt counter
|
|
250
240
|
- Uses `FOR UPDATE SKIP LOCKED` for concurrency
|
|
@@ -277,17 +267,16 @@ Marks a job as failed and schedules retry if attempts remain.
|
|
|
277
267
|
|
|
278
268
|
### app_jobs.add_scheduled_job(...)
|
|
279
269
|
|
|
280
|
-
Creates a scheduled job with cron-style or rule-based timing.
|
|
270
|
+
Creates a scheduled job with cron-style or rule-based timing. `database_id` and `actor_id` are read internally from JWT claims.
|
|
281
271
|
|
|
282
272
|
**Parameters:**
|
|
283
|
-
- `db_id` (uuid): Database/tenant identifier
|
|
284
273
|
- `identifier` (text): Job type/handler name
|
|
285
|
-
- `payload` (json): Job data
|
|
286
|
-
- `schedule_info` (json): Scheduling configuration
|
|
274
|
+
- `payload` (json): Job data (default: `{}`)
|
|
275
|
+
- `schedule_info` (json): Scheduling configuration (default: `{}`)
|
|
287
276
|
- `job_key` (text): Optional unique key
|
|
288
277
|
- `queue_name` (text): Optional queue name
|
|
289
|
-
- `max_attempts` (integer): Maximum retries
|
|
290
|
-
- `priority` (integer): Job priority
|
|
278
|
+
- `max_attempts` (integer): Maximum retries (default: 25)
|
|
279
|
+
- `priority` (integer): Job priority (default: 0)
|
|
291
280
|
|
|
292
281
|
**Returns:** `app_jobs.scheduled_jobs` row
|
|
293
282
|
|
|
@@ -325,10 +314,15 @@ END LOOP;
|
|
|
325
314
|
The package includes several triggers for automatic management:
|
|
326
315
|
|
|
327
316
|
- **timestamps**: Automatically sets created_at/updated_at
|
|
328
|
-
- **notify_worker**:
|
|
317
|
+
- **notify_worker**: Statement-level NOTIFY trigger — sends a single `jobs:insert` notification per statement (not per row)
|
|
329
318
|
- **increase_job_queue_count**: Updates queue statistics on insert
|
|
330
319
|
- **decrease_job_queue_count**: Updates queue statistics on delete/update
|
|
331
320
|
|
|
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
|
+
|
|
332
326
|
## Dependencies
|
|
333
327
|
|
|
334
328
|
- PGPM roles (anonymous, authenticated, administrator)
|
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
-- requires: schemas/app_jobs/schema
|
|
3
3
|
-- requires: schemas/app_jobs/tables/jobs/table
|
|
4
4
|
-- requires: schemas/app_jobs/tables/job_queues/table
|
|
5
|
+
-- requires: pgpm-jwt-claims:schemas/jwt_private/procedures/current_database_id
|
|
6
|
+
-- requires: pgpm-jwt-claims:schemas/jwt_public/procedures/current_user_id
|
|
5
7
|
|
|
6
8
|
BEGIN;
|
|
7
9
|
CREATE FUNCTION app_jobs.add_job (
|
|
8
|
-
db_id uuid,
|
|
9
10
|
identifier text,
|
|
10
11
|
payload json DEFAULT '{}' ::json,
|
|
11
12
|
job_key text DEFAULT NULL,
|
|
@@ -18,14 +19,18 @@ CREATE FUNCTION app_jobs.add_job (
|
|
|
18
19
|
AS $$
|
|
19
20
|
DECLARE
|
|
20
21
|
v_job app_jobs.jobs;
|
|
22
|
+
v_database_id uuid;
|
|
23
|
+
v_actor_id uuid;
|
|
21
24
|
BEGIN
|
|
22
|
-
--
|
|
23
|
-
|
|
25
|
+
-- Read context from JWT claims
|
|
26
|
+
v_database_id := jwt_private.current_database_id();
|
|
27
|
+
v_actor_id := jwt_public.current_user_id();
|
|
24
28
|
|
|
25
29
|
IF job_key IS NOT NULL THEN
|
|
26
|
-
-- Upsert job
|
|
30
|
+
-- Upsert job
|
|
27
31
|
INSERT INTO app_jobs.jobs (
|
|
28
32
|
database_id,
|
|
33
|
+
actor_id,
|
|
29
34
|
task_identifier,
|
|
30
35
|
payload,
|
|
31
36
|
queue_name,
|
|
@@ -34,52 +39,51 @@ BEGIN
|
|
|
34
39
|
key,
|
|
35
40
|
priority
|
|
36
41
|
) VALUES (
|
|
37
|
-
|
|
42
|
+
v_database_id,
|
|
43
|
+
v_actor_id,
|
|
38
44
|
identifier,
|
|
39
|
-
coalesce(payload,
|
|
40
|
-
'{}'::json),
|
|
45
|
+
coalesce(payload, '{}'::json),
|
|
41
46
|
queue_name,
|
|
42
47
|
coalesce(run_at, now()),
|
|
43
48
|
coalesce(max_attempts, 25),
|
|
44
49
|
job_key,
|
|
45
50
|
coalesce(priority, 0)
|
|
46
|
-
)
|
|
47
|
-
ON CONFLICT (key)
|
|
48
|
-
DO UPDATE SET
|
|
51
|
+
)
|
|
52
|
+
ON CONFLICT (key)
|
|
53
|
+
DO UPDATE SET
|
|
49
54
|
task_identifier = EXCLUDED.task_identifier,
|
|
50
55
|
payload = EXCLUDED.payload,
|
|
51
56
|
queue_name = EXCLUDED.queue_name,
|
|
52
57
|
max_attempts = EXCLUDED.max_attempts,
|
|
53
58
|
run_at = EXCLUDED.run_at,
|
|
54
|
-
priority = EXCLUDED.priority,
|
|
55
|
-
-- always reset error/retry state
|
|
56
|
-
attempts = 0, last_error = NULL
|
|
57
|
-
WHERE
|
|
58
|
-
jobs.locked_at IS NULL
|
|
59
|
-
RETURNING
|
|
60
|
-
* INTO v_job;
|
|
59
|
+
priority = EXCLUDED.priority,
|
|
60
|
+
-- always reset error/retry state
|
|
61
|
+
attempts = 0, last_error = NULL
|
|
62
|
+
WHERE
|
|
63
|
+
jobs.locked_at IS NULL
|
|
64
|
+
RETURNING
|
|
65
|
+
* INTO v_job;
|
|
61
66
|
|
|
62
|
-
-- If upsert succeeded (insert or update), return early
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
END IF;
|
|
67
|
+
-- If upsert succeeded (insert or update), return early
|
|
68
|
+
IF NOT (v_job IS NULL) THEN
|
|
69
|
+
RETURN v_job;
|
|
70
|
+
END IF;
|
|
67
71
|
|
|
68
|
-
-- Upsert failed -> there must be an existing job that is locked. Remove
|
|
69
|
-
-- existing key to allow a new one to be inserted, and prevent any
|
|
72
|
+
-- Upsert failed -> there must be an existing job that is locked. Remove
|
|
73
|
+
-- existing key to allow a new one to be inserted, and prevent any
|
|
70
74
|
-- subsequent retries by bumping attempts to the max allowed.
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
KEY = job_key;
|
|
75
|
+
UPDATE
|
|
76
|
+
app_jobs.jobs
|
|
77
|
+
SET
|
|
78
|
+
key = NULL,
|
|
79
|
+
attempts = jobs.max_attempts
|
|
80
|
+
WHERE
|
|
81
|
+
key = job_key;
|
|
79
82
|
END IF;
|
|
80
83
|
|
|
81
84
|
INSERT INTO app_jobs.jobs (
|
|
82
85
|
database_id,
|
|
86
|
+
actor_id,
|
|
83
87
|
task_identifier,
|
|
84
88
|
payload,
|
|
85
89
|
queue_name,
|
|
@@ -87,7 +91,8 @@ BEGIN
|
|
|
87
91
|
max_attempts,
|
|
88
92
|
priority
|
|
89
93
|
) VALUES (
|
|
90
|
-
|
|
94
|
+
v_database_id,
|
|
95
|
+
v_actor_id,
|
|
91
96
|
identifier,
|
|
92
97
|
payload,
|
|
93
98
|
queue_name,
|
|
@@ -103,4 +108,3 @@ $$
|
|
|
103
108
|
LANGUAGE 'plpgsql' VOLATILE SECURITY DEFINER;
|
|
104
109
|
|
|
105
110
|
COMMIT;
|
|
106
|
-
|
|
@@ -2,11 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
-- requires: schemas/app_jobs/schema
|
|
4
4
|
-- requires: schemas/app_jobs/tables/scheduled_jobs/table
|
|
5
|
+
-- requires: pgpm-jwt-claims:schemas/jwt_private/procedures/current_database_id
|
|
6
|
+
-- requires: pgpm-jwt-claims:schemas/jwt_public/procedures/current_user_id
|
|
5
7
|
|
|
6
8
|
BEGIN;
|
|
7
9
|
|
|
8
10
|
CREATE FUNCTION app_jobs.add_scheduled_job(
|
|
9
|
-
db_id uuid,
|
|
10
11
|
identifier text,
|
|
11
12
|
payload json DEFAULT '{}'::json,
|
|
12
13
|
schedule_info json DEFAULT '{}'::json,
|
|
@@ -19,12 +20,18 @@ CREATE FUNCTION app_jobs.add_scheduled_job(
|
|
|
19
20
|
AS $$
|
|
20
21
|
DECLARE
|
|
21
22
|
v_job app_jobs.scheduled_jobs;
|
|
23
|
+
v_database_id uuid;
|
|
24
|
+
v_actor_id uuid;
|
|
22
25
|
BEGIN
|
|
26
|
+
v_database_id := jwt_private.current_database_id();
|
|
27
|
+
v_actor_id := jwt_public.current_user_id();
|
|
28
|
+
|
|
23
29
|
IF job_key IS NOT NULL THEN
|
|
24
30
|
|
|
25
|
-
-- Upsert job
|
|
31
|
+
-- Upsert job
|
|
26
32
|
INSERT INTO app_jobs.scheduled_jobs (
|
|
27
33
|
database_id,
|
|
34
|
+
actor_id,
|
|
28
35
|
task_identifier,
|
|
29
36
|
payload,
|
|
30
37
|
queue_name,
|
|
@@ -33,7 +40,8 @@ BEGIN
|
|
|
33
40
|
key,
|
|
34
41
|
priority
|
|
35
42
|
) VALUES (
|
|
36
|
-
|
|
43
|
+
v_database_id,
|
|
44
|
+
v_actor_id,
|
|
37
45
|
identifier,
|
|
38
46
|
coalesce(payload, '{}'::json),
|
|
39
47
|
queue_name,
|
|
@@ -41,37 +49,38 @@ BEGIN
|
|
|
41
49
|
coalesce(max_attempts, 25),
|
|
42
50
|
job_key,
|
|
43
51
|
coalesce(priority, 0)
|
|
44
|
-
)
|
|
45
|
-
ON CONFLICT (key)
|
|
46
|
-
DO UPDATE SET
|
|
52
|
+
)
|
|
53
|
+
ON CONFLICT (key)
|
|
54
|
+
DO UPDATE SET
|
|
47
55
|
task_identifier = EXCLUDED.task_identifier,
|
|
48
56
|
payload = EXCLUDED.payload,
|
|
49
57
|
queue_name = EXCLUDED.queue_name,
|
|
50
58
|
max_attempts = EXCLUDED.max_attempts,
|
|
51
59
|
schedule_info = EXCLUDED.schedule_info,
|
|
52
60
|
priority = EXCLUDED.priority
|
|
53
|
-
WHERE
|
|
54
|
-
scheduled_jobs.locked_at IS NULL
|
|
55
|
-
RETURNING
|
|
56
|
-
* INTO v_job;
|
|
61
|
+
WHERE
|
|
62
|
+
scheduled_jobs.locked_at IS NULL
|
|
63
|
+
RETURNING
|
|
64
|
+
* INTO v_job;
|
|
65
|
+
|
|
66
|
+
-- If upsert succeeded (insert or update), return early
|
|
57
67
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
IF
|
|
61
|
-
RETURN v_job;
|
|
62
|
-
END IF;
|
|
68
|
+
IF NOT (v_job IS NULL) THEN
|
|
69
|
+
RETURN v_job;
|
|
70
|
+
END IF;
|
|
63
71
|
|
|
64
|
-
-- Upsert failed -> there must be an existing scheduled job that is locked. Remove
|
|
72
|
+
-- Upsert failed -> there must be an existing scheduled job that is locked. Remove
|
|
65
73
|
-- and allow a new one to be inserted
|
|
66
74
|
|
|
67
|
-
DELETE FROM
|
|
68
|
-
app_jobs.scheduled_jobs
|
|
69
|
-
WHERE
|
|
70
|
-
KEY = job_key;
|
|
75
|
+
DELETE FROM
|
|
76
|
+
app_jobs.scheduled_jobs
|
|
77
|
+
WHERE
|
|
78
|
+
KEY = job_key;
|
|
71
79
|
END IF;
|
|
72
80
|
|
|
73
81
|
INSERT INTO app_jobs.scheduled_jobs (
|
|
74
82
|
database_id,
|
|
83
|
+
actor_id,
|
|
75
84
|
task_identifier,
|
|
76
85
|
payload,
|
|
77
86
|
queue_name,
|
|
@@ -79,7 +88,8 @@ BEGIN
|
|
|
79
88
|
max_attempts,
|
|
80
89
|
priority
|
|
81
90
|
) VALUES (
|
|
82
|
-
|
|
91
|
+
v_database_id,
|
|
92
|
+
v_actor_id,
|
|
83
93
|
identifier,
|
|
84
94
|
payload,
|
|
85
95
|
queue_name,
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
-- Deploy schemas/app_jobs/procedures/force_unlock_workers to pg
|
|
2
|
+
-- requires: schemas/app_jobs/schema
|
|
3
|
+
-- requires: schemas/app_jobs/tables/jobs/table
|
|
4
|
+
-- requires: schemas/app_jobs/tables/job_queues/table
|
|
5
|
+
|
|
6
|
+
BEGIN;
|
|
7
|
+
CREATE FUNCTION app_jobs.force_unlock_workers (worker_ids text[])
|
|
8
|
+
RETURNS void
|
|
9
|
+
LANGUAGE sql
|
|
10
|
+
VOLATILE
|
|
11
|
+
AS $$
|
|
12
|
+
UPDATE app_jobs.jobs
|
|
13
|
+
SET locked_at = NULL, locked_by = NULL
|
|
14
|
+
WHERE locked_by = ANY (worker_ids);
|
|
15
|
+
|
|
16
|
+
UPDATE app_jobs.job_queues
|
|
17
|
+
SET locked_at = NULL, locked_by = NULL
|
|
18
|
+
WHERE locked_by = ANY (worker_ids);
|
|
19
|
+
$$;
|
|
20
|
+
COMMIT;
|
|
@@ -4,7 +4,11 @@
|
|
|
4
4
|
-- requires: schemas/app_jobs/tables/jobs/table
|
|
5
5
|
|
|
6
6
|
BEGIN;
|
|
7
|
-
CREATE FUNCTION app_jobs.get_job (
|
|
7
|
+
CREATE FUNCTION app_jobs.get_job (
|
|
8
|
+
worker_id text,
|
|
9
|
+
task_identifiers text[] DEFAULT NULL,
|
|
10
|
+
job_expiry interval DEFAULT '4 hours'
|
|
11
|
+
)
|
|
8
12
|
RETURNS app_jobs.jobs
|
|
9
13
|
LANGUAGE plpgsql
|
|
10
14
|
AS $$
|
|
@@ -14,79 +18,51 @@ DECLARE
|
|
|
14
18
|
v_row app_jobs.jobs;
|
|
15
19
|
v_now timestamptz = now();
|
|
16
20
|
BEGIN
|
|
17
|
-
|
|
18
21
|
IF worker_id IS NULL THEN
|
|
19
|
-
RAISE
|
|
22
|
+
RAISE EXCEPTION 'INVALID_WORKER_ID';
|
|
20
23
|
END IF;
|
|
21
24
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
jobs.
|
|
27
|
-
|
|
28
|
-
FROM
|
|
29
|
-
app_jobs.jobs
|
|
30
|
-
WHERE (jobs.locked_at IS NULL
|
|
31
|
-
OR jobs.locked_at < (v_now - job_expiry))
|
|
25
|
+
SELECT jobs.queue_name, jobs.id
|
|
26
|
+
INTO v_queue_name, v_job_id
|
|
27
|
+
FROM app_jobs.jobs
|
|
28
|
+
WHERE is_available = true
|
|
29
|
+
AND (jobs.locked_at IS NULL
|
|
30
|
+
OR jobs.locked_at < (v_now - job_expiry))
|
|
32
31
|
AND (jobs.queue_name IS NULL
|
|
33
|
-
OR
|
|
34
|
-
SELECT
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
AND (job_queues.locked_at IS NULL
|
|
41
|
-
OR job_queues.locked_at < (v_now - job_expiry))
|
|
42
|
-
FOR UPDATE
|
|
43
|
-
SKIP LOCKED))
|
|
32
|
+
OR jobs.queue_name IN (
|
|
33
|
+
SELECT jq.queue_name
|
|
34
|
+
FROM app_jobs.job_queues jq
|
|
35
|
+
WHERE (jq.locked_at IS NULL
|
|
36
|
+
OR jq.locked_at < (v_now - job_expiry))
|
|
37
|
+
FOR UPDATE SKIP LOCKED
|
|
38
|
+
))
|
|
44
39
|
AND run_at <= v_now
|
|
45
40
|
AND attempts < max_attempts
|
|
46
41
|
AND (task_identifiers IS NULL
|
|
47
42
|
OR task_identifier = ANY (task_identifiers))
|
|
48
|
-
ORDER BY
|
|
49
|
-
priority ASC,
|
|
50
|
-
run_at ASC,
|
|
51
|
-
id ASC
|
|
43
|
+
ORDER BY priority ASC, run_at ASC, id ASC
|
|
52
44
|
LIMIT 1
|
|
53
|
-
FOR UPDATE
|
|
54
|
-
SKIP LOCKED;
|
|
55
|
-
|
|
56
|
-
--
|
|
45
|
+
FOR UPDATE SKIP LOCKED;
|
|
57
46
|
|
|
58
47
|
IF v_job_id IS NULL THEN
|
|
59
48
|
RETURN NULL;
|
|
60
49
|
END IF;
|
|
61
50
|
|
|
62
|
-
--
|
|
63
|
-
|
|
64
51
|
IF v_queue_name IS NOT NULL THEN
|
|
65
|
-
UPDATE
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
locked_by = worker_id,
|
|
69
|
-
locked_at = v_now
|
|
70
|
-
WHERE
|
|
71
|
-
job_queues.queue_name = v_queue_name;
|
|
52
|
+
UPDATE app_jobs.job_queues
|
|
53
|
+
SET locked_by = worker_id, locked_at = v_now
|
|
54
|
+
WHERE job_queues.queue_name = v_queue_name;
|
|
72
55
|
END IF;
|
|
73
56
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
UPDATE
|
|
77
|
-
app_jobs.jobs
|
|
57
|
+
UPDATE app_jobs.jobs
|
|
78
58
|
SET
|
|
79
59
|
attempts = attempts + 1,
|
|
80
60
|
locked_by = worker_id,
|
|
81
61
|
locked_at = v_now
|
|
82
|
-
WHERE
|
|
83
|
-
|
|
84
|
-
RETURNING
|
|
85
|
-
* INTO v_row;
|
|
62
|
+
WHERE id = v_job_id
|
|
63
|
+
RETURNING * INTO v_row;
|
|
86
64
|
|
|
87
|
-
--
|
|
88
65
|
RETURN v_row;
|
|
89
66
|
END;
|
|
90
67
|
$$;
|
|
91
68
|
COMMIT;
|
|
92
|
-
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
-- Deploy schemas/app_jobs/procedures/remove_job to pg
|
|
2
|
+
-- requires: schemas/app_jobs/schema
|
|
3
|
+
-- requires: schemas/app_jobs/tables/jobs/table
|
|
4
|
+
|
|
5
|
+
BEGIN;
|
|
6
|
+
CREATE FUNCTION app_jobs.remove_job (job_key text)
|
|
7
|
+
RETURNS app_jobs.jobs
|
|
8
|
+
LANGUAGE plpgsql
|
|
9
|
+
STRICT
|
|
10
|
+
AS $$
|
|
11
|
+
DECLARE
|
|
12
|
+
v_job app_jobs.jobs;
|
|
13
|
+
BEGIN
|
|
14
|
+
DELETE FROM app_jobs.jobs
|
|
15
|
+
WHERE key = job_key
|
|
16
|
+
AND (locked_at IS NULL
|
|
17
|
+
OR locked_at < NOW() - interval '4 hours')
|
|
18
|
+
RETURNING * INTO v_job;
|
|
19
|
+
|
|
20
|
+
IF NOT (v_job IS NULL) THEN
|
|
21
|
+
RETURN v_job;
|
|
22
|
+
END IF;
|
|
23
|
+
|
|
24
|
+
UPDATE app_jobs.jobs
|
|
25
|
+
SET
|
|
26
|
+
key = NULL,
|
|
27
|
+
attempts = jobs.max_attempts
|
|
28
|
+
WHERE key = job_key
|
|
29
|
+
RETURNING * INTO v_job;
|
|
30
|
+
|
|
31
|
+
RETURN v_job;
|
|
32
|
+
END;
|
|
33
|
+
$$;
|
|
34
|
+
COMMIT;
|
|
@@ -41,6 +41,7 @@ BEGIN
|
|
|
41
41
|
-- insert new job
|
|
42
42
|
INSERT INTO app_jobs.jobs (
|
|
43
43
|
database_id,
|
|
44
|
+
actor_id,
|
|
44
45
|
queue_name,
|
|
45
46
|
task_identifier,
|
|
46
47
|
payload,
|
|
@@ -49,6 +50,7 @@ BEGIN
|
|
|
49
50
|
key
|
|
50
51
|
) SELECT
|
|
51
52
|
database_id,
|
|
53
|
+
actor_id,
|
|
52
54
|
queue_name,
|
|
53
55
|
task_identifier,
|
|
54
56
|
payload,
|
|
@@ -3,6 +3,15 @@
|
|
|
3
3
|
-- requires: schemas/app_jobs/tables/jobs/table
|
|
4
4
|
|
|
5
5
|
BEGIN;
|
|
6
|
-
CREATE INDEX priority_run_at_id_idx ON app_jobs.jobs (priority, run_at, id);
|
|
7
|
-
COMMIT;
|
|
8
6
|
|
|
7
|
+
CREATE INDEX jobs_main_index
|
|
8
|
+
ON app_jobs.jobs USING btree (priority, run_at)
|
|
9
|
+
INCLUDE (id, queue_name)
|
|
10
|
+
WHERE (is_available = true);
|
|
11
|
+
|
|
12
|
+
CREATE INDEX jobs_no_queue_index
|
|
13
|
+
ON app_jobs.jobs USING btree (priority, run_at)
|
|
14
|
+
INCLUDE (id)
|
|
15
|
+
WHERE (is_available = true AND queue_name IS NULL);
|
|
16
|
+
|
|
17
|
+
COMMIT;
|
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
BEGIN;
|
|
5
5
|
CREATE TABLE app_jobs.jobs (
|
|
6
6
|
id bigserial PRIMARY KEY,
|
|
7
|
-
database_id uuid
|
|
8
|
-
|
|
7
|
+
database_id uuid,
|
|
8
|
+
actor_id uuid,
|
|
9
|
+
queue_name text DEFAULT NULL,
|
|
9
10
|
task_identifier text NOT NULL,
|
|
10
11
|
payload json DEFAULT '{}' ::json NOT NULL,
|
|
11
12
|
priority integer DEFAULT 0 NOT NULL,
|
|
@@ -16,17 +17,19 @@ CREATE TABLE app_jobs.jobs (
|
|
|
16
17
|
last_error text,
|
|
17
18
|
locked_at timestamptz,
|
|
18
19
|
locked_by text,
|
|
20
|
+
is_available boolean GENERATED ALWAYS AS ((locked_at IS NULL) AND (attempts < max_attempts)) STORED NOT NULL,
|
|
19
21
|
CHECK (length(key) < 513),
|
|
20
22
|
CHECK (length(task_identifier) < 127),
|
|
21
|
-
CHECK (max_attempts
|
|
23
|
+
CHECK (max_attempts >= 1),
|
|
22
24
|
CHECK (length(queue_name) < 127),
|
|
23
25
|
CHECK (length(locked_by) > 3),
|
|
24
26
|
UNIQUE (key)
|
|
25
27
|
);
|
|
26
28
|
|
|
27
|
-
COMMENT ON TABLE app_jobs.jobs IS 'Background job queue
|
|
29
|
+
COMMENT ON TABLE app_jobs.jobs IS 'Background job queue: each row is a pending or in-progress task, optionally scoped to a database';
|
|
28
30
|
COMMENT ON COLUMN app_jobs.jobs.id IS 'Auto-incrementing job identifier';
|
|
29
|
-
COMMENT ON COLUMN app_jobs.jobs.database_id IS 'Database this job belongs to
|
|
31
|
+
COMMENT ON COLUMN app_jobs.jobs.database_id IS 'Database this job belongs to (nullable for system-level jobs without tenant context)';
|
|
32
|
+
COMMENT ON COLUMN app_jobs.jobs.actor_id IS 'User who triggered this job, read from JWT claims at enqueue time';
|
|
30
33
|
COMMENT ON COLUMN app_jobs.jobs.queue_name IS 'Name of the queue this job belongs to; used for worker routing and concurrency control';
|
|
31
34
|
COMMENT ON COLUMN app_jobs.jobs.task_identifier IS 'Identifier for the task type (maps to a worker handler function)';
|
|
32
35
|
COMMENT ON COLUMN app_jobs.jobs.payload IS 'JSON payload of arguments passed to the task handler';
|
|
@@ -38,6 +41,7 @@ COMMENT ON COLUMN app_jobs.jobs.key IS 'Optional unique deduplication key; preve
|
|
|
38
41
|
COMMENT ON COLUMN app_jobs.jobs.last_error IS 'Error message from the most recent failed attempt';
|
|
39
42
|
COMMENT ON COLUMN app_jobs.jobs.locked_at IS 'Timestamp when a worker locked this job for processing';
|
|
40
43
|
COMMENT ON COLUMN app_jobs.jobs.locked_by IS 'Identifier of the worker that currently holds the lock';
|
|
44
|
+
COMMENT ON COLUMN app_jobs.jobs.is_available IS 'Generated column: true when job is unlocked and has remaining attempts';
|
|
41
45
|
|
|
42
46
|
COMMIT;
|
|
43
47
|
|
|
@@ -5,9 +5,19 @@
|
|
|
5
5
|
-- requires: schemas/app_jobs/tables/jobs/triggers/increase_job_queue_count
|
|
6
6
|
|
|
7
7
|
BEGIN;
|
|
8
|
-
CREATE
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
8
|
+
CREATE FUNCTION app_jobs.tg_jobs__after_insert ()
|
|
9
|
+
RETURNS TRIGGER
|
|
10
|
+
AS $$
|
|
11
|
+
BEGIN
|
|
12
|
+
PERFORM
|
|
13
|
+
pg_notify('jobs:insert', '');
|
|
14
|
+
RETURN NULL;
|
|
15
|
+
END;
|
|
16
|
+
$$
|
|
17
|
+
LANGUAGE plpgsql;
|
|
13
18
|
|
|
19
|
+
CREATE TRIGGER _900_after_insert
|
|
20
|
+
AFTER INSERT ON app_jobs.jobs
|
|
21
|
+
FOR EACH STATEMENT
|
|
22
|
+
EXECUTE PROCEDURE app_jobs.tg_jobs__after_insert ();
|
|
23
|
+
COMMIT;
|