@pgpm/database-jobs 0.21.0 → 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.
Files changed (26) hide show
  1. package/Makefile +1 -1
  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/sql/{pgpm-database-jobs--0.15.3.sql → pgpm-database-jobs--0.22.0.sql} +201 -124
  23. package/verify/schemas/app_jobs/procedures/force_unlock_workers.sql +7 -0
  24. package/verify/schemas/app_jobs/procedures/remove_job.sql +7 -0
  25. package/verify/schemas/app_jobs/tables/jobs/indexes/priority_run_at_id_idx.sql +2 -1
  26. package/verify/schemas/app_jobs/tables/jobs/triggers/notify_worker.sql +1 -2
package/Makefile CHANGED
@@ -1,5 +1,5 @@
1
1
  EXTENSION = pgpm-database-jobs
2
- DATA = sql/pgpm-database-jobs--0.15.3.sql
2
+ DATA = sql/pgpm-database-jobs--0.22.0.sql
3
3
 
4
4
  PG_CONFIG = pg_config
5
5
  PGXS := $(shell $(PG_CONFIG) --pgxs)
@@ -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;
@@ -4,8 +4,9 @@
4
4
  BEGIN;
5
5
  CREATE TABLE app_jobs.scheduled_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,
@@ -18,15 +19,16 @@ CREATE TABLE app_jobs.scheduled_jobs (
18
19
  last_scheduled_id bigint,
19
20
  CHECK (length(key) < 513),
20
21
  CHECK (length(task_identifier) < 127),
21
- CHECK (max_attempts > 0),
22
+ CHECK (max_attempts >= 1),
22
23
  CHECK (length(queue_name) < 127),
23
24
  CHECK (length(locked_by) > 3),
24
25
  UNIQUE (key)
25
26
  );
26
27
 
27
- COMMENT ON TABLE app_jobs.scheduled_jobs IS 'Recurring/cron-style job definitions with database scoping: each row spawns jobs on a schedule for a specific database';
28
+ 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';
28
29
  COMMENT ON COLUMN app_jobs.scheduled_jobs.id IS 'Auto-incrementing scheduled job identifier';
29
- COMMENT ON COLUMN app_jobs.scheduled_jobs.database_id IS 'Database this scheduled job belongs to, for multi-tenant isolation';
30
+ COMMENT ON COLUMN app_jobs.scheduled_jobs.database_id IS 'Database this scheduled job belongs to (nullable for system-level schedules without tenant context)';
31
+ COMMENT ON COLUMN app_jobs.scheduled_jobs.actor_id IS 'User who created this scheduled job, read from JWT claims at creation time';
30
32
  COMMENT ON COLUMN app_jobs.scheduled_jobs.queue_name IS 'Name of the queue spawned jobs are placed into';
31
33
  COMMENT ON COLUMN app_jobs.scheduled_jobs.task_identifier IS 'Task type identifier for spawned jobs';
32
34
  COMMENT ON COLUMN app_jobs.scheduled_jobs.payload IS 'JSON payload passed to each spawned job';
@@ -34,7 +34,7 @@ BEGIN
34
34
  END IF;
35
35
  END LOOP;
36
36
  PERFORM
37
- app_jobs.add_job (jwt_private.current_database_id(), fn, app_jobs.json_build_object_apply (args));
37
+ app_jobs.add_job (fn, app_jobs.json_build_object_apply (args));
38
38
  IF (TG_OP = 'INSERT' OR TG_OP = 'UPDATE') THEN
39
39
  RETURN NEW;
40
40
  END IF;
@@ -8,12 +8,12 @@ CREATE FUNCTION app_jobs.tg_add_job_with_row ()
8
8
  BEGIN
9
9
  IF (TG_OP = 'INSERT' OR TG_OP = 'UPDATE') THEN
10
10
  PERFORM
11
- app_jobs.add_job (jwt_private.current_database_id(), TG_ARGV[0], to_json(NEW));
11
+ app_jobs.add_job (TG_ARGV[0], to_json(NEW));
12
12
  RETURN NEW;
13
13
  END IF;
14
14
  IF (TG_OP = 'DELETE') THEN
15
15
  PERFORM
16
- app_jobs.add_job (jwt_private.current_database_id(), TG_ARGV[0], to_json(OLD));
16
+ app_jobs.add_job (TG_ARGV[0], to_json(OLD));
17
17
  RETURN OLD;
