@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 +1 -1
- package/README.md +35 -29
- package/package.json +5 -5
- package/sql/pgpm-database-jobs--0.22.0.sql +882 -0
package/Makefile
CHANGED
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
|
|
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
|
|
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.
|
|
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:
|
|
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.
|
|
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
|
|
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.
|
|
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
|
|
275
|
-
- `schedule_info` (json): Scheduling configuration
|
|
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
|
|
279
|
-
- `priority` (integer): Job priority
|
|
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**:
|
|
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.
|
|
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.
|
|
24
|
+
"pgpm": "^4.23.2"
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
|
-
"@pgpm/jwt-claims": "0.
|
|
28
|
-
"@pgpm/verify": "0.
|
|
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": "
|
|
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$;
|