pgtask 0.3.0__tar.gz → 0.4.0__tar.gz

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 (84) hide show
  1. {pgtask-0.3.0 → pgtask-0.4.0}/Cargo.lock +11 -11
  2. {pgtask-0.3.0 → pgtask-0.4.0}/Cargo.toml +1 -1
  3. {pgtask-0.3.0 → pgtask-0.4.0}/PKG-INFO +1 -1
  4. {pgtask-0.3.0 → pgtask-0.4.0}/crates/pgtask/Cargo.toml +4 -4
  5. {pgtask-0.3.0 → pgtask-0.4.0}/crates/pgtask-postgres/Cargo.toml +2 -2
  6. pgtask-0.4.0/crates/pgtask-postgres/migrations/0010_batch_task_transitions.sql +189 -0
  7. pgtask-0.4.0/crates/pgtask-postgres/migrations/0011_null_idle_delays.sql +79 -0
  8. pgtask-0.4.0/crates/pgtask-postgres/migrations/0013_resolve_waits_of_finished_tasks.sql +81 -0
  9. pgtask-0.4.0/crates/pgtask-postgres/migrations/0014_retention_throughput.sql +139 -0
  10. pgtask-0.4.0/crates/pgtask-postgres/migrations/0015_release_tasks.sql +104 -0
  11. {pgtask-0.3.0 → pgtask-0.4.0}/crates/pgtask-postgres/src/lib.rs +1 -1
  12. {pgtask-0.3.0 → pgtask-0.4.0}/crates/pgtask-postgres/src/store.rs +285 -49
  13. {pgtask-0.3.0 → pgtask-0.4.0}/crates/pgtask-postgres/tests/guarantees.rs +178 -1
  14. pgtask-0.4.0/crates/pgtask-postgres/tests/idle_delays.rs +169 -0
  15. {pgtask-0.3.0 → pgtask-0.4.0}/crates/pgtask-postgres/tests/linearizability.rs +52 -10
  16. {pgtask-0.3.0 → pgtask-0.4.0}/crates/pgtask-postgres/tests/queue.rs +216 -3
  17. {pgtask-0.3.0 → pgtask-0.4.0}/crates/pgtask-postgres/tests/results.rs +70 -0
  18. {pgtask-0.3.0 → pgtask-0.4.0}/crates/pgtask-postgres/tests/round_trip_properties.rs +17 -1
  19. {pgtask-0.3.0 → pgtask-0.4.0}/crates/pgtask-postgres/tests/schedule.rs +104 -2
  20. {pgtask-0.3.0 → pgtask-0.4.0}/crates/pgtask-postgres/tests/signals.rs +86 -0
  21. {pgtask-0.3.0 → pgtask-0.4.0}/crates/pgtask-postgres/tests/sql_surface.txt +12 -0
  22. {pgtask-0.3.0 → pgtask-0.4.0}/crates/pgtask-worker/Cargo.toml +3 -3
  23. {pgtask-0.3.0 → pgtask-0.4.0}/crates/pgtask-worker/src/lib.rs +1 -0
  24. pgtask-0.4.0/crates/pgtask-worker/src/outcome.rs +289 -0
  25. {pgtask-0.3.0 → pgtask-0.4.0}/crates/pgtask-worker/src/registry.rs +35 -12
  26. {pgtask-0.3.0 → pgtask-0.4.0}/crates/pgtask-worker/src/runtime.rs +564 -139
  27. pgtask-0.4.0/crates/pgtask-worker/tests/idle.rs +123 -0
  28. pgtask-0.4.0/crates/pgtask-worker/tests/lifecycle.rs +245 -0
  29. pgtask-0.4.0/crates/pgtask-worker/tests/outcomes.rs +298 -0
  30. pgtask-0.4.0/crates/pgtask-worker/tests/retention.rs +159 -0
  31. pgtask-0.4.0/crates/pgtask-worker/tests/schedule_failures.rs +168 -0
  32. {pgtask-0.3.0 → pgtask-0.4.0}/crates/pgtask-worker/tests/worker.rs +178 -8
  33. {pgtask-0.3.0 → pgtask-0.4.0}/python/pgtask/__init__.py +3 -0
  34. {pgtask-0.3.0 → pgtask-0.4.0}/python/pgtask/_native.pyi +3 -0
  35. {pgtask-0.3.0 → pgtask-0.4.0}/python/pgtask/client.py +41 -1
  36. {pgtask-0.3.0 → pgtask-0.4.0}/sdks/python/Cargo.toml +1 -1
  37. {pgtask-0.3.0 → pgtask-0.4.0}/sdks/python/src/lib.rs +121 -8
  38. {pgtask-0.3.0 → pgtask-0.4.0}/sdks/python/tests/test_python_sdk.py +342 -3
  39. {pgtask-0.3.0 → pgtask-0.4.0}/README.md +0 -0
  40. {pgtask-0.3.0 → pgtask-0.4.0}/crates/pgtask/README.md +0 -0
  41. {pgtask-0.3.0 → pgtask-0.4.0}/crates/pgtask/src/lib.rs +0 -0
  42. {pgtask-0.3.0 → pgtask-0.4.0}/crates/pgtask/tests/public_api.rs +0 -0
  43. {pgtask-0.3.0 → pgtask-0.4.0}/crates/pgtask-core/Cargo.toml +0 -0
  44. {pgtask-0.3.0 → pgtask-0.4.0}/crates/pgtask-core/README.md +0 -0
  45. {pgtask-0.3.0 → pgtask-0.4.0}/crates/pgtask-core/src/identifier.rs +0 -0
  46. {pgtask-0.3.0 → pgtask-0.4.0}/crates/pgtask-core/src/lib.rs +0 -0
  47. {pgtask-0.3.0 → pgtask-0.4.0}/crates/pgtask-core/src/retry.rs +0 -0
  48. {pgtask-0.3.0 → pgtask-0.4.0}/crates/pgtask-core/src/schedule.rs +0 -0
  49. {pgtask-0.3.0 → pgtask-0.4.0}/crates/pgtask-core/src/task.rs +0 -0
  50. {pgtask-0.3.0 → pgtask-0.4.0}/crates/pgtask-core/tests/.gitignore +0 -0
  51. {pgtask-0.3.0 → pgtask-0.4.0}/crates/pgtask-core/tests/retry_properties.rs +0 -0
  52. {pgtask-0.3.0 → pgtask-0.4.0}/crates/pgtask-core/tests/schedule_clock.rs +0 -0
  53. {pgtask-0.3.0 → pgtask-0.4.0}/crates/pgtask-core/tests/schedule_properties.rs +0 -0
  54. {pgtask-0.3.0 → pgtask-0.4.0}/crates/pgtask-core/tests/types.rs +0 -0
  55. {pgtask-0.3.0 → pgtask-0.4.0}/crates/pgtask-otel/Cargo.toml +0 -0
  56. {pgtask-0.3.0 → pgtask-0.4.0}/crates/pgtask-otel/README.md +0 -0
  57. {pgtask-0.3.0 → pgtask-0.4.0}/crates/pgtask-otel/src/lib.rs +0 -0
  58. {pgtask-0.3.0 → pgtask-0.4.0}/crates/pgtask-otel/src/metrics.rs +0 -0
  59. {pgtask-0.3.0 → pgtask-0.4.0}/crates/pgtask-otel/src/propagation.rs +0 -0
  60. {pgtask-0.3.0 → pgtask-0.4.0}/crates/pgtask-otel/tests/propagation.rs +0 -0
  61. {pgtask-0.3.0 → pgtask-0.4.0}/crates/pgtask-postgres/README.md +0 -0
  62. {pgtask-0.3.0 → pgtask-0.4.0}/crates/pgtask-postgres/build.rs +0 -0
  63. {pgtask-0.3.0 → pgtask-0.4.0}/crates/pgtask-postgres/migrations/0001_initial.sql +0 -0
  64. {pgtask-0.3.0 → pgtask-0.4.0}/crates/pgtask-postgres/migrations/0003_remove_redundant_notifications.sql +0 -0
  65. {pgtask-0.3.0 → pgtask-0.4.0}/crates/pgtask-postgres/migrations/0004_sample_queue_demand.sql +0 -0
  66. {pgtask-0.3.0 → pgtask-0.4.0}/crates/pgtask-postgres/migrations/0005_serialise_result_wait_registration.sql +0 -0
  67. {pgtask-0.3.0 → pgtask-0.4.0}/crates/pgtask-postgres/migrations/0006_keep_resumed_tasks_claimable.sql +0 -0
  68. {pgtask-0.3.0 → pgtask-0.4.0}/crates/pgtask-postgres/migrations/0007_backfill_failed_attempts.sql +0 -0
  69. {pgtask-0.3.0 → pgtask-0.4.0}/crates/pgtask-postgres/migrations/0008_index_worker_pagination.sql +0 -0
  70. {pgtask-0.3.0 → pgtask-0.4.0}/crates/pgtask-postgres/tests/.gitignore +0 -0
  71. {pgtask-0.3.0 → pgtask-0.4.0}/crates/pgtask-postgres/tests/corruption.rs +0 -0
  72. {pgtask-0.3.0 → pgtask-0.4.0}/crates/pgtask-postgres/tests/model.rs +0 -0
  73. {pgtask-0.3.0 → pgtask-0.4.0}/crates/pgtask-postgres/tests/races.rs +0 -0
  74. {pgtask-0.3.0 → pgtask-0.4.0}/crates/pgtask-postgres/tests/result_wait_locking.rs +0 -0
  75. {pgtask-0.3.0 → pgtask-0.4.0}/crates/pgtask-postgres/tests/sql_surface.rs +0 -0
  76. {pgtask-0.3.0 → pgtask-0.4.0}/crates/pgtask-postgres/tests/sql_surface_baseline.txt +0 -0
  77. {pgtask-0.3.0 → pgtask-0.4.0}/crates/pgtask-worker/README.md +0 -0
  78. {pgtask-0.3.0 → pgtask-0.4.0}/crates/pgtask-worker/src/health.rs +0 -0
  79. {pgtask-0.3.0 → pgtask-0.4.0}/crates/pgtask-worker/tests/durable_restarts.rs +0 -0
  80. {pgtask-0.3.0 → pgtask-0.4.0}/pyproject.toml +0 -0
  81. {pgtask-0.3.0 → pgtask-0.4.0}/python/pgtask/py.typed +0 -0
  82. {pgtask-0.3.0 → pgtask-0.4.0}/sdks/python/README.md +0 -0
  83. {pgtask-0.3.0 → pgtask-0.4.0}/sdks/python/tests/__init__.py +0 -0
  84. {pgtask-0.3.0 → pgtask-0.4.0}/sdks/python/uv.lock +0 -0