18
18
  END IF;
19
19
  END;
@@ -9,12 +9,12 @@ CREATE FUNCTION app_jobs.tg_add_job_with_row_id ()
9
9
  BEGIN
10
10
  IF (TG_OP = 'INSERT' OR TG_OP = 'UPDATE') THEN
11
11
  PERFORM
12
- app_jobs.add_job (jwt_private.current_database_id(), tg_argv[0], json_build_object('id', NEW.id));
12
+ app_jobs.add_job (tg_argv[0], json_build_object('id', NEW.id));
13
13
  RETURN NEW;
14
14
  END IF;
15
15
  IF (TG_OP = 'DELETE') THEN
16
16
  PERFORM
17
- app_jobs.add_job (jwt_private.current_database_id(), tg_argv[0], json_build_object('id', OLD.id));
17
+ app_jobs.add_job (tg_argv[0], json_build_object('id', OLD.id));
18
18
  RETURN OLD;
19
19
  END IF;
20
20
  END;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pgpm/database-jobs",
3
- "version": "0.21.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,10 +21,11 @@
21
21
  "test:watch": "jest --watch"
22
22
  },
23
23
  "devDependencies": {
24
- "pgpm": "^4.2.3"
24
+ "pgpm": "^4.23.2"
25
25
  },
26
26
  "dependencies": {
27
- "@pgpm/verify": "0.21.0"
27
+ "@pgpm/jwt-claims": "0.22.0",
28
+ "@pgpm/verify": "0.22.0"
28
29
  },
29
30
  "repository": {
30
31
  "type": "git",
@@ -34,5 +35,5 @@
34
35
  "bugs": {
35
36
  "url": "https://github.com/constructive-io/pgpm-modules/issues"
36
37
  },
37
- "gitHead": "f79dd69a3f7bac456bbd4474262b0ccbdb3e66a3"
38
+ "gitHead": "96ae0195a8b3a3dd056808c344f0e3c31a199e5e"
38
39
  }
@@ -1,8 +1,8 @@
1
1
  # pgpm-database-jobs extension
2
2
  comment = 'pgpm-database-jobs extension'
3
- default_version = '0.15.5'
3
+ default_version = '0.22.0'
4
4
  module_pathname = '$libdir/pgpm-database-jobs'
5
- requires = 'plpgsql,pgcrypto,pgpm-verify'
5
+ requires = 'plpgsql,pgcrypto,pgpm-verify,pgpm-jwt-claims'
6
6
  relocatable = false
7
7
  superuser = false
8
8
 
