@pgflow/core 0.1.18 → 0.1.20

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.
@@ -0,0 +1,579 @@
1
+ -- Add new schema named "pgflow"
2
+ CREATE SCHEMA "pgflow";
3
+ -- Add new schema named "pgmq"
4
+ CREATE SCHEMA "pgmq";
5
+ -- Create extension "pgmq"
6
+ CREATE EXTENSION "pgmq" WITH SCHEMA "pgmq" VERSION "1.4.4";
7
+ -- Create "read_with_poll" function
8
+ CREATE FUNCTION "pgflow"."read_with_poll" ("queue_name" text, "vt" integer, "qty" integer, "max_poll_seconds" integer DEFAULT 5, "poll_interval_ms" integer DEFAULT 100, "conditional" jsonb DEFAULT '{}') RETURNS SETOF pgmq.message_record LANGUAGE plpgsql AS $$
9
+ DECLARE
10
+ r pgmq.message_record;
11
+ stop_at TIMESTAMP;
12
+ sql TEXT;
13
+ qtable TEXT := pgmq.format_table_name(queue_name, 'q');
14
+ BEGIN
15
+ stop_at := clock_timestamp() + make_interval(secs => max_poll_seconds);
16
+ LOOP
17
+ IF (SELECT clock_timestamp() >= stop_at) THEN
18
+ RETURN;
19
+ END IF;
20
+
21
+ sql := FORMAT(
22
+ $QUERY$
23
+ WITH cte AS
24
+ (
25
+ SELECT msg_id
26
+ FROM pgmq.%I
27
+ WHERE vt <= clock_timestamp() AND CASE
28
+ WHEN %L != '{}'::jsonb THEN (message @> %2$L)::integer
29
+ ELSE 1
30
+ END = 1
31
+ ORDER BY msg_id ASC
32
+ LIMIT $1
33
+ FOR UPDATE SKIP LOCKED
34
+ )
35
+ UPDATE pgmq.%I m
36
+ SET
37
+ vt = clock_timestamp() + %L,
38
+ read_ct = read_ct + 1
39
+ FROM cte
40
+ WHERE m.msg_id = cte.msg_id
41
+ RETURNING m.msg_id, m.read_ct, m.enqueued_at, m.vt, m.message;
42
+ $QUERY$,
43
+ qtable, conditional, qtable, make_interval(secs => vt)
44
+ );
45
+
46
+ FOR r IN
47
+ EXECUTE sql USING qty
48
+ LOOP
49
+ RETURN NEXT r;
50
+ END LOOP;
51
+ IF FOUND THEN
52
+ RETURN;
53
+ ELSE
54
+ PERFORM pg_sleep(poll_interval_ms::numeric / 1000);
55
+ END IF;
56
+ END LOOP;
57
+ END;
58
+ $$;
59
+ -- Create composite type "step_task_record"
60
+ CREATE TYPE "pgflow"."step_task_record" AS ("flow_slug" text, "run_id" uuid, "step_slug" text, "input" jsonb, "msg_id" bigint);
61
+ -- Create "is_valid_slug" function
62
+ CREATE FUNCTION "pgflow"."is_valid_slug" ("slug" text) RETURNS boolean LANGUAGE plpgsql IMMUTABLE AS $$
63
+ begin
64
+ return
65
+ slug is not null
66
+ and slug <> ''
67
+ and length(slug) <= 128
68
+ and slug ~ '^[a-zA-Z_][a-zA-Z0-9_]*$'
69
+ and slug NOT IN ('run'); -- reserved words
70
+ end;
71
+ $$;
72
+ -- Create "flows" table
73
+ CREATE TABLE "pgflow"."flows" ("flow_slug" text NOT NULL, "opt_max_attempts" integer NOT NULL DEFAULT 3, "opt_base_delay" integer NOT NULL DEFAULT 1, "opt_timeout" integer NOT NULL DEFAULT 60, "created_at" timestamptz NOT NULL DEFAULT now(), PRIMARY KEY ("flow_slug"), CONSTRAINT "opt_base_delay_is_nonnegative" CHECK (opt_base_delay >= 0), CONSTRAINT "opt_max_attempts_is_nonnegative" CHECK (opt_max_attempts >= 0), CONSTRAINT "opt_timeout_is_positive" CHECK (opt_timeout > 0), CONSTRAINT "slug_is_valid" CHECK (pgflow.is_valid_slug(flow_slug)));
74
+ -- Create "steps" table
75
+ CREATE TABLE "pgflow"."steps" ("flow_slug" text NOT NULL, "step_slug" text NOT NULL, "step_type" text NOT NULL DEFAULT 'single', "step_index" integer NOT NULL DEFAULT 0, "deps_count" integer NOT NULL DEFAULT 0, "opt_max_attempts" integer NULL, "opt_base_delay" integer NULL, "opt_timeout" integer NULL, "created_at" timestamptz NOT NULL DEFAULT now(), PRIMARY KEY ("flow_slug", "step_slug"), CONSTRAINT "steps_flow_slug_step_index_key" UNIQUE ("flow_slug", "step_index"), CONSTRAINT "steps_flow_slug_fkey" FOREIGN KEY ("flow_slug") REFERENCES "pgflow"."flows" ("flow_slug") ON UPDATE NO ACTION ON DELETE NO ACTION, CONSTRAINT "opt_base_delay_is_nonnegative" CHECK ((opt_base_delay IS NULL) OR (opt_base_delay >= 0)), CONSTRAINT "opt_max_attempts_is_nonnegative" CHECK ((opt_max_attempts IS NULL) OR (opt_max_attempts >= 0)), CONSTRAINT "opt_timeout_is_positive" CHECK ((opt_timeout IS NULL) OR (opt_timeout > 0)), CONSTRAINT "steps_deps_count_check" CHECK (deps_count >= 0), CONSTRAINT "steps_step_slug_check" CHECK (pgflow.is_valid_slug(step_slug)), CONSTRAINT "steps_step_type_check" CHECK (step_type = 'single'::text));
76
+ -- Create "deps" table
77
+ CREATE TABLE "pgflow"."deps" ("flow_slug" text NOT NULL, "dep_slug" text NOT NULL, "step_slug" text NOT NULL, "created_at" timestamptz NOT NULL DEFAULT now(), PRIMARY KEY ("flow_slug", "dep_slug", "step_slug"), CONSTRAINT "deps_flow_slug_dep_slug_fkey" FOREIGN KEY ("flow_slug", "dep_slug") REFERENCES "pgflow"."steps" ("flow_slug", "step_slug") ON UPDATE NO ACTION ON DELETE NO ACTION, CONSTRAINT "deps_flow_slug_fkey" FOREIGN KEY ("flow_slug") REFERENCES "pgflow"."flows" ("flow_slug") ON UPDATE NO ACTION ON DELETE NO ACTION, CONSTRAINT "deps_flow_slug_step_slug_fkey" FOREIGN KEY ("flow_slug", "step_slug") REFERENCES "pgflow"."steps" ("flow_slug", "step_slug") ON UPDATE NO ACTION ON DELETE NO ACTION, CONSTRAINT "deps_check" CHECK (dep_slug <> step_slug));
78
+ -- Create index "idx_deps_by_flow_dep" to table: "deps"
79
+ CREATE INDEX "idx_deps_by_flow_dep" ON "pgflow"."deps" ("flow_slug", "dep_slug");
80
+ -- Create index "idx_deps_by_flow_step" to table: "deps"
81
+ CREATE INDEX "idx_deps_by_flow_step" ON "pgflow"."deps" ("flow_slug", "step_slug");
82
+ -- Create "runs" table
83
+ CREATE TABLE "pgflow"."runs" ("run_id" uuid NOT NULL DEFAULT gen_random_uuid(), "flow_slug" text NOT NULL, "status" text NOT NULL DEFAULT 'started', "input" jsonb NOT NULL, "output" jsonb NULL, "remaining_steps" integer NOT NULL DEFAULT 0, "started_at" timestamptz NOT NULL DEFAULT now(), "completed_at" timestamptz NULL, "failed_at" timestamptz NULL, PRIMARY KEY ("run_id"), CONSTRAINT "runs_flow_slug_fkey" FOREIGN KEY ("flow_slug") REFERENCES "pgflow"."flows" ("flow_slug") ON UPDATE NO ACTION ON DELETE NO ACTION, CONSTRAINT "completed_at_is_after_started_at" CHECK ((completed_at IS NULL) OR (completed_at >= started_at)), CONSTRAINT "completed_at_or_failed_at" CHECK (NOT ((completed_at IS NOT NULL) AND (failed_at IS NOT NULL))), CONSTRAINT "failed_at_is_after_started_at" CHECK ((failed_at IS NULL) OR (failed_at >= started_at)), CONSTRAINT "runs_remaining_steps_check" CHECK (remaining_steps >= 0), CONSTRAINT "status_is_valid" CHECK (status = ANY (ARRAY['started'::text, 'failed'::text, 'completed'::text])));
84
+ -- Create index "idx_runs_flow_slug" to table: "runs"
85
+ CREATE INDEX "idx_runs_flow_slug" ON "pgflow"."runs" ("flow_slug");
86
+ -- Create index "idx_runs_status" to table: "runs"
87
+ CREATE INDEX "idx_runs_status" ON "pgflow"."runs" ("status");
88
+ -- Create "step_states" table
89
+ CREATE TABLE "pgflow"."step_states" ("flow_slug" text NOT NULL, "run_id" uuid NOT NULL, "step_slug" text NOT NULL, "status" text NOT NULL DEFAULT 'created', "remaining_tasks" integer NOT NULL DEFAULT 1, "remaining_deps" integer NOT NULL DEFAULT 0, "created_at" timestamptz NOT NULL DEFAULT now(), "started_at" timestamptz NULL, "completed_at" timestamptz NULL, "failed_at" timestamptz NULL, PRIMARY KEY ("run_id", "step_slug"), CONSTRAINT "step_states_flow_slug_fkey" FOREIGN KEY ("flow_slug") REFERENCES "pgflow"."flows" ("flow_slug") ON UPDATE NO ACTION ON DELETE NO ACTION, CONSTRAINT "step_states_flow_slug_step_slug_fkey" FOREIGN KEY ("flow_slug", "step_slug") REFERENCES "pgflow"."steps" ("flow_slug", "step_slug") ON UPDATE NO ACTION ON DELETE NO ACTION, CONSTRAINT "step_states_run_id_fkey" FOREIGN KEY ("run_id") REFERENCES "pgflow"."runs" ("run_id") ON UPDATE NO ACTION ON DELETE NO ACTION, CONSTRAINT "completed_at_is_after_started_at" CHECK ((completed_at IS NULL) OR (completed_at >= started_at)), CONSTRAINT "completed_at_or_failed_at" CHECK (NOT ((completed_at IS NOT NULL) AND (failed_at IS NOT NULL))), CONSTRAINT "failed_at_is_after_started_at" CHECK ((failed_at IS NULL) OR (failed_at >= started_at)), CONSTRAINT "started_at_is_after_created_at" CHECK ((started_at IS NULL) OR (started_at >= created_at)), CONSTRAINT "status_and_remaining_tasks_match" CHECK ((status <> 'completed'::text) OR (remaining_tasks = 0)), CONSTRAINT "status_is_valid" CHECK (status = ANY (ARRAY['created'::text, 'started'::text, 'completed'::text, 'failed'::text])), CONSTRAINT "step_states_remaining_deps_check" CHECK (remaining_deps >= 0), CONSTRAINT "step_states_remaining_tasks_check" CHECK (remaining_tasks >= 0));
90
+ -- Create index "idx_step_states_failed" to table: "step_states"
91
+ CREATE INDEX "idx_step_states_failed" ON "pgflow"."step_states" ("run_id", "step_slug") WHERE (status = 'failed'::text);
92
+ -- Create index "idx_step_states_flow_slug" to table: "step_states"
93
+ CREATE INDEX "idx_step_states_flow_slug" ON "pgflow"."step_states" ("flow_slug");
94
+ -- Create index "idx_step_states_ready" to table: "step_states"
95
+ CREATE INDEX "idx_step_states_ready" ON "pgflow"."step_states" ("run_id", "status", "remaining_deps") WHERE ((status = 'created'::text) AND (remaining_deps = 0));
96
+ -- Create "step_tasks" table
97
+ CREATE TABLE "pgflow"."step_tasks" ("flow_slug" text NOT NULL, "run_id" uuid NOT NULL, "step_slug" text NOT NULL, "message_id" bigint NULL, "task_index" integer NOT NULL DEFAULT 0, "status" text NOT NULL DEFAULT 'queued', "attempts_count" integer NOT NULL DEFAULT 0, "error_message" text NULL, "output" jsonb NULL, "queued_at" timestamptz NOT NULL DEFAULT now(), "completed_at" timestamptz NULL, "failed_at" timestamptz NULL, PRIMARY KEY ("run_id", "step_slug", "task_index"), CONSTRAINT "step_tasks_flow_slug_fkey" FOREIGN KEY ("flow_slug") REFERENCES "pgflow"."flows" ("flow_slug") ON UPDATE NO ACTION ON DELETE NO ACTION, CONSTRAINT "step_tasks_run_id_fkey" FOREIGN KEY ("run_id") REFERENCES "pgflow"."runs" ("run_id") ON UPDATE NO ACTION ON DELETE NO ACTION, CONSTRAINT "step_tasks_run_id_step_slug_fkey" FOREIGN KEY ("run_id", "step_slug") REFERENCES "pgflow"."step_states" ("run_id", "step_slug") ON UPDATE NO ACTION ON DELETE NO ACTION, CONSTRAINT "attempts_count_nonnegative" CHECK (attempts_count >= 0), CONSTRAINT "completed_at_is_after_queued_at" CHECK ((completed_at IS NULL) OR (completed_at >= queued_at)), CONSTRAINT "completed_at_or_failed_at" CHECK (NOT ((completed_at IS NOT NULL) AND (failed_at IS NOT NULL))), CONSTRAINT "failed_at_is_after_queued_at" CHECK ((failed_at IS NULL) OR (failed_at >= queued_at)), CONSTRAINT "only_single_task_per_step" CHECK (task_index = 0), CONSTRAINT "output_valid_only_for_completed" CHECK ((output IS NULL) OR (status = 'completed'::text)), CONSTRAINT "valid_status" CHECK (status = ANY (ARRAY['queued'::text, 'completed'::text, 'failed'::text])));
98
+ -- Create index "idx_step_tasks_completed" to table: "step_tasks"
99
+ CREATE INDEX "idx_step_tasks_completed" ON "pgflow"."step_tasks" ("run_id", "step_slug") WHERE (status = 'completed'::text);
100
+ -- Create index "idx_step_tasks_failed" to table: "step_tasks"
101
+ CREATE INDEX "idx_step_tasks_failed" ON "pgflow"."step_tasks" ("run_id", "step_slug") WHERE (status = 'failed'::text);
102
+ -- Create index "idx_step_tasks_flow_run_step" to table: "step_tasks"
103
+ CREATE INDEX "idx_step_tasks_flow_run_step" ON "pgflow"."step_tasks" ("flow_slug", "run_id", "step_slug");
104
+ -- Create index "idx_step_tasks_message_id" to table: "step_tasks"
105
+ CREATE INDEX "idx_step_tasks_message_id" ON "pgflow"."step_tasks" ("message_id");
106
+ -- Create index "idx_step_tasks_queued" to table: "step_tasks"
107
+ CREATE INDEX "idx_step_tasks_queued" ON "pgflow"."step_tasks" ("run_id", "step_slug") WHERE (status = 'queued'::text);
108
+ -- Create "poll_for_tasks" function
109
+ CREATE FUNCTION "pgflow"."poll_for_tasks" ("queue_name" text, "vt" integer, "qty" integer, "max_poll_seconds" integer DEFAULT 5, "poll_interval_ms" integer DEFAULT 100) RETURNS SETOF "pgflow"."step_task_record" LANGUAGE sql SET "search_path" = '' AS $$
110
+ with read_messages as (
111
+ select *
112
+ from pgflow.read_with_poll(
113
+ queue_name,
114
+ vt,
115
+ qty,
116
+ max_poll_seconds,
117
+ poll_interval_ms
118
+ )
119
+ ),
120
+ tasks as (
121
+ select
122
+ task.flow_slug,
123
+ task.run_id,
124
+ task.step_slug,
125
+ task.task_index,
126
+ task.message_id
127
+ from pgflow.step_tasks as task
128
+ join read_messages as message on message.msg_id = task.message_id
129
+ where task.message_id = message.msg_id
130
+ and task.status = 'queued'
131
+ ),
132
+ increment_attempts as (
133
+ update pgflow.step_tasks
134
+ set attempts_count = attempts_count + 1
135
+ from tasks
136
+ where step_tasks.message_id = tasks.message_id
137
+ and status = 'queued'
138
+ ),
139
+ runs as (
140
+ select
141
+ r.run_id,
142
+ r.input
143
+ from pgflow.runs r
144
+ where r.run_id in (select run_id from tasks)
145
+ ),
146
+ deps as (
147
+ select
148
+ st.run_id,
149
+ st.step_slug,
150
+ dep.dep_slug,
151
+ dep_task.output as dep_output
152
+ from tasks st
153
+ join pgflow.deps dep on dep.flow_slug = st.flow_slug and dep.step_slug = st.step_slug
154
+ join pgflow.step_tasks dep_task on
155
+ dep_task.run_id = st.run_id and
156
+ dep_task.step_slug = dep.dep_slug and
157
+ dep_task.status = 'completed'
158
+ ),
159
+ deps_outputs as (
160
+ select
161
+ d.run_id,
162
+ d.step_slug,
163
+ jsonb_object_agg(d.dep_slug, d.dep_output) as deps_output
164
+ from deps d
165
+ group by d.run_id, d.step_slug
166
+ ),
167
+ timeouts as (
168
+ select
169
+ task.message_id,
170
+ coalesce(step.opt_timeout, flow.opt_timeout) + 2 as vt_delay
171
+ from tasks task
172
+ join pgflow.flows flow on flow.flow_slug = task.flow_slug
173
+ join pgflow.steps step on step.flow_slug = task.flow_slug and step.step_slug = task.step_slug
174
+ )
175
+
176
+ select
177
+ st.flow_slug,
178
+ st.run_id,
179
+ st.step_slug,
180
+ jsonb_build_object('run', r.input) ||
181
+ coalesce(dep_out.deps_output, '{}'::jsonb) as input,
182
+ st.message_id as msg_id
183
+ from tasks st
184
+ join runs r on st.run_id = r.run_id
185
+ left join deps_outputs dep_out on
186
+ dep_out.run_id = st.run_id and
187
+ dep_out.step_slug = st.step_slug
188
+ cross join lateral (
189
+ -- TODO: this is slow because it calls set_vt for each row, and set_vt
190
+ -- builds dynamic query from string every time it is called
191
+ -- implement set_vt_batch(msgs_ids bigint[], vt_delays int[])
192
+ select pgmq.set_vt(queue_name, st.message_id,
193
+ (select t.vt_delay from timeouts t where t.message_id = st.message_id)
194
+ )
195
+ ) set_vt;
196
+ $$;
197
+ -- Create "add_step" function
198
+ CREATE FUNCTION "pgflow"."add_step" ("flow_slug" text, "step_slug" text, "deps_slugs" text[], "max_attempts" integer DEFAULT NULL::integer, "base_delay" integer DEFAULT NULL::integer, "timeout" integer DEFAULT NULL::integer) RETURNS "pgflow"."steps" LANGUAGE sql SET "search_path" = '' AS $$
199
+ WITH
200
+ next_index AS (
201
+ SELECT COALESCE(MAX(step_index) + 1, 0) as idx
202
+ FROM pgflow.steps
203
+ WHERE flow_slug = add_step.flow_slug
204
+ ),
205
+ create_step AS (
206
+ INSERT INTO pgflow.steps (flow_slug, step_slug, step_index, deps_count, opt_max_attempts, opt_base_delay, opt_timeout)
207
+ SELECT add_step.flow_slug, add_step.step_slug, idx, COALESCE(array_length(deps_slugs, 1), 0), max_attempts, base_delay, timeout
208
+ FROM next_index
209
+ ON CONFLICT (flow_slug, step_slug)
210
+ DO UPDATE SET step_slug = pgflow.steps.step_slug
211
+ RETURNING *
212
+ ),
213
+ insert_deps AS (
214
+ INSERT INTO pgflow.deps (flow_slug, dep_slug, step_slug)
215
+ SELECT add_step.flow_slug, d.dep_slug, add_step.step_slug
216
+ FROM unnest(deps_slugs) AS d(dep_slug)
217
+ ON CONFLICT (flow_slug, dep_slug, step_slug) DO NOTHING
218
+ RETURNING 1
219
+ )
220
+ -- Return the created step
221
+ SELECT * FROM create_step;
222
+ $$;
223
+ -- Create "add_step" function
224
+ CREATE FUNCTION "pgflow"."add_step" ("flow_slug" text, "step_slug" text, "max_attempts" integer DEFAULT NULL::integer, "base_delay" integer DEFAULT NULL::integer, "timeout" integer DEFAULT NULL::integer) RETURNS "pgflow"."steps" LANGUAGE sql SET "search_path" = '' AS $$
225
+ -- Call the original function with an empty array
226
+ SELECT * FROM pgflow.add_step(flow_slug, step_slug, ARRAY[]::text[], max_attempts, base_delay, timeout);
227
+ $$;
228
+ -- Create "calculate_retry_delay" function
229
+ CREATE FUNCTION "pgflow"."calculate_retry_delay" ("base_delay" numeric, "attempts_count" integer) RETURNS integer LANGUAGE sql IMMUTABLE PARALLEL SAFE AS $$ select floor(base_delay * power(2, attempts_count))::int $$;
230
+ -- Create "maybe_complete_run" function
231
+ CREATE FUNCTION "pgflow"."maybe_complete_run" ("run_id" uuid) RETURNS void LANGUAGE sql SET "search_path" = '' AS $$
232
+ -- Update run status to completed and set output when there are no remaining steps
233
+ -- All done in a single declarative SQL statement
234
+ UPDATE pgflow.runs
235
+ SET
236
+ status = 'completed',
237
+ completed_at = now(),
238
+ output = (
239
+ -- Get outputs from final steps (steps that are not dependencies for other steps)
240
+ SELECT jsonb_object_agg(st.step_slug, st.output)
241
+ FROM pgflow.step_tasks st
242
+ JOIN pgflow.step_states ss ON ss.run_id = st.run_id AND ss.step_slug = st.step_slug
243
+ JOIN pgflow.runs r ON r.run_id = ss.run_id AND r.flow_slug = ss.flow_slug
244
+ WHERE st.run_id = maybe_complete_run.run_id
245
+ AND st.status = 'completed'
246
+ AND NOT EXISTS (
247
+ SELECT 1
248
+ FROM pgflow.deps d
249
+ WHERE d.flow_slug = ss.flow_slug
250
+ AND d.dep_slug = ss.step_slug
251
+ )
252
+ )
253
+ WHERE pgflow.runs.run_id = maybe_complete_run.run_id
254
+ AND pgflow.runs.remaining_steps = 0
255
+ AND pgflow.runs.status != 'completed';
256
+ $$;
257
+ -- Create "start_ready_steps" function
258
+ CREATE FUNCTION "pgflow"."start_ready_steps" ("run_id" uuid) RETURNS void LANGUAGE sql SET "search_path" = '' AS $$
259
+ WITH ready_steps AS (
260
+ SELECT *
261
+ FROM pgflow.step_states AS step_state
262
+ WHERE step_state.run_id = start_ready_steps.run_id
263
+ AND step_state.status = 'created'
264
+ AND step_state.remaining_deps = 0
265
+ ORDER BY step_state.step_slug
266
+ FOR UPDATE
267
+ ),
268
+ started_step_states AS (
269
+ UPDATE pgflow.step_states
270
+ SET status = 'started',
271
+ started_at = now()
272
+ FROM ready_steps
273
+ WHERE pgflow.step_states.run_id = start_ready_steps.run_id
274
+ AND pgflow.step_states.step_slug = ready_steps.step_slug
275
+ RETURNING pgflow.step_states.*
276
+ ),
277
+ sent_messages AS (
278
+ SELECT
279
+ started_step.flow_slug,
280
+ started_step.run_id,
281
+ started_step.step_slug,
282
+ pgmq.send(started_step.flow_slug, jsonb_build_object(
283
+ 'flow_slug', started_step.flow_slug,
284
+ 'run_id', started_step.run_id,
285
+ 'step_slug', started_step.step_slug,
286
+ 'task_index', 0
287
+ )) AS msg_id
288
+ FROM started_step_states AS started_step
289
+ )
290
+ INSERT INTO pgflow.step_tasks (flow_slug, run_id, step_slug, message_id)
291
+ SELECT
292
+ sent_messages.flow_slug,
293
+ sent_messages.run_id,
294
+ sent_messages.step_slug,
295
+ sent_messages.msg_id
296
+ FROM sent_messages;
297
+ $$;
298
+ -- Create "complete_task" function
299
+ CREATE FUNCTION "pgflow"."complete_task" ("run_id" uuid, "step_slug" text, "task_index" integer, "output" jsonb) RETURNS SETOF "pgflow"."step_tasks" LANGUAGE plpgsql SET "search_path" = '' AS $$
300
+ begin
301
+
302
+ WITH run_lock AS (
303
+ SELECT * FROM pgflow.runs
304
+ WHERE pgflow.runs.run_id = complete_task.run_id
305
+ FOR UPDATE
306
+ ),
307
+ step_lock AS (
308
+ SELECT * FROM pgflow.step_states
309
+ WHERE pgflow.step_states.run_id = complete_task.run_id
310
+ AND pgflow.step_states.step_slug = complete_task.step_slug
311
+ FOR UPDATE
312
+ ),
313
+ task AS (
314
+ UPDATE pgflow.step_tasks
315
+ SET
316
+ status = 'completed',
317
+ completed_at = now(),
318
+ output = complete_task.output
319
+ WHERE pgflow.step_tasks.run_id = complete_task.run_id
320
+ AND pgflow.step_tasks.step_slug = complete_task.step_slug
321
+ AND pgflow.step_tasks.task_index = complete_task.task_index
322
+ RETURNING *
323
+ ),
324
+ step_state AS (
325
+ UPDATE pgflow.step_states
326
+ SET
327
+ status = CASE
328
+ WHEN pgflow.step_states.remaining_tasks = 1 THEN 'completed' -- Will be 0 after decrement
329
+ ELSE 'started'
330
+ END,
331
+ completed_at = CASE
332
+ WHEN pgflow.step_states.remaining_tasks = 1 THEN now() -- Will be 0 after decrement
333
+ ELSE NULL
334
+ END,
335
+ remaining_tasks = pgflow.step_states.remaining_tasks - 1
336
+ FROM task
337
+ WHERE pgflow.step_states.run_id = complete_task.run_id
338
+ AND pgflow.step_states.step_slug = complete_task.step_slug
339
+ RETURNING pgflow.step_states.*
340
+ ),
341
+ -- Find all dependent steps if the current step was completed
342
+ dependent_steps AS (
343
+ SELECT d.step_slug AS dependent_step_slug
344
+ FROM pgflow.deps d
345
+ JOIN step_state s ON s.status = 'completed' AND d.flow_slug = s.flow_slug
346
+ WHERE d.dep_slug = complete_task.step_slug
347
+ ORDER BY d.step_slug -- Ensure consistent ordering
348
+ ),
349
+ -- Lock dependent steps before updating
350
+ dependent_steps_lock AS (
351
+ SELECT * FROM pgflow.step_states
352
+ WHERE pgflow.step_states.run_id = complete_task.run_id
353
+ AND pgflow.step_states.step_slug IN (SELECT dependent_step_slug FROM dependent_steps)
354
+ FOR UPDATE
355
+ ),
356
+ -- Update all dependent steps
357
+ dependent_steps_update AS (
358
+ UPDATE pgflow.step_states
359
+ SET remaining_deps = pgflow.step_states.remaining_deps - 1
360
+ FROM dependent_steps
361
+ WHERE pgflow.step_states.run_id = complete_task.run_id
362
+ AND pgflow.step_states.step_slug = dependent_steps.dependent_step_slug
363
+ )
364
+ -- Only decrement remaining_steps, don't update status
365
+ UPDATE pgflow.runs
366
+ SET remaining_steps = pgflow.runs.remaining_steps - 1
367
+ FROM step_state
368
+ WHERE pgflow.runs.run_id = complete_task.run_id
369
+ AND step_state.status = 'completed';
370
+
371
+ PERFORM pgmq.archive(
372
+ queue_name => (SELECT run.flow_slug FROM pgflow.runs AS run WHERE run.run_id = complete_task.run_id),
373
+ msg_id => (SELECT message_id FROM pgflow.step_tasks AS step_task
374
+ WHERE step_task.run_id = complete_task.run_id
375
+ AND step_task.step_slug = complete_task.step_slug
376
+ AND step_task.task_index = complete_task.task_index)
377
+ );
378
+
379
+ PERFORM pgflow.start_ready_steps(complete_task.run_id);
380
+
381
+ PERFORM pgflow.maybe_complete_run(complete_task.run_id);
382
+
383
+ RETURN QUERY SELECT *
384
+ FROM pgflow.step_tasks AS step_task
385
+ WHERE step_task.run_id = complete_task.run_id
386
+ AND step_task.step_slug = complete_task.step_slug
387
+ AND step_task.task_index = complete_task.task_index;
388
+
389
+ end;
390
+ $$;
391
+ -- Create "create_flow" function
392
+ CREATE FUNCTION "pgflow"."create_flow" ("flow_slug" text, "max_attempts" integer DEFAULT 3, "base_delay" integer DEFAULT 5, "timeout" integer DEFAULT 60) RETURNS "pgflow"."flows" LANGUAGE sql SET "search_path" = '' AS $$
393
+ WITH
394
+ flow_upsert AS (
395
+ INSERT INTO pgflow.flows (flow_slug, opt_max_attempts, opt_base_delay, opt_timeout)
396
+ VALUES (flow_slug, max_attempts, base_delay, timeout)
397
+ ON CONFLICT (flow_slug) DO UPDATE
398
+ SET flow_slug = pgflow.flows.flow_slug -- Dummy update
399
+ RETURNING *
400
+ ),
401
+ ensure_queue AS (
402
+ SELECT pgmq.create(flow_slug)
403
+ WHERE NOT EXISTS (
404
+ SELECT 1 FROM pgmq.list_queues() WHERE queue_name = flow_slug
405
+ )
406
+ )
407
+ SELECT f.*
408
+ FROM flow_upsert f
409
+ LEFT JOIN (SELECT 1 FROM ensure_queue) _dummy ON true; -- Left join ensures flow is returned
410
+ $$;
411
+ -- Create "fail_task" function
412
+ CREATE FUNCTION "pgflow"."fail_task" ("run_id" uuid, "step_slug" text, "task_index" integer, "error_message" text) RETURNS SETOF "pgflow"."step_tasks" LANGUAGE plpgsql SET "search_path" = '' AS $$
413
+ begin
414
+
415
+ WITH run_lock AS (
416
+ SELECT * FROM pgflow.runs
417
+ WHERE pgflow.runs.run_id = fail_task.run_id
418
+ FOR UPDATE
419
+ ),
420
+ step_lock AS (
421
+ SELECT * FROM pgflow.step_states
422
+ WHERE pgflow.step_states.run_id = fail_task.run_id
423
+ AND pgflow.step_states.step_slug = fail_task.step_slug
424
+ FOR UPDATE
425
+ ),
426
+ flow_info AS (
427
+ SELECT r.flow_slug
428
+ FROM pgflow.runs r
429
+ WHERE r.run_id = fail_task.run_id
430
+ ),
431
+ config AS (
432
+ SELECT
433
+ COALESCE(s.opt_max_attempts, f.opt_max_attempts) AS opt_max_attempts,
434
+ COALESCE(s.opt_base_delay, f.opt_base_delay) AS opt_base_delay
435
+ FROM pgflow.steps s
436
+ JOIN pgflow.flows f ON f.flow_slug = s.flow_slug
437
+ JOIN flow_info fi ON fi.flow_slug = s.flow_slug
438
+ WHERE s.flow_slug = fi.flow_slug AND s.step_slug = fail_task.step_slug
439
+ ),
440
+
441
+ fail_or_retry_task as (
442
+ UPDATE pgflow.step_tasks as task
443
+ SET
444
+ status = CASE
445
+ WHEN task.attempts_count < (SELECT opt_max_attempts FROM config) THEN 'queued'
446
+ ELSE 'failed'
447
+ END,
448
+ failed_at = CASE
449
+ WHEN task.attempts_count >= (SELECT opt_max_attempts FROM config) THEN now()
450
+ ELSE NULL
451
+ END,
452
+ error_message = fail_task.error_message
453
+ WHERE task.run_id = fail_task.run_id
454
+ AND task.step_slug = fail_task.step_slug
455
+ AND task.task_index = fail_task.task_index
456
+ AND task.status = 'queued'
457
+ RETURNING *
458
+ ),
459
+ maybe_fail_step AS (
460
+ UPDATE pgflow.step_states
461
+ SET
462
+ status = CASE
463
+ WHEN (select fail_or_retry_task.status from fail_or_retry_task) = 'failed' THEN 'failed'
464
+ ELSE pgflow.step_states.status
465
+ END,
466
+ failed_at = CASE
467
+ WHEN (select fail_or_retry_task.status from fail_or_retry_task) = 'failed' THEN now()
468
+ ELSE NULL
469
+ END
470
+ FROM fail_or_retry_task
471
+ WHERE pgflow.step_states.run_id = fail_task.run_id
472
+ AND pgflow.step_states.step_slug = fail_task.step_slug
473
+ RETURNING pgflow.step_states.*
474
+ )
475
+ UPDATE pgflow.runs
476
+ SET status = CASE
477
+ WHEN (select status from maybe_fail_step) = 'failed' THEN 'failed'
478
+ ELSE status
479
+ END,
480
+ failed_at = CASE
481
+ WHEN (select status from maybe_fail_step) = 'failed' THEN now()
482
+ ELSE NULL
483
+ END
484
+ WHERE pgflow.runs.run_id = fail_task.run_id;
485
+
486
+ -- For queued tasks: delay the message for retry with exponential backoff
487
+ PERFORM (
488
+ WITH retry_config AS (
489
+ SELECT
490
+ COALESCE(s.opt_base_delay, f.opt_base_delay) AS base_delay
491
+ FROM pgflow.steps s
492
+ JOIN pgflow.flows f ON f.flow_slug = s.flow_slug
493
+ JOIN pgflow.runs r ON r.flow_slug = f.flow_slug
494
+ WHERE r.run_id = fail_task.run_id
495
+ AND s.step_slug = fail_task.step_slug
496
+ ),
497
+ queued_tasks AS (
498
+ SELECT
499
+ r.flow_slug,
500
+ st.message_id,
501
+ pgflow.calculate_retry_delay((SELECT base_delay FROM retry_config), st.attempts_count) AS calculated_delay
502
+ FROM pgflow.step_tasks st
503
+ JOIN pgflow.runs r ON st.run_id = r.run_id
504
+ WHERE st.run_id = fail_task.run_id
505
+ AND st.step_slug = fail_task.step_slug
506
+ AND st.task_index = fail_task.task_index
507
+ AND st.status = 'queued'
508
+ )
509
+ SELECT pgmq.set_vt(qt.flow_slug, qt.message_id, qt.calculated_delay)
510
+ FROM queued_tasks qt
511
+ WHERE EXISTS (SELECT 1 FROM queued_tasks)
512
+ );
513
+
514
+ -- For failed tasks: archive the message
515
+ PERFORM (
516
+ WITH failed_tasks AS (
517
+ SELECT r.flow_slug, st.message_id
518
+ FROM pgflow.step_tasks st
519
+ JOIN pgflow.runs r ON st.run_id = r.run_id
520
+ WHERE st.run_id = fail_task.run_id
521
+ AND st.step_slug = fail_task.step_slug
522
+ AND st.task_index = fail_task.task_index
523
+ AND st.status = 'failed'
524
+ )
525
+ SELECT pgmq.archive(ft.flow_slug, ft.message_id)
526
+ FROM failed_tasks ft
527
+ WHERE EXISTS (SELECT 1 FROM failed_tasks)
528
+ );
529
+
530
+ return query select *
531
+ from pgflow.step_tasks st
532
+ where st.run_id = fail_task.run_id
533
+ and st.step_slug = fail_task.step_slug
534
+ and st.task_index = fail_task.task_index;
535
+
536
+ end;
537
+ $$;
538
+ -- Create "start_flow" function
539
+ CREATE FUNCTION "pgflow"."start_flow" ("flow_slug" text, "input" jsonb) RETURNS SETOF "pgflow"."runs" LANGUAGE plpgsql SET "search_path" = '' AS $$
540
+ declare
541
+ v_created_run pgflow.runs%ROWTYPE;
542
+ begin
543
+
544
+ WITH
545
+ flow_steps AS (
546
+ SELECT steps.flow_slug, steps.step_slug, steps.deps_count
547
+ FROM pgflow.steps
548
+ WHERE steps.flow_slug = start_flow.flow_slug
549
+ ),
550
+ created_run AS (
551
+ INSERT INTO pgflow.runs (flow_slug, input, remaining_steps)
552
+ VALUES (
553
+ start_flow.flow_slug,
554
+ start_flow.input,
555
+ (SELECT count(*) FROM flow_steps)
556
+ )
557
+ RETURNING *
558
+ ),
559
+ created_step_states AS (
560
+ INSERT INTO pgflow.step_states (flow_slug, run_id, step_slug, remaining_deps)
561
+ SELECT
562
+ fs.flow_slug,
563
+ (SELECT run_id FROM created_run),
564
+ fs.step_slug,
565
+ fs.deps_count
566
+ FROM flow_steps fs
567
+ )
568
+ SELECT * FROM created_run INTO v_created_run;
569
+
570
+ PERFORM pgflow.start_ready_steps(v_created_run.run_id);
571
+
572
+ RETURN QUERY SELECT * FROM pgflow.runs where run_id = v_created_run.run_id;
573
+
574
+ end;
575
+ $$;
576
+ -- Create "workers" table
577
+ CREATE TABLE "pgflow"."workers" ("worker_id" uuid NOT NULL, "queue_name" text NOT NULL, "function_name" text NOT NULL, "started_at" timestamptz NOT NULL DEFAULT now(), "stopped_at" timestamptz NULL, "last_heartbeat_at" timestamptz NOT NULL DEFAULT now(), PRIMARY KEY ("worker_id"));
578
+ -- Create index "idx_workers_queue_name" to table: "workers"
579
+ CREATE INDEX "idx_workers_queue_name" ON "pgflow"."workers" ("queue_name");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pgflow/core",
3
- "version": "0.1.18",
3
+ "version": "0.1.20",
4
4
  "license": "AGPL-3.0",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -20,11 +20,11 @@
20
20
  },
21
21
  "devDependencies": {
22
22
  "@types/node": "^22.14.1",
23
- "supabase": "^2.6.8"
23
+ "supabase": "^2.21.1"
24
24
  },
25
25
  "dependencies": {
26
26
  "postgres": "^3.4.5",
27
- "@pgflow/dsl": "0.1.18"
27
+ "@pgflow/dsl": "0.1.20"
28
28
  },
29
29
  "publishConfig": {
30
30
  "access": "public"