@@ -1195,7 +1195,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
1195
1195
 
1196
1196
  [[package]]
1197
1197
  name = "pgtask"
1198
- version = "0.3.0"
1198
+ version = "0.4.0"
1199
1199
  dependencies = [
1200
1200
  "pgtask-core",
1201
1201
  "pgtask-otel",
@@ -1208,7 +1208,7 @@ dependencies = [
1208
1208
 
1209
1209
  [[package]]
1210
1210
  name = "pgtask-bench"
1211
- version = "0.3.0"
1211
+ version = "0.4.0"
1212
1212
  dependencies = [
1213
1213
  "chrono",
1214
1214
  "pgtask",
@@ -1221,7 +1221,7 @@ dependencies = [
1221
1221
 
1222
1222
  [[package]]
1223
1223
  name = "pgtask-cli"
1224
- version = "0.3.0"
1224
+ version = "0.4.0"
1225
1225
  dependencies = [
1226
1226
  "clap",
1227
1227
  "pgtask",
@@ -1231,7 +1231,7 @@ dependencies = [
1231
1231
 
1232
1232
  [[package]]
1233
1233
  name = "pgtask-core"
1234
- version = "0.3.0"
1234
+ version = "0.4.0"
1235
1235
  dependencies = [
1236
1236
  "chrono",
1237
1237
  "cron",
@@ -1245,7 +1245,7 @@ dependencies = [
1245
1245
 
1246
1246
  [[package]]
1247
1247
  name = "pgtask-otel"
1248
- version = "0.3.0"
1248
+ version = "0.4.0"
1249
1249
  dependencies = [
1250
1250
  "opentelemetry",
1251
1251
  "opentelemetry_sdk",
@@ -1256,7 +1256,7 @@ dependencies = [
1256
1256
 
1257
1257
  [[package]]
1258
1258
  name = "pgtask-postgres"
1259
- version = "0.3.0"
1259
+ version = "0.4.0"
1260
1260
  dependencies = [
1261
1261
  "chrono",
1262
1262
  "pgtask-core",
@@ -1273,7 +1273,7 @@ dependencies = [
1273
1273
 
1274
1274
  [[package]]
1275
1275
  name = "pgtask-python"
1276
- version = "0.3.0"
1276
+ version = "0.4.0"
1277
1277
  dependencies = [
1278
1278
  "chrono",
1279
1279
  "pgtask",
@@ -1290,7 +1290,7 @@ dependencies = [
1290
1290
 
1291
1291
  [[package]]
1292
1292
  name = "pgtask-web"
1293
- version = "0.3.0"
1293
+ version = "0.4.0"
1294
1294
  dependencies = [
1295
1295
  "axum",
1296
1296
  "chrono",
@@ -1309,7 +1309,7 @@ dependencies = [
1309
1309
 
1310
1310
  [[package]]
1311
1311
  name = "pgtask-worker"
1312
- version = "0.3.0"
1312
+ version = "0.4.0"
1313
1313
  dependencies = [
1314
1314
  "axum",
1315
1315
  "chrono",
@@ -1674,9 +1674,9 @@ dependencies = [
1674
1674
 
1675
1675
  [[package]]
1676
1676
  name = "rustls"
1677
- version = "0.23.43"
1677
+ version = "0.23.45"
1678
1678
  source = "registry+https://github.com/rust-lang/crates.io-index"
1679
- checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
1679
+ checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
1680
1680
  dependencies = [
1681
1681
  "once_cell",
1682
1682
  "ring",
@@ -7,7 +7,7 @@ edition = "2024"
7
7
  license = "MIT"
8
8
  repository = "https://github.com/Kludex/pgtask"
9
9
  rust-version = "1.94"
10
- version = "0.3.0"
10
+ version = "0.4.0"
11
11
 
12
12
  [workspace.dependencies]
13
13
  axum = "0.8.9"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pgtask
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Classifier: Development Status :: 2 - Pre-Alpha
5
5
  Classifier: Programming Language :: Python :: 3 :: Only
6
6
  Classifier: Programming Language :: Python :: 3.10
@@ -9,10 +9,10 @@ version.workspace = true
9
9
  readme = "README.md"
10
10
 
11
11
  [dependencies]
12
- pgtask-core = { path = "../pgtask-core", version = "0.3.0" }
13
- pgtask-otel = { path = "../pgtask-otel", version = "0.3.0" }
14
- pgtask-postgres = { path = "../pgtask-postgres", version = "0.3.0" }
15
- pgtask-worker = { path = "../pgtask-worker", version = "0.3.0" }
12
+ pgtask-core = { path = "../pgtask-core", version = "0.4.0" }
13
+ pgtask-otel = { path = "../pgtask-otel", version = "0.4.0" }
14
+ pgtask-postgres = { path = "../pgtask-postgres", version = "0.4.0" }
15
+ pgtask-worker = { path = "../pgtask-worker", version = "0.4.0" }
16
16
 
17
17
  [dev-dependencies]
18
18
  serde_json.workspace = true
@@ -10,8 +10,8 @@ readme = "README.md"
10
10
 
11
11
  [dependencies]
12
12
  chrono.workspace = true
13
- pgtask-core = { path = "../pgtask-core", version = "0.3.0" }
14
- pgtask-otel = { path = "../pgtask-otel", version = "0.3.0" }
13
+ pgtask-core = { path = "../pgtask-core", version = "0.4.0" }
14
+ pgtask-otel = { path = "../pgtask-otel", version = "0.4.0" }
15
15
  serde_json.workspace = true
16
16
  sqlx.workspace = true
17
17
  thiserror.workspace = true
@@ -0,0 +1,189 @@
1
+ CREATE FUNCTION pgtask.complete_tasks(p_completions jsonb)
2
+ RETURNS TABLE(request_index bigint, completed boolean)
3
+ LANGUAGE plpgsql
4
+ SECURITY DEFINER
5
+ SET search_path = pg_catalog, pgtask
6
+ AS $$
7
+ BEGIN
8
+ IF p_completions IS NULL OR jsonb_typeof(p_completions) <> 'array' THEN
9
+ RAISE EXCEPTION 'completions must be a JSON array' USING ERRCODE = '22023';
10
+ END IF;
11
+ IF EXISTS (
12
+ SELECT 1
13
+ FROM jsonb_array_elements(p_completions) AS items(completion)
14
+ GROUP BY completion->>'task_id'
15
+ HAVING count(*) > 1
16
+ ) THEN
17
+ RAISE EXCEPTION 'completions contain duplicate task IDs' USING ERRCODE = '22023';
18
+ END IF;
19
+
20
+ RETURN QUERY
21
+ WITH requested AS (
22
+ SELECT
23
+ items.ordinality - 1 AS request_index,
24
+ (items.completion->>'task_id')::uuid AS task_id,
25
+ (items.completion->>'attempt')::integer AS attempt,
26
+ (items.completion->>'lease_token')::uuid AS lease_token,
27
+ CASE
28
+ WHEN (items.completion->>'has_result')::boolean THEN items.completion->'result'
29
+ ELSE NULL
30
+ END AS result
31
+ FROM jsonb_array_elements(p_completions) WITH ORDINALITY AS items(completion, ordinality)
32
+ ),
33
+ completed_tasks AS (
34
+ UPDATE pgtask.tasks
35
+ SET state = 'succeeded',
36
+ lease_token = NULL,
37
+ lease_owner = NULL,
38
+ lease_expires_at = NULL,
39
+ completed_at = statement_timestamp(),
40
+ updated_at = statement_timestamp(),
41
+ result = requested.result,
42
+ error = NULL
43
+ FROM requested
44
+ WHERE tasks.id = requested.task_id
45
+ AND tasks.state = 'running'
46
+ AND tasks.attempt = requested.attempt
47
+ AND tasks.lease_token = requested.lease_token
48
+ RETURNING requested.request_index, tasks.id, tasks.attempt
49
+ ),
50
+ completed_attempts AS (
51
+ UPDATE pgtask.attempts
52
+ SET state = 'succeeded', finished_at = statement_timestamp(), error = NULL
53
+ FROM completed_tasks
54
+ WHERE attempts.task_id = completed_tasks.id AND attempts.attempt = completed_tasks.attempt
55
+ )
56
+ SELECT requested.request_index, completed_tasks.id IS NOT NULL
57
+ FROM requested
58
+ LEFT JOIN completed_tasks USING (request_index)
59
+ ORDER BY requested.request_index;
60
+ END;
61
+ $$;
62
+
63
+ CREATE FUNCTION pgtask.fail_tasks(p_failures jsonb)
64
+ RETURNS TABLE(request_index bigint, state text)
65
+ LANGUAGE plpgsql
66
+ SECURITY DEFINER
67
+ SET search_path = pg_catalog, pgtask
68
+ AS $$
69
+ BEGIN
70
+ IF p_failures IS NULL OR jsonb_typeof(p_failures) <> 'array' THEN
71
+ RAISE EXCEPTION 'failures must be a JSON array' USING ERRCODE = '22023';
72
+ END IF;
73
+ IF EXISTS (
74
+ SELECT 1
75
+ FROM jsonb_array_elements(p_failures) AS items(failure)
76
+ GROUP BY failure->>'task_id'
77
+ HAVING count(*) > 1
78
+ ) THEN
79
+ RAISE EXCEPTION 'failures contain duplicate task IDs' USING ERRCODE = '22023';
80
+ END IF;
81
+
82
+ RETURN QUERY
83
+ WITH requested AS (
84
+ SELECT
85
+ items.ordinality - 1 AS request_index,
86
+ (items.failure->>'task_id')::uuid AS task_id,
87
+ (items.failure->>'attempt')::integer AS attempt,
88
+ (items.failure->>'lease_token')::uuid AS lease_token,
89
+ items.failure->'error' AS error,
90
+ (items.failure->>'retry_milliseconds')::bigint AS retry_milliseconds
91
+ FROM jsonb_array_elements(p_failures) WITH ORDINALITY AS items(failure, ordinality)
92
+ ),
93
+ failed_tasks AS (
94
+ UPDATE pgtask.tasks
95
+ SET state = CASE
96
+ WHEN requested.retry_milliseconds IS NOT NULL
97
+ AND COALESCE(tasks.failed_attempts, (
98
+ SELECT count(*)::integer FROM pgtask.attempts AS history
99
+ WHERE history.task_id = tasks.id AND history.state IN ('failed', 'lost')
100
+ )) + 1 < tasks.max_attempts THEN 'pending'
101
+ ELSE 'failed'
102
+ END,
103
+ run_at = CASE
104
+ WHEN requested.retry_milliseconds IS NOT NULL
105
+ THEN statement_timestamp() + (requested.retry_milliseconds * interval '1 millisecond')
106
+ ELSE tasks.run_at
107
+ END,
108
+ lease_token = NULL,
109
+ lease_owner = NULL,
110
+ lease_expires_at = NULL,
111
+ completed_at = CASE
112
+ WHEN requested.retry_milliseconds IS NOT NULL
113
+ AND COALESCE(tasks.failed_attempts, (
114
+ SELECT count(*)::integer FROM pgtask.attempts AS history
115
+ WHERE history.task_id = tasks.id AND history.state IN ('failed', 'lost')
116
+ )) + 1 < tasks.max_attempts THEN NULL
117
+ ELSE statement_timestamp()
118
+ END,
119
+ updated_at = statement_timestamp(),
120
+ error = requested.error,
121
+ failed_attempts = COALESCE(tasks.failed_attempts, (
122
+ SELECT count(*)::integer FROM pgtask.attempts AS history
123
+ WHERE history.task_id = tasks.id AND history.state IN ('failed', 'lost')
124
+ )) + 1
125
+ FROM requested
126
+ WHERE tasks.id = requested.task_id
127
+ AND tasks.state = 'running'
128
+ AND tasks.attempt = requested.attempt
129
+ AND tasks.lease_token = requested.lease_token
130
+ RETURNING requested.request_index, tasks.id, tasks.attempt, tasks.state, requested.error
131
+ ),
132
+ failed_attempts AS (
133
+ UPDATE pgtask.attempts
134
+ SET state = 'failed', finished_at = statement_timestamp(), error = failed_tasks.error
135
+ FROM failed_tasks
136
+ WHERE attempts.task_id = failed_tasks.id AND attempts.attempt = failed_tasks.attempt
137
+ )
138
+ SELECT requested.request_index, failed_tasks.state
139
+ FROM requested
140
+ LEFT JOIN failed_tasks USING (request_index)
141
+ ORDER BY requested.request_index;
142
+ END;
143
+ $$;
144
+
145
+ REVOKE ALL ON FUNCTION pgtask.complete_tasks(jsonb) FROM PUBLIC;
146
+ REVOKE ALL ON FUNCTION pgtask.fail_tasks(jsonb) FROM PUBLIC;
147
+
148
+ DO $$
149
+ DECLARE
150
+ definition text;
151
+ rewritten text;
152
+ target regrole;
153
+ BEGIN
154
+ FOR target IN
155
+ SELECT privileges.grantee::regrole
156
+ FROM pg_proc
157
+ CROSS JOIN LATERAL aclexplode(pg_proc.proacl) AS privileges
158
+ WHERE pg_proc.oid = 'pgtask.complete_task(uuid, integer, uuid, jsonb)'::regprocedure
159
+ AND privileges.privilege_type = 'EXECUTE'
160
+ LOOP
161
+ EXECUTE format('GRANT EXECUTE ON FUNCTION pgtask.complete_tasks(jsonb) TO %s', target);
162
+ END LOOP;
163
+ FOR target IN
164
+ SELECT privileges.grantee::regrole
165
+ FROM pg_proc
166
+ CROSS JOIN LATERAL aclexplode(pg_proc.proacl) AS privileges
167
+ WHERE pg_proc.oid = 'pgtask.fail_task(uuid, integer, uuid, jsonb, bigint)'::regprocedure
168
+ AND privileges.privilege_type = 'EXECUTE'
169
+ LOOP
170
+ EXECUTE format('GRANT EXECUTE ON FUNCTION pgtask.fail_tasks(jsonb) TO %s', target);
171
+ END LOOP;
172
+
173
+ definition := pg_get_functiondef('pgtask.configure_grants(regrole, regrole, regrole, regrole, regrole)'::regprocedure);
174
+ rewritten := replace(
175
+ definition,
176
+ E' EXECUTE format(''GRANT EXECUTE ON FUNCTION pgtask.complete_task(uuid, integer, uuid, jsonb) TO %s'', p_worker);\n',
177
+ E' EXECUTE format(''GRANT EXECUTE ON FUNCTION pgtask.complete_task(uuid, integer, uuid, jsonb) TO %s'', p_worker);\n EXECUTE format(''GRANT EXECUTE ON FUNCTION pgtask.complete_tasks(jsonb) TO %s'', p_worker);\n'
178
+ );
179
+ rewritten := replace(
180
+ rewritten,
181
+ E' EXECUTE format(''GRANT EXECUTE ON FUNCTION pgtask.fail_task(uuid, integer, uuid, jsonb, bigint) TO %s'', p_worker);\n',
182
+ E' EXECUTE format(''GRANT EXECUTE ON FUNCTION pgtask.fail_task(uuid, integer, uuid, jsonb, bigint) TO %s'', p_worker);\n EXECUTE format(''GRANT EXECUTE ON FUNCTION pgtask.fail_tasks(jsonb) TO %s'', p_worker);\n'
183
+ );
184
+ IF rewritten = definition THEN
185
+ RAISE EXCEPTION 'could not extend pgtask.configure_grants';
186
+ END IF;
187
+ EXECUTE rewritten;
188
+ END;
189
+ $$;
@@ -0,0 +1,79 @@
1
+ -- The next_*_delay_milliseconds functions report how long a worker may sleep before the next
2
+ -- deadline, and NULL when there is no deadline at all. Wrapping min() in GREATEST(0, ...) broke the
3
+ -- NULL case: GREATEST ignores NULL arguments, so an empty set returned 0 and idle workers polled in
4
+ -- a tight loop. Filtering the aggregate with HAVING keeps the 0 floor for overdue deadlines and
5
+ -- returns no row, which a scalar SQL function reports as NULL, when nothing is due.
6
+ --
7
+ -- CREATE OR REPLACE keeps each function's owner and grants.
8
+
9
+ CREATE OR REPLACE FUNCTION pgtask.next_schedule_delay_milliseconds()
10
+ RETURNS bigint
11
+ LANGUAGE sql
12
+ STABLE
13
+ SECURITY DEFINER
14
+ SET search_path = pg_catalog, pgtask
15
+ AS $$
16
+ SELECT GREATEST(
17
+ 0,
18
+ ceil(EXTRACT(epoch FROM (min(next_run_at) - statement_timestamp())) * 1000)::bigint
19
+ )
20
+ FROM pgtask.schedules
21
+ WHERE paused_at IS NULL
22
+ HAVING min(next_run_at) IS NOT NULL;
23
+ $$;
24
+
25
+ CREATE OR REPLACE FUNCTION pgtask.next_wait_delay_milliseconds()
26
+ RETURNS bigint
27
+ LANGUAGE sql
28
+ STABLE
29
+ SECURITY DEFINER
30
+ SET search_path = pg_catalog, pgtask
31
+ AS $$
32
+ SELECT GREATEST(
33
+ 0,
34
+ ceil(EXTRACT(epoch FROM (min(deadlines.timeout_at) - statement_timestamp())) * 1000)::bigint
35
+ )
36
+ FROM (
37
+ SELECT waits.timeout_at
38
+ FROM pgtask.waits
39
+ WHERE waits.resolved_at IS NULL AND waits.timeout_at IS NOT NULL
40
+ UNION ALL
41
+ SELECT result_waits.timeout_at
42
+ FROM pgtask.result_waits
43
+ WHERE result_waits.resolved_at IS NULL AND result_waits.timeout_at IS NOT NULL
44
+ ) AS deadlines
45
+ HAVING min(deadlines.timeout_at) IS NOT NULL;
46
+ $$;
47
+
48
+ CREATE OR REPLACE FUNCTION pgtask.next_task_delay_milliseconds(
49
+ p_queue_name text,
50
+ p_task_names text[],
51
+ p_handler_versions integer[]
52
+ )
53
+ RETURNS bigint
54
+ LANGUAGE sql
55
+ STABLE
56
+ SECURITY DEFINER
57
+ SET search_path = pg_catalog, pgtask
58
+ AS $$
59
+ SELECT GREATEST(
60
+ 0,
61
+ ceil(EXTRACT(epoch FROM (min(tasks.run_at) - statement_timestamp())) * 1000)::bigint
62
+ )
63
+ FROM pgtask.tasks
64
+ WHERE tasks.queue_name = p_queue_name
65
+ AND tasks.state = 'pending'
66
+ AND COALESCE(tasks.failed_attempts, (SELECT count(*)::integer FROM pgtask.attempts AS history WHERE history.task_id = tasks.id AND history.state IN ('failed', 'lost'))) < tasks.max_attempts
67
+ AND EXISTS (
68
+ SELECT 1
69
+ FROM pgtask.queues
70
+ WHERE queues.name = tasks.queue_name AND queues.paused_at IS NULL
71
+ )
72
+ AND EXISTS (
73
+ SELECT 1
74
+ FROM unnest(p_task_names, p_handler_versions) AS handlers(task_name, handler_version)
75
+ WHERE handlers.task_name = tasks.task_name
76
+ AND handlers.handler_version = tasks.handler_version
77
+ )
78
+ HAVING min(tasks.run_at) IS NOT NULL;
79
+ $$;
@@ -0,0 +1,81 @@
1
+ -- A task that finishes while it waits for a signal (cancellation is the only way) used to keep its
2
+ -- pgtask.waits row unresolved. When an administrator retried it, the handler replayed to the same
3
+ -- wait_for_signal step and inserted the same key again, which failed on waits_pkey every attempt.
4
+ --
5
+ -- 1. Finishing a task resolves its open signal waits with outcome 'cancelled', in the trigger that
6
+ -- already does this for its result waits, and existing leftovers are resolved the same way.
7
+ -- 2. wait_for_signal and wait_for_result re-arm a wait row left over from an earlier attempt. They
8
+ -- hold the task row lock and have just found no checkpoint for the step, so any row under that
9
+ -- key is from an attempt that ended without the wait completing; a completed wait always writes
10
+ -- its checkpoint in the same transaction that resolves the row.
11
+ --
12
+ -- The functions are rewritten from their installed definitions, so earlier rewrites are kept.
13
+
14
+ ALTER TABLE pgtask.waits DROP CONSTRAINT waits_outcome_check;
15
+ ALTER TABLE pgtask.waits
16
+ ADD CONSTRAINT waits_outcome_check CHECK (outcome IN ('signal', 'timeout', 'cancelled'));
17
+
18
+ UPDATE pgtask.waits
19
+ SET resolved_at = statement_timestamp(), outcome = 'cancelled'
20
+ FROM pgtask.tasks
21
+ WHERE waits.task_id = tasks.id
22
+ AND waits.resolved_at IS NULL
23
+ AND tasks.state IN ('succeeded', 'failed', 'cancelled');
24
+
25
+ DO $$
26
+ DECLARE
27
+ definition text;
28
+ rewritten text;
29
+ BEGIN
30
+ definition := pg_get_functiondef('pgtask.resolve_task_result()'::regprocedure);
31
+ rewritten := replace(
32
+ definition,
33
+ E' UPDATE pgtask.result_waits\n SET resolved_at = statement_timestamp(), outcome = ''cancelled''\n WHERE task_id = NEW.id AND resolved_at IS NULL;\n',
34
+ E' UPDATE pgtask.result_waits\n SET resolved_at = statement_timestamp(), outcome = ''cancelled''\n WHERE task_id = NEW.id AND resolved_at IS NULL;\n\n'
35
+ || E' UPDATE pgtask.waits\n SET resolved_at = statement_timestamp(), outcome = ''cancelled''\n WHERE task_id = NEW.id AND resolved_at IS NULL;\n'
36
+ );
37
+ IF rewritten = definition THEN
38
+ RAISE EXCEPTION 'could not extend pgtask.resolve_task_result';
39
+ END IF;
40
+ EXECUTE rewritten;
41
+
42
+ definition := pg_get_functiondef(
43
+ 'pgtask.wait_for_signal(uuid, integer, uuid, text, integer, text, integer, bigint)'::regprocedure
44
+ );
45
+ rewritten := replace(
46
+ definition,
47
+ E' p_task_id, target_handler_version, p_step_name, p_occurrence, p_signal_name, p_signal_occurrence, timeout_at\n );\n',
48
+ E' p_task_id, target_handler_version, p_step_name, p_occurrence, p_signal_name, p_signal_occurrence, timeout_at\n )\n'
49
+ || E' ON CONFLICT (task_id, handler_version, step_name, occurrence) DO UPDATE\n'
50
+ || E' SET signal_name = EXCLUDED.signal_name,\n'
51
+ || E' signal_occurrence = EXCLUDED.signal_occurrence,\n'
52
+ || E' timeout_at = EXCLUDED.timeout_at,\n'
53
+ || E' created_at = EXCLUDED.created_at,\n'
54
+ || E' resolved_at = NULL,\n'
55
+ || E' outcome = NULL;\n'
56
+ );
57
+ IF rewritten = definition THEN
58
+ RAISE EXCEPTION 'could not re-arm stale waits in pgtask.wait_for_signal';
59
+ END IF;
60
+ EXECUTE rewritten;
61
+
62
+ definition := pg_get_functiondef(
63
+ 'pgtask.wait_for_result(uuid, integer, uuid, text, integer, uuid, bigint)'::regprocedure
64
+ );
65
+ rewritten := replace(
66
+ definition,
67
+ E' ELSE statement_timestamp() + (p_timeout_milliseconds * interval ''1 millisecond'')\n END\n );\n',
68
+ E' ELSE statement_timestamp() + (p_timeout_milliseconds * interval ''1 millisecond'')\n END\n )\n'
69
+ || E' ON CONFLICT (task_id, handler_version, step_name, occurrence) DO UPDATE\n'
70
+ || E' SET result_task_id = EXCLUDED.result_task_id,\n'
71
+ || E' timeout_at = EXCLUDED.timeout_at,\n'
72
+ || E' created_at = EXCLUDED.created_at,\n'
73
+ || E' resolved_at = NULL,\n'
74
+ || E' outcome = NULL;\n'
75
+ );
76
+ IF rewritten = definition THEN
77
+ RAISE EXCEPTION 'could not re-arm stale waits in pgtask.wait_for_result';
78
+ END IF;
79
+ EXECUTE rewritten;
80
+ END;
81
+ $$;
@@ -0,0 +1,139 @@
1
+ -- Retention read every terminal task in a queue and sorted the expired ones to delete a batch of
2
+ -- 100, and every deleted task cascaded into result_waits with a sequential scan, because the only
3
+ -- index on result_waits.result_task_id is partial. Worker rows were never deleted at all.
4
+ --
5
+ -- On a large tasks table, building tasks_terminal_retention_idx blocks writes to tasks while it
6
+ -- runs. To avoid that, create the same index with CREATE INDEX CONCURRENTLY before upgrading;
7
+ -- IF NOT EXISTS then skips it here.
8
+
9
+ CREATE INDEX IF NOT EXISTS tasks_terminal_retention_idx
10
+ ON pgtask.tasks (queue_name, completed_at, id)
11
+ WHERE state IN ('succeeded', 'failed', 'cancelled');
12
+
13
+ -- The ON DELETE CASCADE from tasks looks rows up by result_task_id alone, resolved or not.
14
+ CREATE INDEX IF NOT EXISTS result_waits_result_task_idx
15
+ ON pgtask.result_waits (result_task_id);
16
+
17
+ -- Delete the locked candidates by tuple id instead of joining back on the key columns. CREATE OR
18
+ -- REPLACE keeps each function's owner and grants; signatures and results are unchanged.
19
+
20
+ CREATE OR REPLACE FUNCTION pgtask.delete_expired_terminal(p_queue_name text, p_limit integer)
21
+ RETURNS bigint
22
+ LANGUAGE sql
23
+ SECURITY DEFINER
24
+ SET search_path = pg_catalog, pgtask
25
+ AS $$
26
+ WITH candidates AS (
27
+ SELECT tasks.ctid AS tuple_id
28
+ FROM pgtask.tasks
29
+ WHERE tasks.queue_name = p_queue_name
30
+ AND tasks.state IN ('succeeded', 'failed', 'cancelled')
31
+ -- A scalar cutoff, not a join filter, so it bounds the index scan: a short final batch
32
+ -- stops at the first unexpired row instead of reading every terminal task in the queue.
33
+ AND tasks.completed_at <= (
34
+ SELECT statement_timestamp() - (queues.terminal_retention_seconds * interval '1 second')
35
+ FROM pgtask.queues
36
+ WHERE queues.name = p_queue_name
37
+ )
38
+ AND NOT EXISTS (
39
+ SELECT 1 FROM pgtask.tasks AS children WHERE children.parent_task_id = tasks.id
40
+ )
41
+ ORDER BY tasks.completed_at, tasks.id
42
+ FOR UPDATE OF tasks SKIP LOCKED
43
+ LIMIT p_limit
44
+ ),
45
+ deleted AS (
46
+ DELETE FROM pgtask.tasks
47
+ WHERE tasks.ctid = ANY (ARRAY(SELECT candidates.tuple_id FROM candidates))
48
+ RETURNING tasks.id
49
+ )
50
+ SELECT count(*) FROM deleted;
51
+ $$;
52
+
53
+ CREATE OR REPLACE FUNCTION pgtask.delete_expired_idempotency_keys(p_queue_name text, p_limit integer)
54
+ RETURNS bigint
55
+ LANGUAGE sql
56
+ SECURITY DEFINER
57
+ SET search_path = pg_catalog, pgtask
58
+ AS $$
59
+ WITH candidates AS (
60
+ SELECT idempotency_keys.ctid AS tuple_id
61
+ FROM pgtask.idempotency_keys
62
+ WHERE idempotency_keys.queue_name = p_queue_name
63
+ AND idempotency_keys.expires_at <= statement_timestamp()
64
+ ORDER BY idempotency_keys.expires_at, idempotency_keys.idempotency_key
65
+ FOR UPDATE SKIP LOCKED
66
+ LIMIT p_limit
67
+ ),
68
+ deleted AS (
69
+ DELETE FROM pgtask.idempotency_keys
70
+ WHERE idempotency_keys.ctid = ANY (ARRAY(SELECT candidates.tuple_id FROM candidates))
71
+ RETURNING idempotency_keys.idempotency_key
72
+ )
73
+ SELECT count(*) FROM deleted;
74
+ $$;
75
+
76
+ -- A worker row is inserted per process start and only ever expired. Delete rows that expired more
77
+ -- than p_grace_milliseconds ago; worker_capabilities follows by cascade.
78
+ CREATE FUNCTION pgtask.delete_expired_workers(p_grace_milliseconds bigint, p_limit integer)
79
+ RETURNS bigint
80
+ LANGUAGE sql
81
+ SECURITY DEFINER
82
+ SET search_path = pg_catalog, pgtask
83
+ AS $$
84
+ WITH candidates AS (
85
+ SELECT workers.ctid AS tuple_id
86
+ FROM pgtask.workers
87
+ WHERE workers.expires_at <= statement_timestamp() - (p_grace_milliseconds * interval '1 millisecond')
88
+ ORDER BY workers.expires_at, workers.id
89
+ FOR UPDATE SKIP LOCKED
90
+ LIMIT p_limit
91
+ ),
92
+ deleted AS (
93
+ DELETE FROM pgtask.workers
94
+ WHERE workers.ctid = ANY (ARRAY(SELECT candidates.tuple_id FROM candidates))
95
+ RETURNING workers.id
96
+ )
97
+ SELECT count(*) FROM deleted;
98
+ $$;
99
+
100
+ REVOKE ALL ON FUNCTION pgtask.delete_expired_workers(bigint, integer) FROM PUBLIC;
101
+
102
+ -- Grant it to every role that may already run retention, and teach configure_grants to do the same.
103
+ DO $$
104
+ DECLARE
105
+ definition text;
106
+ rewritten text;
107
+ target regrole;
108
+ BEGIN
109
+ FOR target IN
110
+ SELECT privileges.grantee::regrole
111
+ FROM pg_proc
112
+ CROSS JOIN LATERAL aclexplode(pg_proc.proacl) AS privileges
113
+ WHERE pg_proc.oid = 'pgtask.delete_expired_terminal(text, integer)'::regprocedure
114
+ AND privileges.privilege_type = 'EXECUTE'
115
+ LOOP
116
+ EXECUTE format('GRANT EXECUTE ON FUNCTION pgtask.delete_expired_workers(bigint, integer) TO %s', target);
117
+ END LOOP;
118
+
119
+ definition := pg_get_functiondef('pgtask.configure_grants(regrole, regrole, regrole, regrole, regrole)'::regprocedure);
120
+ rewritten := replace(
121
+ definition,
122
+ E' EXECUTE format(''GRANT EXECUTE ON FUNCTION pgtask.delete_expired_terminal(text, integer) TO %s'', p_worker);\n',
123
+ E' EXECUTE format(''GRANT EXECUTE ON FUNCTION pgtask.delete_expired_terminal(text, integer) TO %s'', p_worker);\n'
124
+ || E' EXECUTE format(''GRANT EXECUTE ON FUNCTION pgtask.delete_expired_workers(bigint, integer) TO %s'', p_worker);\n'
125
+ );
126
+ rewritten := replace(
127
+ rewritten,
128
+ E' EXECUTE format(''GRANT EXECUTE ON FUNCTION pgtask.delete_expired_terminal(text, integer) TO %s'', p_administrator);\n',
129
+ E' EXECUTE format(''GRANT EXECUTE ON FUNCTION pgtask.delete_expired_terminal(text, integer) TO %s'', p_administrator);\n'
130
+ || E' EXECUTE format(''GRANT EXECUTE ON FUNCTION pgtask.delete_expired_workers(bigint, integer) TO %s'', p_administrator);\n'
131
+ );
132
+ IF rewritten NOT LIKE '%delete_expired_workers(bigint, integer) TO %%s'', p_worker)%'
133
+ OR rewritten NOT LIKE '%delete_expired_workers(bigint, integer) TO %%s'', p_administrator)%'
134
+ THEN
135
+ RAISE EXCEPTION 'could not extend pgtask.configure_grants';
136
+ END IF;
137
+ EXECUTE rewritten;
138
+ END;
139
+ $$;