package/pgpm.plan CHANGED
@@ -34,5 +34,7 @@ schemas/app_jobs/procedures/get_job [schemas/app_jobs/schema schemas/app_jobs/ta
34
34
  schemas/app_jobs/procedures/fail_job [schemas/app_jobs/schema schemas/app_jobs/tables/jobs/table schemas/app_jobs/tables/job_queues/table] 2025-08-26T23:57:41Z pgpm <pgpm@5b0c196eeb62> # add schemas/app_jobs/procedures/fail_job
35
35
  schemas/app_jobs/procedures/complete_jobs [schemas/app_jobs/schema schemas/app_jobs/tables/job_queues/table schemas/app_jobs/tables/jobs/table] 2025-08-26T23:57:41Z pgpm <pgpm@5b0c196eeb62> # add schemas/app_jobs/procedures/complete_jobs
36
36
  schemas/app_jobs/procedures/complete_job [schemas/app_jobs/schema schemas/app_jobs/tables/jobs/table schemas/app_jobs/tables/job_queues/table] 2025-08-26T23:57:41Z pgpm <pgpm@5b0c196eeb62> # add schemas/app_jobs/procedures/complete_job
37
- schemas/app_jobs/procedures/add_scheduled_job [schemas/app_jobs/schema schemas/app_jobs/tables/scheduled_jobs/table] 2025-08-26T23:57:41Z pgpm <pgpm@5b0c196eeb62> # add schemas/app_jobs/procedures/add_scheduled_job
38
- schemas/app_jobs/procedures/add_job [schemas/app_jobs/schema schemas/app_jobs/tables/jobs/table schemas/app_jobs/tables/job_queues/table] 2025-08-26T23:57:41Z pgpm <pgpm@5b0c196eeb62> # add schemas/app_jobs/procedures/add_job
37
+ schemas/app_jobs/procedures/add_scheduled_job [schemas/app_jobs/schema schemas/app_jobs/tables/scheduled_jobs/table pgpm-jwt-claims:schemas/jwt_private/procedures/current_database_id] 2025-08-26T23:57:41Z pgpm <pgpm@5b0c196eeb62> # add schemas/app_jobs/procedures/add_scheduled_job
38
+ schemas/app_jobs/procedures/add_job [schemas/app_jobs/schema schemas/app_jobs/tables/jobs/table schemas/app_jobs/tables/job_queues/table pgpm-jwt-claims:schemas/jwt_private/procedures/current_database_id pgpm-jwt-claims:schemas/jwt_public/procedures/current_user_id] 2025-08-26T23:57:41Z pgpm <pgpm@5b0c196eeb62> # add schemas/app_jobs/procedures/add_job
39
+ schemas/app_jobs/procedures/remove_job [schemas/app_jobs/schema schemas/app_jobs/tables/jobs/table] 2025-08-26T23:57:41Z pgpm <pgpm@5b0c196eeb62> # add schemas/app_jobs/procedures/remove_job
40
+ schemas/app_jobs/procedures/force_unlock_workers [schemas/app_jobs/schema schemas/app_jobs/tables/jobs/table schemas/app_jobs/tables/job_queues/table] 2025-08-26T23:57:41Z pgpm <pgpm@5b0c196eeb62> # add schemas/app_jobs/procedures/force_unlock_workers
@@ -0,0 +1,7 @@
1
+ -- Revert schemas/app_jobs/procedures/force_unlock_workers from pg
2
+
3
+ BEGIN;
4
+
5
+ DROP FUNCTION app_jobs.force_unlock_workers;
6
+
7
+ COMMIT;
@@ -0,0 +1,7 @@
1
+ -- Revert schemas/app_jobs/procedures/remove_job from pg
2
+
3
+ BEGIN;
4
+
5
+ DROP FUNCTION app_jobs.remove_job;
6
+
7
+ COMMIT;
@@ -2,6 +2,8 @@
2
2
 
3
3
  BEGIN;
4
4
 
5
- DROP INDEX app_jobs.priority_run_at_id_idx;
5
+ DROP INDEX IF EXISTS app_jobs.priority_run_at_id_idx;
6
+ DROP INDEX IF EXISTS app_jobs.jobs_main_index;
7
+ DROP INDEX IF EXISTS app_jobs.jobs_no_queue_index;
6
8
 
7
9
  COMMIT;
@@ -1,5 +1,6 @@
1
1
  -- Revert schemas/app_jobs/tables/jobs/triggers/notify_worker from pg
2
2
  BEGIN;
3
- DROP TRIGGER _900_notify_worker ON app_jobs.jobs;
3
+ DROP TRIGGER IF EXISTS _900_notify_worker ON app_jobs.jobs;
4
+ DROP TRIGGER IF EXISTS _900_after_insert ON app_jobs.jobs;
5
+ DROP FUNCTION IF EXISTS app_jobs.tg_jobs__after_insert;
4
6
  COMMIT;
5
-
@@ -23,12 +23,12 @@ CREATE FUNCTION app_jobs.tg_add_job_with_row_id() RETURNS trigger AS $EOFCODE$
23
23
  BEGIN
24
24
  IF (TG_OP = 'INSERT' OR TG_OP = 'UPDATE') THEN
25
25
  PERFORM
26
- app_jobs.add_job (jwt_private.current_database_id(), tg_argv[0], json_build_object('id', NEW.id));
26
+ app_jobs.add_job (tg_argv[0], json_build_object('id', NEW.id));
27
27
  RETURN NEW;
28
28
  END IF;
29
29
  IF (TG_OP = 'DELETE') THEN
30
30
  PERFORM
31
- app_jobs.add_job (jwt_private.current_database_id(), tg_argv[0], json_build_object('id', OLD.id));
31
+ app_jobs.add_job (tg_argv[0], json_build_object('id', OLD.id));
32
32
  RETURN OLD;
33
33
  END IF;
34
34
  END;
@@ -40,12 +40,12 @@ CREATE FUNCTION app_jobs.tg_add_job_with_row() RETURNS trigger AS $EOFCODE$
40
40
  BEGIN
41
41
  IF (TG_OP = 'INSERT' OR TG_OP = 'UPDATE') THEN
42
42
  PERFORM
43
- app_jobs.add_job (jwt_private.current_database_id(), TG_ARGV[0], to_json(NEW));
43
+ app_jobs.add_job (TG_ARGV[0], to_json(NEW));
44
44
  RETURN NEW;
45
45
  END IF;
46
46
  IF (TG_OP = 'DELETE') THEN
47
47
  PERFORM
48
- app_jobs.add_job (jwt_private.current_database_id(), TG_ARGV[0], to_json(OLD));
48
+ app_jobs.add_job (TG_ARGV[0], to_json(OLD));
49
49
  RETURN OLD;
50
50
  END IF;
51
51
  END;
@@ -102,7 +102,7 @@ BEGIN
102
102
  END IF;
103
103
  END LOOP;
104
104
  PERFORM
105
- app_jobs.add_job (jwt_private.current_database_id(), fn, app_jobs.json_build_object_apply (args));
105
+ app_jobs.add_job (fn, app_jobs.json_build_object_apply (args));
106
106
  IF (TG_OP = 'INSERT' OR TG_OP = 'UPDATE') THEN
107
107
  RETURN NEW;
108
108
  END IF;
@@ -114,8 +114,9 @@ $EOFCODE$ LANGUAGE plpgsql VOLATILE SECURITY DEFINER;
114
114
 
115
115
  CREATE TABLE app_jobs.scheduled_jobs (
116
116
  id bigserial PRIMARY KEY,
117
- database_id uuid NOT NULL,
118
- queue_name text DEFAULT (public.gen_random_uuid())::text,
117
+ database_id uuid,
118
+ actor_id uuid,
119
+ queue_name text DEFAULT NULL,
119
120
  task_identifier text NOT NULL,
120
121
  payload pg_catalog.json DEFAULT '{}'::json NOT NULL,
121
122
  priority int DEFAULT 0 NOT NULL,
@@ -128,25 +129,40 @@ CREATE TABLE app_jobs.scheduled_jobs (
128
129
  last_scheduled_id bigint,
129
130
  CHECK (length(key) < 513),
130
131
  CHECK (length(task_identifier) < 127),
131
- CHECK (max_attempts > 0),
132
+ CHECK (max_attempts >= 1),
132
133
  CHECK (length(queue_name) < 127),
133
134
  CHECK (length(locked_by) > 3),
134
135
  UNIQUE (key)
135
136
  );
136
137
 
137
- COMMENT ON TABLE app_jobs.scheduled_jobs IS 'Recurring/cron-style job definitions with database scoping: each row spawns jobs on a schedule for a specific database';
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
+
138
140
  COMMENT ON COLUMN app_jobs.scheduled_jobs.id IS 'Auto-incrementing scheduled job identifier';
139
- COMMENT ON COLUMN app_jobs.scheduled_jobs.database_id IS 'Database this scheduled job belongs to, for multi-tenant isolation';
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
+
140
146
  COMMENT ON COLUMN app_jobs.scheduled_jobs.queue_name IS 'Name of the queue spawned jobs are placed into';
147
+
141
148
  COMMENT ON COLUMN app_jobs.scheduled_jobs.task_identifier IS 'Task type identifier for spawned jobs';
149
+
142
150
  COMMENT ON COLUMN app_jobs.scheduled_jobs.payload IS 'JSON payload passed to each spawned job';
151
+
143
152
  COMMENT ON COLUMN app_jobs.scheduled_jobs.priority IS 'Priority assigned to spawned jobs (lower = higher priority)';
153
+
144
154
  COMMENT ON COLUMN app_jobs.scheduled_jobs.max_attempts IS 'Max retry attempts for spawned jobs';
155
+
145
156
  COMMENT ON COLUMN app_jobs.scheduled_jobs.key IS 'Optional unique deduplication key';
157
+
146
158
  COMMENT ON COLUMN app_jobs.scheduled_jobs.locked_at IS 'Timestamp when the scheduler locked this record for processing';
159
+
147
160
  COMMENT ON COLUMN app_jobs.scheduled_jobs.locked_by IS 'Identifier of the scheduler worker holding the lock';
161
+
148
162
  COMMENT ON COLUMN app_jobs.scheduled_jobs.schedule_info IS 'JSON schedule configuration (e.g. cron expression, interval)';
163
+
149
164
  COMMENT ON COLUMN app_jobs.scheduled_jobs.last_scheduled IS 'Timestamp when a job was last spawned from this schedule';
165
+
150
166
  COMMENT ON COLUMN app_jobs.scheduled_jobs.last_scheduled_id IS 'ID of the last job spawned from this schedule';
151
167
 
152
168
  CREATE FUNCTION app_jobs.do_notify() RETURNS trigger AS $EOFCODE$
@@ -171,8 +187,9 @@ GRANT SELECT, INSERT, UPDATE, DELETE ON app_jobs.scheduled_jobs TO administrator
171
187
 
172
188
  CREATE TABLE app_jobs.jobs (
173
189
  id bigserial PRIMARY KEY,
174
- database_id uuid NOT NULL,
175
- queue_name text DEFAULT (public.gen_random_uuid())::text,
190
+ database_id uuid,
191
+ actor_id uuid,
192
+ queue_name text DEFAULT NULL,
176
193
  task_identifier text NOT NULL,
177
194
  payload pg_catalog.json DEFAULT '{}'::json NOT NULL,
178
195
  priority int DEFAULT 0 NOT NULL,
@@ -183,29 +200,48 @@ CREATE TABLE app_jobs.jobs (
183
200
  last_error text,
184
201
  locked_at timestamptz,
185
202
  locked_by text,
203
+ is_available boolean GENERATED ALWAYS AS (locked_at IS NULL
204
+ AND attempts < max_attempts) STORED NOT NULL,
186
205
  CHECK (length(key) < 513),
187
206
  CHECK (length(task_identifier) < 127),
188
- CHECK (max_attempts > 0),
207
+ CHECK (max_attempts >= 1),
189
208
  CHECK (length(queue_name) < 127),
190
209
  CHECK (length(locked_by) > 3),
191
210
  UNIQUE (key)
192
211
  );
193
212
 
194
- 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';
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
+
195
215
  COMMENT ON COLUMN app_jobs.jobs.id IS 'Auto-incrementing job identifier';
196
- COMMENT ON COLUMN app_jobs.jobs.database_id IS 'Database this job belongs to, for multi-tenant job isolation';
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
+
197
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
+
198
223
  COMMENT ON COLUMN app_jobs.jobs.task_identifier IS 'Identifier for the task type (maps to a worker handler function)';
224
+
199
225
  COMMENT ON COLUMN app_jobs.jobs.payload IS 'JSON payload of arguments passed to the task handler';
226
+
200
227
  COMMENT ON COLUMN app_jobs.jobs.priority IS 'Execution priority; lower numbers run first (default 0)';
228
+
201
229
  COMMENT ON COLUMN app_jobs.jobs.run_at IS 'Earliest time this job should be executed; used for delayed/scheduled execution';
230
+
202
231
  COMMENT ON COLUMN app_jobs.jobs.attempts IS 'Number of times this job has been attempted so far';
232
+
203
233
  COMMENT ON COLUMN app_jobs.jobs.max_attempts IS 'Maximum retry attempts before the job is considered permanently failed';
234
+
204
235
  COMMENT ON COLUMN app_jobs.jobs.key IS 'Optional unique deduplication key; prevents duplicate jobs with the same key';
236
+
205
237
  COMMENT ON COLUMN app_jobs.jobs.last_error IS 'Error message from the most recent failed attempt';
238
+
206
239
  COMMENT ON COLUMN app_jobs.jobs.locked_at IS 'Timestamp when a worker locked this job for processing';
240
+
207
241
  COMMENT ON COLUMN app_jobs.jobs.locked_by IS 'Identifier of the worker that currently holds the lock';
208
242
 
243
+ COMMENT ON COLUMN app_jobs.jobs.is_available IS 'Generated column: true when job is unlocked and has remaining attempts';
244
+
209
245
  ALTER TABLE app_jobs.jobs
210
246
  ADD COLUMN created_at timestamptz;
211
247
 
@@ -250,11 +286,19 @@ CREATE TRIGGER _500_increase_job_queue_count_on_update
250
286
  AND new.queue_name IS NOT NULL)
251
287
  EXECUTE PROCEDURE app_jobs.tg_increase_job_queue_count();
252
288
 
253
- CREATE TRIGGER _900_notify_worker
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
254
298
  AFTER INSERT
255
299
  ON app_jobs.jobs
256
- FOR EACH ROW
257
- EXECUTE PROCEDURE app_jobs.do_notify('jobs:insert');
300
+ FOR EACH STATEMENT
301
+ EXECUTE PROCEDURE app_jobs.tg_jobs__after_insert();
258
302
 
259
303
  CREATE FUNCTION app_jobs.tg_decrease_job_queue_count() RETURNS trigger AS $EOFCODE$
260
304
  DECLARE
@@ -292,7 +336,10 @@ CREATE TRIGGER decrease_job_queue_count_on_update
292
336
  AND old.queue_name IS NOT NULL)
293
337
  EXECUTE PROCEDURE app_jobs.tg_decrease_job_queue_count();
294
338
 
295
- CREATE INDEX priority_run_at_id_idx ON app_jobs.jobs (priority, run_at, id);
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;
296
343
 
297
344
  CREATE INDEX jobs_locked_by_idx ON app_jobs.jobs (locked_by);
298
345
 
@@ -306,9 +353,13 @@ CREATE TABLE app_jobs.job_queues (
306
353
  );
307
354
 
308
355
  COMMENT ON TABLE app_jobs.job_queues IS 'Queue metadata: tracks job counts and locking state for each named queue';
356
+
309
357
  COMMENT ON COLUMN app_jobs.job_queues.queue_name IS 'Unique name identifying this queue';
358
+
310
359
  COMMENT ON COLUMN app_jobs.job_queues.job_count IS 'Number of pending jobs in this queue';
360
+
311
361
  COMMENT ON COLUMN app_jobs.job_queues.locked_at IS 'Timestamp when this queue was locked for batch processing';
362
+
312
363
  COMMENT ON COLUMN app_jobs.job_queues.locked_by IS 'Identifier of the worker that currently holds the queue lock';
313
364
 
314
365
  CREATE INDEX job_queues_locked_by_idx ON app_jobs.job_queues (locked_by);
@@ -350,6 +401,7 @@ BEGIN
350
401
  -- insert new job
351
402
  INSERT INTO app_jobs.jobs (
352
403
  database_id,
404
+ actor_id,
353
405
  queue_name,
354
406
  task_identifier,
355
407
  payload,
@@ -358,6 +410,7 @@ BEGIN
358
410
  key
359
411
  ) SELECT
360
412
  database_id,
413
+ actor_id,
361
414
  queue_name,
362
415
  task_identifier,
363
416
  payload,
@@ -510,77 +563,50 @@ DECLARE
510
563
  v_row app_jobs.jobs;
511
564
  v_now timestamptz = now();
512
565
  BEGIN
513
-
514
566
  IF worker_id IS NULL THEN
515
- RAISE exception 'INVALID_WORKER_ID';
567
+ RAISE EXCEPTION 'INVALID_WORKER_ID';
516
568
  END IF;
517
569
 
518
- --
519
-
520
- SELECT
521
- jobs.queue_name,
522
- jobs.id INTO v_queue_name,
523
- v_job_id
524
- FROM
525
- app_jobs.jobs
526
- WHERE (jobs.locked_at IS NULL
527
- OR jobs.locked_at < (v_now - job_expiry))
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))
528
576
  AND (jobs.queue_name IS NULL
529
- OR EXISTS (
530
- SELECT
531
- 1
532
- FROM
533
- app_jobs.job_queues
534
- WHERE
535
- job_queues.queue_name = jobs.queue_name
536
- AND (job_queues.locked_at IS NULL
537
- OR job_queues.locked_at < (v_now - job_expiry))
538
- FOR UPDATE
539
- SKIP LOCKED))
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
+ ))
540
584
  AND run_at <= v_now
