@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.
Files changed (26) hide show
  1. package/README.md +29 -35
  2. package/deploy/schemas/app_jobs/procedures/add_job.sql +38 -34
  3. package/deploy/schemas/app_jobs/procedures/add_scheduled_job.sql +31 -21
  4. package/deploy/schemas/app_jobs/procedures/force_unlock_workers.sql +20 -0
  5. package/deploy/schemas/app_jobs/procedures/get_job.sql +27 -51
  6. package/deploy/schemas/app_jobs/procedures/remove_job.sql +34 -0
  7. package/deploy/schemas/app_jobs/procedures/run_scheduled_job.sql +2 -0
  8. package/deploy/schemas/app_jobs/tables/jobs/indexes/priority_run_at_id_idx.sql +11 -2
  9. package/deploy/schemas/app_jobs/tables/jobs/table.sql +9 -5
  10. package/deploy/schemas/app_jobs/tables/jobs/triggers/notify_worker.sql +15 -5
  11. package/deploy/schemas/app_jobs/tables/scheduled_jobs/table.sql +7 -5
  12. package/deploy/schemas/app_jobs/triggers/tg_add_job_with_fields.sql +1 -1
  13. package/deploy/schemas/app_jobs/triggers/tg_add_job_with_row.sql +2 -2
  14. package/deploy/schemas/app_jobs/triggers/tg_add_job_with_row_id.sql +2 -2
  15. package/package.json +5 -4
  16. package/pgpm-database-jobs.control +2 -2
  17. package/pgpm.plan +4 -2
  18. package/revert/schemas/app_jobs/procedures/force_unlock_workers.sql +7 -0
  19. package/revert/schemas/app_jobs/procedures/remove_job.sql +7 -0
  20. package/revert/schemas/app_jobs/tables/jobs/indexes/priority_run_at_id_idx.sql +3 -1
  21. package/revert/schemas/app_jobs/tables/jobs/triggers/notify_worker.sql +3 -2
  22. package/verify/schemas/app_jobs/procedures/force_unlock_workers.sql +7 -0
  23. package/verify/schemas/app_jobs/procedures/remove_job.sql +7 -0
  24. package/verify/schemas/app_jobs/tables/jobs/indexes/priority_run_at_id_idx.sql +2 -1
  25. package/verify/schemas/app_jobs/tables/jobs/triggers/notify_worker.sql +1 -2
  26. 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: random UUID)
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**: Sends LISTEN/NOTIFY events when jobs are added
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
- -- Bake actor_id into payload
23
- payload := (coalesce(payload, '{}'::json)::jsonb || jsonb_build_object('actor_id', jwt_public.current_user_id()))::json;
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
- db_id,
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
- IF NOT (v_job IS NULL) THEN
65
- RETURN v_job;
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
- UPDATE
73
- app_jobs.jobs
74
- SET
75
- KEY = NULL,
76
- attempts = jobs.max_attempts
77
- WHERE
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
- db_id,
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
- db_id,
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
- -- If upsert succeeded (insert or update), return early
59
-
60
- IF NOT (v_job IS NULL) THEN
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
- db_id,
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 (worker_id text, task_identifiers text[] DEFAULT NULL, job_expiry interval DEFAULT '4 hours')
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 exception 'INVALID_WORKER_ID';
22
+ RAISE EXCEPTION 'INVALID_WORKER_ID';
20
23
  END IF;
21
24
 
22
- --
23
-
24
- SELECT
25
- jobs.queue_name,
26
- jobs.id INTO v_queue_name,
27
- v_job_id
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 EXISTS (
34
- SELECT
35
- 1
36
- FROM
37
- app_jobs.job_queues
38
- WHERE
39
- job_queues.queue_name = jobs.queue_name
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
- app_jobs.job_queues
67
- SET
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
- id = v_job_id
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 NOT NULL,
8
- queue_name text DEFAULT (public.gen_random_uuid ()) ::text,
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 > 0),
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 with database scoping: each row is a pending or in-progress task for a specific database';
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, for multi-tenant job isolation';
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 TRIGGER _900_notify_worker
9
- AFTER INSERT ON app_jobs.jobs
10
- FOR EACH ROW
11
- EXECUTE PROCEDURE app_jobs.do_notify ('jobs:insert');
12
- COMMIT;
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;