541
585
  AND attempts < max_attempts
542
586
  AND (task_identifiers IS NULL
543
587
  OR task_identifier = ANY (task_identifiers))
544
- ORDER BY
545
- priority ASC,
546
- run_at ASC,
547
- id ASC
588
+ ORDER BY priority ASC, run_at ASC, id ASC
548
589
  LIMIT 1
549
- FOR UPDATE
550
- SKIP LOCKED;
551
-
552
- --
590
+ FOR UPDATE SKIP LOCKED;
553
591
 
554
592
  IF v_job_id IS NULL THEN
555
593
  RETURN NULL;
556
594
  END IF;
557
595
 
558
- --
559
-
560
596
  IF v_queue_name IS NOT NULL THEN
561
- UPDATE
562
- app_jobs.job_queues
563
- SET
564
- locked_by = worker_id,
565
- locked_at = v_now
566
- WHERE
567
- job_queues.queue_name = v_queue_name;
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;
568
600
  END IF;
569
601
 
570
- --
571
-
572
- UPDATE
573
- app_jobs.jobs
602
+ UPDATE app_jobs.jobs
574
603
  SET
575
604
  attempts = attempts + 1,
576
605
  locked_by = worker_id,
577
606
  locked_at = v_now
578
- WHERE
579
- id = v_job_id
580
- RETURNING
581
- * INTO v_row;
607
+ WHERE id = v_job_id
608
+ RETURNING * INTO v_row;
582
609
 
583
- --
584
610
  RETURN v_row;
585
611
  END;
586
612
  $EOFCODE$;
@@ -646,15 +672,21 @@ BEGIN
646
672
  END;
647
673
  $EOFCODE$;
648
674
 
649
- CREATE FUNCTION app_jobs.add_scheduled_job(db_id uuid, 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$
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$
650
676
  DECLARE
651
677
  v_job app_jobs.scheduled_jobs;
678
+ v_database_id uuid;
679
+ v_actor_id uuid;
652
680
  BEGIN
681
+ v_database_id := jwt_private.current_database_id();
682
+ v_actor_id := jwt_public.current_user_id();
683
+
653
684
  IF job_key IS NOT NULL THEN
654
685
 
655
- -- Upsert job
686
+ -- Upsert job
656
687
  INSERT INTO app_jobs.scheduled_jobs (
657
688
  database_id,
689
+ actor_id,
658
690
  task_identifier,
659
691
  payload,
660
692
  queue_name,
@@ -663,7 +695,8 @@ BEGIN
663
695
  key,
664
696
  priority
665
697
  ) VALUES (
666
- db_id,
698
+ v_database_id,
699
+ v_actor_id,
667
700
  identifier,
668
701
  coalesce(payload, '{}'::json),
669
702
  queue_name,
@@ -671,37 +704,38 @@ BEGIN
671
704
  coalesce(max_attempts, 25),
672
705
  job_key,
673
706
  coalesce(priority, 0)
674
- )
675
- ON CONFLICT (key)
676
- DO UPDATE SET
707
+ )
708
+ ON CONFLICT (key)
709
+ DO UPDATE SET
677
710
  task_identifier = EXCLUDED.task_identifier,
678
711
  payload = EXCLUDED.payload,
679
712
  queue_name = EXCLUDED.queue_name,
680
713
  max_attempts = EXCLUDED.max_attempts,
681
714
  schedule_info = EXCLUDED.schedule_info,
682
715
  priority = EXCLUDED.priority
683
- WHERE
684
- scheduled_jobs.locked_at IS NULL
685
- RETURNING
686
- * INTO v_job;
687
-
688
- -- If upsert succeeded (insert or update), return early
689
-
690
- IF NOT (v_job IS NULL) THEN
691
- RETURN v_job;
692
- END IF;
693
-
694
- -- Upsert failed -> there must be an existing scheduled job that is locked. Remove
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
695
728
  -- and allow a new one to be inserted
696
729
 
697
- DELETE FROM
698
- app_jobs.scheduled_jobs
699
- WHERE
700
- KEY = job_key;
730
+ DELETE FROM
731
+ app_jobs.scheduled_jobs
732
+ WHERE
733
+ KEY = job_key;
701
734
  END IF;
702
735
 
703
736
  INSERT INTO app_jobs.scheduled_jobs (
704
737
  database_id,
738
+ actor_id,
705
739
  task_identifier,
706
740
  payload,
707
741
  queue_name,
@@ -709,7 +743,8 @@ BEGIN
709
743
  max_attempts,
710
744
  priority
711
745
  ) VALUES (
712
- db_id,
746
+ v_database_id,
747
+ v_actor_id,
713
748
  identifier,
714
749
  payload,
715
750
  queue_name,
@@ -721,14 +756,21 @@ BEGIN
721
756
  END;
722
757
  $EOFCODE$ LANGUAGE plpgsql VOLATILE SECURITY DEFINER;
723
758
 
724
- CREATE FUNCTION app_jobs.add_job(db_id uuid, 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$
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$
725
760
  DECLARE
726
761
  v_job app_jobs.jobs;
762
+ v_database_id uuid;
763
+ v_actor_id uuid;
727
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
+
728
769
  IF job_key IS NOT NULL THEN
729
- -- Upsert job
770
+ -- Upsert job
730
771
  INSERT INTO app_jobs.jobs (
731
772
  database_id,
773
+ actor_id,
732
774
  task_identifier,
733
775
  payload,
734
776
  queue_name,
@@ -737,52 +779,51 @@ BEGIN
737
779
  key,
738
780
  priority
739
781
  ) VALUES (
740
- db_id,
782
+ v_database_id,
783
+ v_actor_id,
741
784
  identifier,
742
- coalesce(payload,
743
- '{}'::json),
785
+ coalesce(payload, '{}'::json),
744
786
  queue_name,
745
787
  coalesce(run_at, now()),
746
788
  coalesce(max_attempts, 25),
747
789
  job_key,
748
790
  coalesce(priority, 0)
749
- )
750
- ON CONFLICT (key)
751
- DO UPDATE SET
791
+ )
792
+ ON CONFLICT (key)
793
+ DO UPDATE SET
752
794
  task_identifier = EXCLUDED.task_identifier,
753
795
  payload = EXCLUDED.payload,
754
796
  queue_name = EXCLUDED.queue_name,
755
797
  max_attempts = EXCLUDED.max_attempts,
756
798
  run_at = EXCLUDED.run_at,
757
- priority = EXCLUDED.priority,
758
- -- always reset error/retry state
759
- attempts = 0, last_error = NULL
760
- WHERE
761
- jobs.locked_at IS NULL
762
- RETURNING
763
- * INTO v_job;
764
-
765
- -- If upsert succeeded (insert or update), return early
766
-
767
- IF NOT (v_job IS NULL) THEN
768
- RETURN v_job;
769
- END IF;
770
-
771
- -- Upsert failed -> there must be an existing job that is locked. Remove
772
- -- existing key to allow a new one to be inserted, and prevent any
773
- -- subsequent retries by bumping attempts to the max allowed.
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;
774
811
 
775
- UPDATE
776
- app_jobs.jobs
777
- SET
778
- KEY = NULL,
779
- attempts = jobs.max_attempts
780
- WHERE
781
- KEY = job_key;
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;
782
822
  END IF;
783
823
 
784
824
  INSERT INTO app_jobs.jobs (
785
825
  database_id,
826
+ actor_id,
786
827
  task_identifier,
787
828
  payload,
788
829
  queue_name,
@@ -790,7 +831,8 @@ BEGIN
790
831
  max_attempts,
791
832
  priority
792
833
  ) VALUES (
793
- db_id,
834
+ v_database_id,
835
+ v_actor_id,
794
836
  identifier,
795
837
  payload,
796
838
  queue_name,
@@ -803,3 +845,38 @@ BEGIN
803
845
  RETURN v_job;
804
846
  END;
805
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$;
@@ -0,0 +1,7 @@
1
+ -- Verify schemas/app_jobs/procedures/force_unlock_workers on pg
2
+
3
+ BEGIN;
4
+
5
+ SELECT verify_function ('app_jobs.force_unlock_workers');
6
+
7
+ ROLLBACK;
@@ -0,0 +1,7 @@
1
+ -- Verify schemas/app_jobs/procedures/remove_job on pg
2
+
3
+ BEGIN;
4
+
5
+ SELECT verify_function ('app_jobs.remove_job');
6
+
7
+ ROLLBACK;
@@ -2,6 +2,7 @@
2
2
 
3
3
  BEGIN;
4
4
 
5
- SELECT verify_index ('app_jobs.jobs', 'priority_run_at_id_idx');
5
+ SELECT verify_index ('app_jobs.jobs', 'jobs_main_index');
6
+ SELECT verify_index ('app_jobs.jobs', 'jobs_no_queue_index');
6
7
 
7
8
  ROLLBACK;
@@ -1,6 +1,5 @@
1
1
  -- Verify schemas/app_jobs/tables/jobs/triggers/notify_worker on pg
2
2
  BEGIN;
3
3
  SELECT
4
- verify_trigger ('app_jobs._900_notify_worker');
4
+ verify_trigger ('app_jobs._900_after_insert');
5
5
  ROLLBACK;
6
-