@pgflow/core 0.0.0-array-map-steps-cd94242a-20251008042921 → 0.0.0-condition-4354fcb6-20260108134756

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,1515 @@
1
+ -- Modify "step_states" table
2
+ ALTER TABLE "pgflow"."step_states" DROP CONSTRAINT "completed_at_or_failed_at", DROP CONSTRAINT "remaining_tasks_state_consistency", ADD CONSTRAINT "remaining_tasks_state_consistency" CHECK ((remaining_tasks IS NULL) OR (status <> ALL (ARRAY['created'::text, 'skipped'::text]))), DROP CONSTRAINT "status_is_valid", ADD CONSTRAINT "status_is_valid" CHECK (status = ANY (ARRAY['created'::text, 'started'::text, 'completed'::text, 'failed'::text, 'skipped'::text])), ADD CONSTRAINT "completed_at_or_failed_at_or_skipped_at" CHECK (((
3
+ CASE
4
+ WHEN (completed_at IS NOT NULL) THEN 1
5
+ ELSE 0
6
+ END +
7
+ CASE
8
+ WHEN (failed_at IS NOT NULL) THEN 1
9
+ ELSE 0
10
+ END) +
11
+ CASE
12
+ WHEN (skipped_at IS NOT NULL) THEN 1
13
+ ELSE 0
14
+ END) <= 1), ADD CONSTRAINT "skip_reason_matches_status" CHECK (((status = 'skipped'::text) AND (skip_reason IS NOT NULL)) OR ((status <> 'skipped'::text) AND (skip_reason IS NULL))), ADD CONSTRAINT "skipped_at_is_after_created_at" CHECK ((skipped_at IS NULL) OR (skipped_at >= created_at)), ADD COLUMN "skip_reason" text NULL, ADD COLUMN "skipped_at" timestamptz NULL;
15
+ -- Create index "idx_step_states_skipped" to table: "step_states"
16
+ CREATE INDEX "idx_step_states_skipped" ON "pgflow"."step_states" ("run_id", "step_slug") WHERE (status = 'skipped'::text);
17
+ -- Modify "steps" table
18
+ ALTER TABLE "pgflow"."steps" ADD CONSTRAINT "when_failed_is_valid" CHECK (when_failed = ANY (ARRAY['fail'::text, 'skip'::text, 'skip-cascade'::text])), ADD CONSTRAINT "when_unmet_is_valid" CHECK (when_unmet = ANY (ARRAY['fail'::text, 'skip'::text, 'skip-cascade'::text])), ADD COLUMN "condition_pattern" jsonb NULL, ADD COLUMN "condition_not_pattern" jsonb NULL, ADD COLUMN "when_unmet" text NOT NULL DEFAULT 'skip', ADD COLUMN "when_failed" text NOT NULL DEFAULT 'fail';
19
+ -- Create "_cascade_force_skip_steps" function
20
+ CREATE FUNCTION "pgflow"."_cascade_force_skip_steps" ("run_id" uuid, "step_slug" text, "skip_reason" text) RETURNS integer LANGUAGE plpgsql AS $$
21
+ DECLARE
22
+ v_flow_slug text;
23
+ v_total_skipped int := 0;
24
+ BEGIN
25
+ -- Get flow_slug for this run
26
+ SELECT r.flow_slug INTO v_flow_slug
27
+ FROM pgflow.runs r
28
+ WHERE r.run_id = _cascade_force_skip_steps.run_id;
29
+
30
+ IF v_flow_slug IS NULL THEN
31
+ RAISE EXCEPTION 'Run not found: %', _cascade_force_skip_steps.run_id;
32
+ END IF;
33
+
34
+ -- ==========================================
35
+ -- SKIP STEPS IN TOPOLOGICAL ORDER
36
+ -- ==========================================
37
+ -- Use recursive CTE to find all downstream dependents,
38
+ -- then skip them in topological order (by step_index)
39
+ WITH RECURSIVE
40
+ -- ---------- Find all downstream steps ----------
41
+ downstream_steps AS (
42
+ -- Base case: the trigger step
43
+ SELECT
44
+ s.flow_slug,
45
+ s.step_slug,
46
+ s.step_index,
47
+ _cascade_force_skip_steps.skip_reason AS reason -- Original reason for trigger step
48
+ FROM pgflow.steps s
49
+ WHERE s.flow_slug = v_flow_slug
50
+ AND s.step_slug = _cascade_force_skip_steps.step_slug
51
+
52
+ UNION ALL
53
+
54
+ -- Recursive case: steps that depend on already-found steps
55
+ SELECT
56
+ s.flow_slug,
57
+ s.step_slug,
58
+ s.step_index,
59
+ 'dependency_skipped'::text AS reason -- Downstream steps get this reason
60
+ FROM pgflow.steps s
61
+ JOIN pgflow.deps d ON d.flow_slug = s.flow_slug AND d.step_slug = s.step_slug
62
+ JOIN downstream_steps ds ON ds.flow_slug = d.flow_slug AND ds.step_slug = d.dep_slug
63
+ ),
64
+ -- ---------- Deduplicate and order by step_index ----------
65
+ steps_to_skip AS (
66
+ SELECT DISTINCT ON (ds.step_slug)
67
+ ds.flow_slug,
68
+ ds.step_slug,
69
+ ds.step_index,
70
+ ds.reason
71
+ FROM downstream_steps ds
72
+ ORDER BY ds.step_slug, ds.step_index -- Keep first occurrence (trigger step has original reason)
73
+ ),
74
+ -- ---------- Skip the steps ----------
75
+ skipped AS (
76
+ UPDATE pgflow.step_states ss
77
+ SET status = 'skipped',
78
+ skip_reason = sts.reason,
79
+ skipped_at = now(),
80
+ remaining_tasks = NULL -- Clear remaining_tasks for skipped steps
81
+ FROM steps_to_skip sts
82
+ WHERE ss.run_id = _cascade_force_skip_steps.run_id
83
+ AND ss.step_slug = sts.step_slug
84
+ AND ss.status IN ('created', 'started') -- Only skip non-terminal steps
85
+ RETURNING
86
+ ss.*,
87
+ -- Broadcast step:skipped event
88
+ realtime.send(
89
+ jsonb_build_object(
90
+ 'event_type', 'step:skipped',
91
+ 'run_id', ss.run_id,
92
+ 'flow_slug', ss.flow_slug,
93
+ 'step_slug', ss.step_slug,
94
+ 'status', 'skipped',
95
+ 'skip_reason', ss.skip_reason,
96
+ 'skipped_at', ss.skipped_at
97
+ ),
98
+ concat('step:', ss.step_slug, ':skipped'),
99
+ concat('pgflow:run:', ss.run_id),
100
+ false
101
+ ) as _broadcast_result
102
+ ),
103
+ -- ---------- Update run counters ----------
104
+ run_updates AS (
105
+ UPDATE pgflow.runs r
106
+ SET remaining_steps = r.remaining_steps - skipped_count.count
107
+ FROM (SELECT COUNT(*) AS count FROM skipped) skipped_count
108
+ WHERE r.run_id = _cascade_force_skip_steps.run_id
109
+ AND skipped_count.count > 0
110
+ )
111
+ SELECT COUNT(*) INTO v_total_skipped FROM skipped;
112
+
113
+ RETURN v_total_skipped;
114
+ END;
115
+ $$;
116
+ -- Create "cascade_resolve_conditions" function
117
+ CREATE FUNCTION "pgflow"."cascade_resolve_conditions" ("run_id" uuid) RETURNS boolean LANGUAGE plpgsql SET "search_path" = '' AS $$
118
+ DECLARE
119
+ v_run_input jsonb;
120
+ v_run_status text;
121
+ v_first_fail record;
122
+ v_iteration_count int := 0;
123
+ v_max_iterations int := 50;
124
+ v_processed_count int;
125
+ BEGIN
126
+ -- ==========================================
127
+ -- GUARD: Early return if run is already terminal
128
+ -- ==========================================
129
+ SELECT r.status, r.input INTO v_run_status, v_run_input
130
+ FROM pgflow.runs r
131
+ WHERE r.run_id = cascade_resolve_conditions.run_id;
132
+
133
+ IF v_run_status IN ('failed', 'completed') THEN
134
+ RETURN v_run_status != 'failed';
135
+ END IF;
136
+
137
+ -- ==========================================
138
+ -- ITERATE UNTIL CONVERGENCE
139
+ -- ==========================================
140
+ -- After skipping steps, dependents may become ready and need evaluation.
141
+ -- Loop until no more steps are processed.
142
+ LOOP
143
+ v_iteration_count := v_iteration_count + 1;
144
+ IF v_iteration_count > v_max_iterations THEN
145
+ RAISE EXCEPTION 'cascade_resolve_conditions exceeded safety limit of % iterations', v_max_iterations;
146
+ END IF;
147
+
148
+ v_processed_count := 0;
149
+
150
+ -- ==========================================
151
+ -- PHASE 1a: CHECK FOR FAIL CONDITIONS
152
+ -- ==========================================
153
+ -- Find first step (by topological order) with unmet condition and 'fail' mode.
154
+ -- Condition is unmet when:
155
+ -- (condition_pattern is set AND input does NOT contain it) OR
156
+ -- (condition_not_pattern is set AND input DOES contain it)
157
+ WITH steps_with_conditions AS (
158
+ SELECT
159
+ step_state.flow_slug,
160
+ step_state.step_slug,
161
+ step.condition_pattern,
162
+ step.condition_not_pattern,
163
+ step.when_unmet,
164
+ step.deps_count,
165
+ step.step_index
166
+ FROM pgflow.step_states AS step_state
167
+ JOIN pgflow.steps AS step
168
+ ON step.flow_slug = step_state.flow_slug
169
+ AND step.step_slug = step_state.step_slug
170
+ WHERE step_state.run_id = cascade_resolve_conditions.run_id
171
+ AND step_state.status = 'created'
172
+ AND step_state.remaining_deps = 0
173
+ AND (step.condition_pattern IS NOT NULL OR step.condition_not_pattern IS NOT NULL)
174
+ ),
175
+ step_deps_output AS (
176
+ SELECT
177
+ swc.step_slug,
178
+ jsonb_object_agg(dep_state.step_slug, dep_state.output) AS deps_output
179
+ FROM steps_with_conditions swc
180
+ JOIN pgflow.deps dep ON dep.flow_slug = swc.flow_slug AND dep.step_slug = swc.step_slug
181
+ JOIN pgflow.step_states dep_state
182
+ ON dep_state.run_id = cascade_resolve_conditions.run_id
183
+ AND dep_state.step_slug = dep.dep_slug
184
+ AND dep_state.status = 'completed' -- Only completed deps (not skipped)
185
+ WHERE swc.deps_count > 0
186
+ GROUP BY swc.step_slug
187
+ ),
188
+ condition_evaluations AS (
189
+ SELECT
190
+ swc.*,
191
+ -- condition_met = (if IS NULL OR input @> if) AND (ifNot IS NULL OR NOT(input @> ifNot))
192
+ (swc.condition_pattern IS NULL OR
193
+ CASE WHEN swc.deps_count = 0 THEN v_run_input ELSE COALESCE(sdo.deps_output, '{}'::jsonb) END @> swc.condition_pattern)
194
+ AND
195
+ (swc.condition_not_pattern IS NULL OR
196
+ NOT (CASE WHEN swc.deps_count = 0 THEN v_run_input ELSE COALESCE(sdo.deps_output, '{}'::jsonb) END @> swc.condition_not_pattern))
197
+ AS condition_met
198
+ FROM steps_with_conditions swc
199
+ LEFT JOIN step_deps_output sdo ON sdo.step_slug = swc.step_slug
200
+ )
201
+ SELECT flow_slug, step_slug, condition_pattern, condition_not_pattern
202
+ INTO v_first_fail
203
+ FROM condition_evaluations
204
+ WHERE NOT condition_met AND when_unmet = 'fail'
205
+ ORDER BY step_index
206
+ LIMIT 1;
207
+
208
+ -- Handle fail mode: fail step and run, return false
209
+ -- Note: Cannot use "v_first_fail IS NOT NULL" because records with NULL fields
210
+ -- evaluate to NULL in IS NOT NULL checks. Use FOUND instead.
211
+ IF FOUND THEN
212
+ UPDATE pgflow.step_states
213
+ SET status = 'failed',
214
+ failed_at = now(),
215
+ error_message = 'Condition not met'
216
+ WHERE pgflow.step_states.run_id = cascade_resolve_conditions.run_id
217
+ AND pgflow.step_states.step_slug = v_first_fail.step_slug;
218
+
219
+ UPDATE pgflow.runs
220
+ SET status = 'failed',
221
+ failed_at = now()
222
+ WHERE pgflow.runs.run_id = cascade_resolve_conditions.run_id;
223
+
224
+ RETURN false;
225
+ END IF;
226
+
227
+ -- ==========================================
228
+ -- PHASE 1b: HANDLE SKIP CONDITIONS (with propagation)
229
+ -- ==========================================
230
+ -- Skip steps with unmet conditions and whenUnmet='skip'.
231
+ -- Also decrement remaining_deps on dependents and set initial_tasks=0 for map dependents.
232
+ WITH steps_with_conditions AS (
233
+ SELECT
234
+ step_state.flow_slug,
235
+ step_state.step_slug,
236
+ step.condition_pattern,
237
+ step.condition_not_pattern,
238
+ step.when_unmet,
239
+ step.deps_count,
240
+ step.step_index
241
+ FROM pgflow.step_states AS step_state
242
+ JOIN pgflow.steps AS step
243
+ ON step.flow_slug = step_state.flow_slug
244
+ AND step.step_slug = step_state.step_slug
245
+ WHERE step_state.run_id = cascade_resolve_conditions.run_id
246
+ AND step_state.status = 'created'
247
+ AND step_state.remaining_deps = 0
248
+ AND (step.condition_pattern IS NOT NULL OR step.condition_not_pattern IS NOT NULL)
249
+ ),
250
+ step_deps_output AS (
251
+ SELECT
252
+ swc.step_slug,
253
+ jsonb_object_agg(dep_state.step_slug, dep_state.output) AS deps_output
254
+ FROM steps_with_conditions swc
255
+ JOIN pgflow.deps dep ON dep.flow_slug = swc.flow_slug AND dep.step_slug = swc.step_slug
256
+ JOIN pgflow.step_states dep_state
257
+ ON dep_state.run_id = cascade_resolve_conditions.run_id
258
+ AND dep_state.step_slug = dep.dep_slug
259
+ AND dep_state.status = 'completed' -- Only completed deps (not skipped)
260
+ WHERE swc.deps_count > 0
261
+ GROUP BY swc.step_slug
262
+ ),
263
+ condition_evaluations AS (
264
+ SELECT
265
+ swc.*,
266
+ -- condition_met = (if IS NULL OR input @> if) AND (ifNot IS NULL OR NOT(input @> ifNot))
267
+ (swc.condition_pattern IS NULL OR
268
+ CASE WHEN swc.deps_count = 0 THEN v_run_input ELSE COALESCE(sdo.deps_output, '{}'::jsonb) END @> swc.condition_pattern)
269
+ AND
270
+ (swc.condition_not_pattern IS NULL OR
271
+ NOT (CASE WHEN swc.deps_count = 0 THEN v_run_input ELSE COALESCE(sdo.deps_output, '{}'::jsonb) END @> swc.condition_not_pattern))
272
+ AS condition_met
273
+ FROM steps_with_conditions swc
274
+ LEFT JOIN step_deps_output sdo ON sdo.step_slug = swc.step_slug
275
+ ),
276
+ unmet_skip_steps AS (
277
+ SELECT * FROM condition_evaluations
278
+ WHERE NOT condition_met AND when_unmet = 'skip'
279
+ ),
280
+ skipped_steps AS (
281
+ UPDATE pgflow.step_states ss
282
+ SET status = 'skipped',
283
+ skip_reason = 'condition_unmet',
284
+ skipped_at = now()
285
+ FROM unmet_skip_steps uss
286
+ WHERE ss.run_id = cascade_resolve_conditions.run_id
287
+ AND ss.step_slug = uss.step_slug
288
+ RETURNING
289
+ ss.*,
290
+ realtime.send(
291
+ jsonb_build_object(
292
+ 'event_type', 'step:skipped',
293
+ 'run_id', ss.run_id,
294
+ 'flow_slug', ss.flow_slug,
295
+ 'step_slug', ss.step_slug,
296
+ 'status', 'skipped',
297
+ 'skip_reason', 'condition_unmet',
298
+ 'skipped_at', ss.skipped_at
299
+ ),
300
+ concat('step:', ss.step_slug, ':skipped'),
301
+ concat('pgflow:run:', ss.run_id),
302
+ false
303
+ ) AS _broadcast_result
304
+ ),
305
+ -- NEW: Update dependent steps (decrement remaining_deps, set initial_tasks=0 for maps)
306
+ dependent_updates AS (
307
+ UPDATE pgflow.step_states child_state
308
+ SET remaining_deps = child_state.remaining_deps - 1,
309
+ -- If child is a map step and this skipped step is its only dependency,
310
+ -- set initial_tasks = 0 (skipped dep = empty array)
311
+ initial_tasks = CASE
312
+ WHEN child_step.step_type = 'map' AND child_step.deps_count = 1 THEN 0
313
+ ELSE child_state.initial_tasks
314
+ END
315
+ FROM skipped_steps parent
316
+ JOIN pgflow.deps dep ON dep.flow_slug = parent.flow_slug AND dep.dep_slug = parent.step_slug
317
+ JOIN pgflow.steps child_step ON child_step.flow_slug = dep.flow_slug AND child_step.step_slug = dep.step_slug
318
+ WHERE child_state.run_id = cascade_resolve_conditions.run_id
319
+ AND child_state.step_slug = dep.step_slug
320
+ ),
321
+ run_update AS (
322
+ UPDATE pgflow.runs r
323
+ SET remaining_steps = r.remaining_steps - (SELECT COUNT(*) FROM skipped_steps)
324
+ WHERE r.run_id = cascade_resolve_conditions.run_id
325
+ AND (SELECT COUNT(*) FROM skipped_steps) > 0
326
+ )
327
+ SELECT COUNT(*)::int INTO v_processed_count FROM skipped_steps;
328
+
329
+ -- ==========================================
330
+ -- PHASE 1c: HANDLE SKIP-CASCADE CONDITIONS
331
+ -- ==========================================
332
+ -- Call _cascade_force_skip_steps for each step with unmet condition and whenUnmet='skip-cascade'.
333
+ -- Process in topological order; _cascade_force_skip_steps is idempotent.
334
+ PERFORM pgflow._cascade_force_skip_steps(cascade_resolve_conditions.run_id, ready_step.step_slug, 'condition_unmet')
335
+ FROM pgflow.step_states AS ready_step
336
+ JOIN pgflow.steps AS step
337
+ ON step.flow_slug = ready_step.flow_slug
338
+ AND step.step_slug = ready_step.step_slug
339
+ LEFT JOIN LATERAL (
340
+ SELECT jsonb_object_agg(dep_state.step_slug, dep_state.output) AS deps_output
341
+ FROM pgflow.deps dep
342
+ JOIN pgflow.step_states dep_state
343
+ ON dep_state.run_id = cascade_resolve_conditions.run_id
344
+ AND dep_state.step_slug = dep.dep_slug
345
+ AND dep_state.status = 'completed' -- Only completed deps (not skipped)
346
+ WHERE dep.flow_slug = ready_step.flow_slug
347
+ AND dep.step_slug = ready_step.step_slug
348
+ ) AS agg_deps ON step.deps_count > 0
349
+ WHERE ready_step.run_id = cascade_resolve_conditions.run_id
350
+ AND ready_step.status = 'created'
351
+ AND ready_step.remaining_deps = 0
352
+ AND (step.condition_pattern IS NOT NULL OR step.condition_not_pattern IS NOT NULL)
353
+ AND step.when_unmet = 'skip-cascade'
354
+ -- Condition is NOT met when: (if fails) OR (ifNot fails)
355
+ AND NOT (
356
+ (step.condition_pattern IS NULL OR
357
+ CASE WHEN step.deps_count = 0 THEN v_run_input ELSE COALESCE(agg_deps.deps_output, '{}'::jsonb) END @> step.condition_pattern)
358
+ AND
359
+ (step.condition_not_pattern IS NULL OR
360
+ NOT (CASE WHEN step.deps_count = 0 THEN v_run_input ELSE COALESCE(agg_deps.deps_output, '{}'::jsonb) END @> step.condition_not_pattern))
361
+ )
362
+ ORDER BY step.step_index;
363
+
364
+ -- Check if run was failed during cascade (e.g., if _cascade_force_skip_steps triggers fail)
365
+ SELECT r.status INTO v_run_status
366
+ FROM pgflow.runs r
367
+ WHERE r.run_id = cascade_resolve_conditions.run_id;
368
+
369
+ IF v_run_status IN ('failed', 'completed') THEN
370
+ RETURN v_run_status != 'failed';
371
+ END IF;
372
+
373
+ -- Exit loop if no steps were processed in this iteration
374
+ EXIT WHEN v_processed_count = 0;
375
+ END LOOP;
376
+
377
+ RETURN true;
378
+ END;
379
+ $$;
380
+ -- Modify "start_ready_steps" function
381
+ CREATE OR REPLACE FUNCTION "pgflow"."start_ready_steps" ("run_id" uuid) RETURNS void LANGUAGE plpgsql SET "search_path" = '' AS $$
382
+ BEGIN
383
+ -- ==========================================
384
+ -- GUARD: No mutations on terminal runs
385
+ -- ==========================================
386
+ IF EXISTS (
387
+ SELECT 1 FROM pgflow.runs
388
+ WHERE pgflow.runs.run_id = start_ready_steps.run_id
389
+ AND pgflow.runs.status IN ('failed', 'completed')
390
+ ) THEN
391
+ RETURN;
392
+ END IF;
393
+
394
+ -- ==========================================
395
+ -- PHASE 1: START READY STEPS
396
+ -- ==========================================
397
+ -- NOTE: Condition evaluation and empty map handling are done by
398
+ -- cascade_resolve_conditions() and cascade_complete_taskless_steps()
399
+ -- which are called before this function.
400
+ WITH
401
+ -- ---------- Find ready steps ----------
402
+ -- Steps with no remaining deps and known task count
403
+ ready_steps AS (
404
+ SELECT *
405
+ FROM pgflow.step_states AS step_state
406
+ WHERE step_state.run_id = start_ready_steps.run_id
407
+ AND step_state.status = 'created'
408
+ AND step_state.remaining_deps = 0
409
+ AND step_state.initial_tasks IS NOT NULL -- Cannot start with unknown count
410
+ AND step_state.initial_tasks > 0 -- Don't start taskless steps (handled by cascade_complete_taskless_steps)
411
+ ORDER BY step_state.step_slug
412
+ FOR UPDATE
413
+ ),
414
+ -- ---------- Mark steps as started ----------
415
+ started_step_states AS (
416
+ UPDATE pgflow.step_states
417
+ SET status = 'started',
418
+ started_at = now(),
419
+ remaining_tasks = ready_steps.initial_tasks -- Copy initial_tasks to remaining_tasks when starting
420
+ FROM ready_steps
421
+ WHERE pgflow.step_states.run_id = start_ready_steps.run_id
422
+ AND pgflow.step_states.step_slug = ready_steps.step_slug
423
+ RETURNING pgflow.step_states.*,
424
+ -- Broadcast step:started event atomically with the UPDATE
425
+ -- Using RETURNING ensures this executes during row processing
426
+ -- and cannot be optimized away by the query planner
427
+ realtime.send(
428
+ jsonb_build_object(
429
+ 'event_type', 'step:started',
430
+ 'run_id', pgflow.step_states.run_id,
431
+ 'step_slug', pgflow.step_states.step_slug,
432
+ 'status', 'started',
433
+ 'started_at', pgflow.step_states.started_at,
434
+ 'remaining_tasks', pgflow.step_states.remaining_tasks,
435
+ 'remaining_deps', pgflow.step_states.remaining_deps
436
+ ),
437
+ concat('step:', pgflow.step_states.step_slug, ':started'),
438
+ concat('pgflow:run:', pgflow.step_states.run_id),
439
+ false
440
+ ) as _broadcast_result -- Prefix with _ to indicate internal use only
441
+ ),
442
+
443
+ -- ==========================================
444
+ -- PHASE 2: TASK GENERATION AND QUEUE MESSAGES
445
+ -- ==========================================
446
+ -- ---------- Generate tasks and batch messages ----------
447
+ -- Single steps: 1 task (index 0)
448
+ -- Map steps: N tasks (indices 0..N-1)
449
+ message_batches AS (
450
+ SELECT
451
+ started_step.flow_slug,
452
+ started_step.run_id,
453
+ started_step.step_slug,
454
+ COALESCE(step.opt_start_delay, 0) as delay,
455
+ array_agg(
456
+ jsonb_build_object(
457
+ 'flow_slug', started_step.flow_slug,
458
+ 'run_id', started_step.run_id,
459
+ 'step_slug', started_step.step_slug,
460
+ 'task_index', task_idx.task_index
461
+ ) ORDER BY task_idx.task_index
462
+ ) AS messages,
463
+ array_agg(task_idx.task_index ORDER BY task_idx.task_index) AS task_indices
464
+ FROM started_step_states AS started_step
465
+ JOIN pgflow.steps AS step
466
+ ON step.flow_slug = started_step.flow_slug
467
+ AND step.step_slug = started_step.step_slug
468
+ -- Generate task indices from 0 to initial_tasks-1
469
+ CROSS JOIN LATERAL generate_series(0, started_step.initial_tasks - 1) AS task_idx(task_index)
470
+ GROUP BY started_step.flow_slug, started_step.run_id, started_step.step_slug, step.opt_start_delay
471
+ ),
472
+ -- ---------- Send messages to queue ----------
473
+ -- Uses batch sending for performance with large arrays
474
+ sent_messages AS (
475
+ SELECT
476
+ mb.flow_slug,
477
+ mb.run_id,
478
+ mb.step_slug,
479
+ task_indices.task_index,
480
+ msg_ids.msg_id
481
+ FROM message_batches mb
482
+ CROSS JOIN LATERAL unnest(mb.task_indices) WITH ORDINALITY AS task_indices(task_index, idx_ord)
483
+ CROSS JOIN LATERAL pgmq.send_batch(mb.flow_slug, mb.messages, mb.delay) WITH ORDINALITY AS msg_ids(msg_id, msg_ord)
484
+ WHERE task_indices.idx_ord = msg_ids.msg_ord
485
+ )
486
+
487
+ -- ==========================================
488
+ -- PHASE 3: RECORD TASKS IN DATABASE
489
+ -- ==========================================
490
+ INSERT INTO pgflow.step_tasks (flow_slug, run_id, step_slug, task_index, message_id)
491
+ SELECT
492
+ sent_messages.flow_slug,
493
+ sent_messages.run_id,
494
+ sent_messages.step_slug,
495
+ sent_messages.task_index,
496
+ sent_messages.msg_id
497
+ FROM sent_messages;
498
+
499
+ END;
500
+ $$;
501
+ -- Modify "complete_task" function
502
+ CREATE OR REPLACE 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 $$
503
+ declare
504
+ v_step_state pgflow.step_states%ROWTYPE;
505
+ v_dependent_map_slug text;
506
+ v_run_record pgflow.runs%ROWTYPE;
507
+ v_step_record pgflow.step_states%ROWTYPE;
508
+ begin
509
+
510
+ -- ==========================================
511
+ -- GUARD: No mutations on failed runs
512
+ -- ==========================================
513
+ IF EXISTS (SELECT 1 FROM pgflow.runs WHERE pgflow.runs.run_id = complete_task.run_id AND pgflow.runs.status = 'failed') THEN
514
+ RETURN QUERY SELECT * FROM pgflow.step_tasks
515
+ WHERE pgflow.step_tasks.run_id = complete_task.run_id
516
+ AND pgflow.step_tasks.step_slug = complete_task.step_slug
517
+ AND pgflow.step_tasks.task_index = complete_task.task_index;
518
+ RETURN;
519
+ END IF;
520
+
521
+ -- ==========================================
522
+ -- LOCK ACQUISITION AND TYPE VALIDATION
523
+ -- ==========================================
524
+ -- Acquire locks first to prevent race conditions
525
+ SELECT * INTO v_run_record FROM pgflow.runs
526
+ WHERE pgflow.runs.run_id = complete_task.run_id
527
+ FOR UPDATE;
528
+
529
+ SELECT * INTO v_step_record FROM pgflow.step_states
530
+ WHERE pgflow.step_states.run_id = complete_task.run_id
531
+ AND pgflow.step_states.step_slug = complete_task.step_slug
532
+ FOR UPDATE;
533
+
534
+ -- Check for type violations AFTER acquiring locks
535
+ SELECT child_step.step_slug INTO v_dependent_map_slug
536
+ FROM pgflow.deps dependency
537
+ JOIN pgflow.steps child_step ON child_step.flow_slug = dependency.flow_slug
538
+ AND child_step.step_slug = dependency.step_slug
539
+ JOIN pgflow.steps parent_step ON parent_step.flow_slug = dependency.flow_slug
540
+ AND parent_step.step_slug = dependency.dep_slug
541
+ JOIN pgflow.step_states child_state ON child_state.flow_slug = child_step.flow_slug
542
+ AND child_state.step_slug = child_step.step_slug
543
+ WHERE dependency.dep_slug = complete_task.step_slug -- parent is the completing step
544
+ AND dependency.flow_slug = v_run_record.flow_slug
545
+ AND parent_step.step_type = 'single' -- Only validate single steps
546
+ AND child_step.step_type = 'map'
547
+ AND child_state.run_id = complete_task.run_id
548
+ AND child_state.initial_tasks IS NULL
549
+ AND (complete_task.output IS NULL OR jsonb_typeof(complete_task.output) != 'array')
550
+ LIMIT 1;
551
+
552
+ -- Handle type violation if detected
553
+ IF v_dependent_map_slug IS NOT NULL THEN
554
+ -- Mark run as failed immediately
555
+ UPDATE pgflow.runs
556
+ SET status = 'failed',
557
+ failed_at = now()
558
+ WHERE pgflow.runs.run_id = complete_task.run_id;
559
+
560
+ -- Broadcast run:failed event
561
+ -- Uses PERFORM pattern to ensure execution (proven reliable pattern in this function)
562
+ PERFORM realtime.send(
563
+ jsonb_build_object(
564
+ 'event_type', 'run:failed',
565
+ 'run_id', complete_task.run_id,
566
+ 'flow_slug', v_run_record.flow_slug,
567
+ 'status', 'failed',
568
+ 'failed_at', now()
569
+ ),
570
+ 'run:failed',
571
+ concat('pgflow:run:', complete_task.run_id),
572
+ false
573
+ );
574
+
575
+ -- Archive all active messages (both queued and started) to prevent orphaned messages
576
+ PERFORM pgmq.archive(
577
+ v_run_record.flow_slug,
578
+ array_agg(st.message_id)
579
+ )
580
+ FROM pgflow.step_tasks st
581
+ WHERE st.run_id = complete_task.run_id
582
+ AND st.status IN ('queued', 'started')
583
+ AND st.message_id IS NOT NULL
584
+ HAVING count(*) > 0; -- Only call archive if there are messages to archive
585
+
586
+ -- Mark current task as failed and store the output
587
+ UPDATE pgflow.step_tasks
588
+ SET status = 'failed',
589
+ failed_at = now(),
590
+ output = complete_task.output, -- Store the output that caused the violation
591
+ error_message = '[TYPE_VIOLATION] Produced ' ||
592
+ CASE WHEN complete_task.output IS NULL THEN 'null'
593
+ ELSE jsonb_typeof(complete_task.output) END ||
594
+ ' instead of array'
595
+ WHERE pgflow.step_tasks.run_id = complete_task.run_id
596
+ AND pgflow.step_tasks.step_slug = complete_task.step_slug
597
+ AND pgflow.step_tasks.task_index = complete_task.task_index;
598
+
599
+ -- Mark step state as failed
600
+ UPDATE pgflow.step_states
601
+ SET status = 'failed',
602
+ failed_at = now(),
603
+ error_message = '[TYPE_VIOLATION] Map step ' || v_dependent_map_slug ||
604
+ ' expects array input but dependency ' || complete_task.step_slug ||
605
+ ' produced ' || CASE WHEN complete_task.output IS NULL THEN 'null'
606
+ ELSE jsonb_typeof(complete_task.output) END
607
+ WHERE pgflow.step_states.run_id = complete_task.run_id
608
+ AND pgflow.step_states.step_slug = complete_task.step_slug;
609
+
610
+ -- Broadcast step:failed event
611
+ -- Uses PERFORM pattern to ensure execution (proven reliable pattern in this function)
612
+ PERFORM realtime.send(
613
+ jsonb_build_object(
614
+ 'event_type', 'step:failed',
615
+ 'run_id', complete_task.run_id,
616
+ 'step_slug', complete_task.step_slug,
617
+ 'status', 'failed',
618
+ 'error_message', '[TYPE_VIOLATION] Map step ' || v_dependent_map_slug ||
619
+ ' expects array input but dependency ' || complete_task.step_slug ||
620
+ ' produced ' || CASE WHEN complete_task.output IS NULL THEN 'null'
621
+ ELSE jsonb_typeof(complete_task.output) END,
622
+ 'failed_at', now()
623
+ ),
624
+ concat('step:', complete_task.step_slug, ':failed'),
625
+ concat('pgflow:run:', complete_task.run_id),
626
+ false
627
+ );
628
+
629
+ -- Archive the current task's message (it was started, now failed)
630
+ PERFORM pgmq.archive(
631
+ v_run_record.flow_slug,
632
+ st.message_id -- Single message, use scalar form
633
+ )
634
+ FROM pgflow.step_tasks st
635
+ WHERE st.run_id = complete_task.run_id
636
+ AND st.step_slug = complete_task.step_slug
637
+ AND st.task_index = complete_task.task_index
638
+ AND st.message_id IS NOT NULL;
639
+
640
+ -- Return empty result
641
+ RETURN QUERY SELECT * FROM pgflow.step_tasks WHERE false;
642
+ RETURN;
643
+ END IF;
644
+
645
+ -- ==========================================
646
+ -- MAIN CTE CHAIN: Update task and propagate changes
647
+ -- ==========================================
648
+ WITH
649
+ -- ---------- Task completion ----------
650
+ -- Update the task record with completion status and output
651
+ task AS (
652
+ UPDATE pgflow.step_tasks
653
+ SET
654
+ status = 'completed',
655
+ completed_at = now(),
656
+ output = complete_task.output
657
+ WHERE pgflow.step_tasks.run_id = complete_task.run_id
658
+ AND pgflow.step_tasks.step_slug = complete_task.step_slug
659
+ AND pgflow.step_tasks.task_index = complete_task.task_index
660
+ AND pgflow.step_tasks.status = 'started'
661
+ RETURNING *
662
+ ),
663
+ -- ---------- Get step type for output handling ----------
664
+ step_def AS (
665
+ SELECT step.step_type
666
+ FROM pgflow.steps step
667
+ JOIN pgflow.runs run ON run.flow_slug = step.flow_slug
668
+ WHERE run.run_id = complete_task.run_id
669
+ AND step.step_slug = complete_task.step_slug
670
+ ),
671
+ -- ---------- Step state update ----------
672
+ -- Decrement remaining_tasks and potentially mark step as completed
673
+ -- Also store output atomically with status transition to completed
674
+ step_state AS (
675
+ UPDATE pgflow.step_states
676
+ SET
677
+ status = CASE
678
+ WHEN pgflow.step_states.remaining_tasks = 1 THEN 'completed' -- Will be 0 after decrement
679
+ ELSE 'started'
680
+ END,
681
+ completed_at = CASE
682
+ WHEN pgflow.step_states.remaining_tasks = 1 THEN now() -- Will be 0 after decrement
683
+ ELSE NULL
684
+ END,
685
+ remaining_tasks = pgflow.step_states.remaining_tasks - 1,
686
+ -- Store output atomically with completion (only when remaining_tasks = 1, meaning step completes)
687
+ output = CASE
688
+ -- Single step: store task output directly when completing
689
+ WHEN (SELECT step_type FROM step_def) = 'single' AND pgflow.step_states.remaining_tasks = 1 THEN
690
+ complete_task.output
691
+ -- Map step: aggregate on completion (ordered by task_index)
692
+ WHEN (SELECT step_type FROM step_def) = 'map' AND pgflow.step_states.remaining_tasks = 1 THEN
693
+ (SELECT COALESCE(jsonb_agg(all_outputs.output ORDER BY all_outputs.task_index), '[]'::jsonb)
694
+ FROM (
695
+ -- All previously completed tasks
696
+ SELECT st.output, st.task_index
697
+ FROM pgflow.step_tasks st
698
+ WHERE st.run_id = complete_task.run_id
699
+ AND st.step_slug = complete_task.step_slug
700
+ AND st.status = 'completed'
701
+ UNION ALL
702
+ -- Current task being completed (not yet visible as completed in snapshot)
703
+ SELECT complete_task.output, complete_task.task_index
704
+ ) all_outputs)
705
+ ELSE pgflow.step_states.output
706
+ END
707
+ FROM task
708
+ WHERE pgflow.step_states.run_id = complete_task.run_id
709
+ AND pgflow.step_states.step_slug = complete_task.step_slug
710
+ RETURNING pgflow.step_states.*
711
+ ),
712
+ -- ---------- Dependency resolution ----------
713
+ -- Find all child steps that depend on the completed parent step (only if parent completed)
714
+ child_steps AS (
715
+ SELECT deps.step_slug AS child_step_slug
716
+ FROM pgflow.deps deps
717
+ JOIN step_state parent_state ON parent_state.status = 'completed' AND deps.flow_slug = parent_state.flow_slug
718
+ WHERE deps.dep_slug = complete_task.step_slug -- dep_slug is the parent, step_slug is the child
719
+ ORDER BY deps.step_slug -- Ensure consistent ordering
720
+ ),
721
+ -- ---------- Lock child steps ----------
722
+ -- Acquire locks on all child steps before updating them
723
+ child_steps_lock AS (
724
+ SELECT * FROM pgflow.step_states
725
+ WHERE pgflow.step_states.run_id = complete_task.run_id
726
+ AND pgflow.step_states.step_slug IN (SELECT child_step_slug FROM child_steps)
727
+ FOR UPDATE
728
+ ),
729
+ -- ---------- Update child steps ----------
730
+ -- Decrement remaining_deps and resolve NULL initial_tasks for map steps
731
+ child_steps_update AS (
732
+ UPDATE pgflow.step_states child_state
733
+ SET remaining_deps = child_state.remaining_deps - 1,
734
+ -- Resolve NULL initial_tasks for child map steps
735
+ -- This is where child maps learn their array size from the parent
736
+ -- This CTE only runs when the parent step is complete (see child_steps JOIN)
737
+ initial_tasks = CASE
738
+ WHEN child_step.step_type = 'map' AND child_state.initial_tasks IS NULL THEN
739
+ CASE
740
+ WHEN parent_step.step_type = 'map' THEN
741
+ -- Map->map: Count all completed tasks from parent map
742
+ -- We add 1 because the current task is being completed in this transaction
743
+ -- but isn't yet visible as 'completed' in the step_tasks table
744
+ -- TODO: Refactor to use future column step_states.total_tasks
745
+ -- Would eliminate the COUNT query and just use parent_state.total_tasks
746
+ (SELECT COUNT(*)::int + 1
747
+ FROM pgflow.step_tasks parent_tasks
748
+ WHERE parent_tasks.run_id = complete_task.run_id
749
+ AND parent_tasks.step_slug = complete_task.step_slug
750
+ AND parent_tasks.status = 'completed'
751
+ AND parent_tasks.task_index != complete_task.task_index)
752
+ ELSE
753
+ -- Single->map: Use output array length (single steps complete immediately)
754
+ CASE
755
+ WHEN complete_task.output IS NOT NULL
756
+ AND jsonb_typeof(complete_task.output) = 'array' THEN
757
+ jsonb_array_length(complete_task.output)
758
+ ELSE NULL -- Keep NULL if not an array
759
+ END
760
+ END
761
+ ELSE child_state.initial_tasks -- Keep existing value (including NULL)
762
+ END
763
+ FROM child_steps children
764
+ JOIN pgflow.steps child_step ON child_step.flow_slug = (SELECT r.flow_slug FROM pgflow.runs r WHERE r.run_id = complete_task.run_id)
765
+ AND child_step.step_slug = children.child_step_slug
766
+ JOIN pgflow.steps parent_step ON parent_step.flow_slug = (SELECT r.flow_slug FROM pgflow.runs r WHERE r.run_id = complete_task.run_id)
767
+ AND parent_step.step_slug = complete_task.step_slug
768
+ WHERE child_state.run_id = complete_task.run_id
769
+ AND child_state.step_slug = children.child_step_slug
770
+ )
771
+ -- ---------- Update run remaining_steps ----------
772
+ -- Decrement the run's remaining_steps counter if step completed
773
+ UPDATE pgflow.runs
774
+ SET remaining_steps = pgflow.runs.remaining_steps - 1
775
+ FROM step_state
776
+ WHERE pgflow.runs.run_id = complete_task.run_id
777
+ AND step_state.status = 'completed';
778
+
779
+ -- ==========================================
780
+ -- POST-COMPLETION ACTIONS
781
+ -- ==========================================
782
+
783
+ -- ---------- Get updated state for broadcasting ----------
784
+ SELECT * INTO v_step_state FROM pgflow.step_states
785
+ WHERE pgflow.step_states.run_id = complete_task.run_id AND pgflow.step_states.step_slug = complete_task.step_slug;
786
+
787
+ -- ---------- Handle step completion ----------
788
+ IF v_step_state.status = 'completed' THEN
789
+ -- Broadcast step:completed event FIRST (before cascade)
790
+ -- This ensures parent broadcasts before its dependent children
791
+ -- Use stored output from step_states (set atomically during status transition)
792
+ PERFORM realtime.send(
793
+ jsonb_build_object(
794
+ 'event_type', 'step:completed',
795
+ 'run_id', complete_task.run_id,
796
+ 'step_slug', complete_task.step_slug,
797
+ 'status', 'completed',
798
+ 'output', v_step_state.output, -- Use stored output instead of re-aggregating
799
+ 'completed_at', v_step_state.completed_at
800
+ ),
801
+ concat('step:', complete_task.step_slug, ':completed'),
802
+ concat('pgflow:run:', complete_task.run_id),
803
+ false
804
+ );
805
+
806
+ -- THEN evaluate conditions on newly-ready dependent steps
807
+ -- This must happen before cascade_complete_taskless_steps so that
808
+ -- skipped steps can set initial_tasks=0 for their map dependents
809
+ IF NOT pgflow.cascade_resolve_conditions(complete_task.run_id) THEN
810
+ -- Run was failed due to a condition with when_unmet='fail'
811
+ -- Archive the current task's message before returning
812
+ PERFORM pgmq.archive(
813
+ (SELECT r.flow_slug FROM pgflow.runs r WHERE r.run_id = complete_task.run_id),
814
+ (SELECT st.message_id FROM pgflow.step_tasks st
815
+ WHERE st.run_id = complete_task.run_id
816
+ AND st.step_slug = complete_task.step_slug
817
+ AND st.task_index = complete_task.task_index)
818
+ );
819
+ RETURN QUERY SELECT * FROM pgflow.step_tasks
820
+ WHERE pgflow.step_tasks.run_id = complete_task.run_id
821
+ AND pgflow.step_tasks.step_slug = complete_task.step_slug
822
+ AND pgflow.step_tasks.task_index = complete_task.task_index;
823
+ RETURN;
824
+ END IF;
825
+
826
+ -- THEN cascade complete any taskless steps that are now ready
827
+ -- This ensures dependent children broadcast AFTER their parent
828
+ PERFORM pgflow.cascade_complete_taskless_steps(complete_task.run_id);
829
+ END IF;
830
+
831
+ -- ---------- Archive completed task message ----------
832
+ -- Move message from active queue to archive table
833
+ PERFORM (
834
+ WITH completed_tasks AS (
835
+ SELECT r.flow_slug, st.message_id
836
+ FROM pgflow.step_tasks st
837
+ JOIN pgflow.runs r ON st.run_id = r.run_id
838
+ WHERE st.run_id = complete_task.run_id
839
+ AND st.step_slug = complete_task.step_slug
840
+ AND st.task_index = complete_task.task_index
841
+ AND st.status = 'completed'
842
+ )
843
+ SELECT pgmq.archive(ct.flow_slug, ct.message_id)
844
+ FROM completed_tasks ct
845
+ WHERE EXISTS (SELECT 1 FROM completed_tasks)
846
+ );
847
+
848
+ -- ---------- Trigger next steps ----------
849
+ -- Start any steps that are now ready (deps satisfied)
850
+ PERFORM pgflow.start_ready_steps(complete_task.run_id);
851
+
852
+ -- Check if the entire run is complete
853
+ PERFORM pgflow.maybe_complete_run(complete_task.run_id);
854
+
855
+ -- ---------- Return completed task ----------
856
+ RETURN QUERY SELECT *
857
+ FROM pgflow.step_tasks AS step_task
858
+ WHERE step_task.run_id = complete_task.run_id
859
+ AND step_task.step_slug = complete_task.step_slug
860
+ AND step_task.task_index = complete_task.task_index;
861
+
862
+ end;
863
+ $$;
864
+ -- Modify "fail_task" function
865
+ CREATE OR REPLACE 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 $$
866
+ DECLARE
867
+ v_run_failed boolean;
868
+ v_step_failed boolean;
869
+ v_step_skipped boolean;
870
+ v_when_failed text;
871
+ v_task_exhausted boolean; -- True if task has exhausted retries
872
+ begin
873
+
874
+ -- If run is already failed, no retries allowed
875
+ IF EXISTS (SELECT 1 FROM pgflow.runs WHERE pgflow.runs.run_id = fail_task.run_id AND pgflow.runs.status = 'failed') THEN
876
+ UPDATE pgflow.step_tasks
877
+ SET status = 'failed',
878
+ failed_at = now(),
879
+ error_message = fail_task.error_message
880
+ WHERE pgflow.step_tasks.run_id = fail_task.run_id
881
+ AND pgflow.step_tasks.step_slug = fail_task.step_slug
882
+ AND pgflow.step_tasks.task_index = fail_task.task_index
883
+ AND pgflow.step_tasks.status = 'started';
884
+
885
+ -- Archive the task's message
886
+ PERFORM pgmq.archive(r.flow_slug, ARRAY_AGG(st.message_id))
887
+ FROM pgflow.step_tasks st
888
+ JOIN pgflow.runs r ON st.run_id = r.run_id
889
+ WHERE st.run_id = fail_task.run_id
890
+ AND st.step_slug = fail_task.step_slug
891
+ AND st.task_index = fail_task.task_index
892
+ AND st.message_id IS NOT NULL
893
+ GROUP BY r.flow_slug
894
+ HAVING COUNT(st.message_id) > 0;
895
+
896
+ RETURN QUERY SELECT * FROM pgflow.step_tasks
897
+ WHERE pgflow.step_tasks.run_id = fail_task.run_id
898
+ AND pgflow.step_tasks.step_slug = fail_task.step_slug
899
+ AND pgflow.step_tasks.task_index = fail_task.task_index;
900
+ RETURN;
901
+ END IF;
902
+
903
+ WITH run_lock AS (
904
+ SELECT * FROM pgflow.runs
905
+ WHERE pgflow.runs.run_id = fail_task.run_id
906
+ FOR UPDATE
907
+ ),
908
+ step_lock AS (
909
+ SELECT * FROM pgflow.step_states
910
+ WHERE pgflow.step_states.run_id = fail_task.run_id
911
+ AND pgflow.step_states.step_slug = fail_task.step_slug
912
+ FOR UPDATE
913
+ ),
914
+ flow_info AS (
915
+ SELECT r.flow_slug
916
+ FROM pgflow.runs r
917
+ WHERE r.run_id = fail_task.run_id
918
+ ),
919
+ config AS (
920
+ SELECT
921
+ COALESCE(s.opt_max_attempts, f.opt_max_attempts) AS opt_max_attempts,
922
+ COALESCE(s.opt_base_delay, f.opt_base_delay) AS opt_base_delay,
923
+ s.when_failed
924
+ FROM pgflow.steps s
925
+ JOIN pgflow.flows f ON f.flow_slug = s.flow_slug
926
+ JOIN flow_info fi ON fi.flow_slug = s.flow_slug
927
+ WHERE s.flow_slug = fi.flow_slug AND s.step_slug = fail_task.step_slug
928
+ ),
929
+ fail_or_retry_task as (
930
+ UPDATE pgflow.step_tasks as task
931
+ SET
932
+ status = CASE
933
+ WHEN task.attempts_count < (SELECT opt_max_attempts FROM config) THEN 'queued'
934
+ ELSE 'failed'
935
+ END,
936
+ failed_at = CASE
937
+ WHEN task.attempts_count >= (SELECT opt_max_attempts FROM config) THEN now()
938
+ ELSE NULL
939
+ END,
940
+ started_at = CASE
941
+ WHEN task.attempts_count < (SELECT opt_max_attempts FROM config) THEN NULL
942
+ ELSE task.started_at
943
+ END,
944
+ error_message = fail_task.error_message
945
+ WHERE task.run_id = fail_task.run_id
946
+ AND task.step_slug = fail_task.step_slug
947
+ AND task.task_index = fail_task.task_index
948
+ AND task.status = 'started'
949
+ RETURNING *
950
+ ),
951
+ -- Determine if task exhausted retries and get when_failed mode
952
+ task_status AS (
953
+ SELECT
954
+ (select status from fail_or_retry_task) AS new_task_status,
955
+ (select when_failed from config) AS when_failed_mode,
956
+ -- Task is exhausted when it's failed (no more retries)
957
+ ((select status from fail_or_retry_task) = 'failed') AS is_exhausted
958
+ ),
959
+ maybe_fail_step AS (
960
+ UPDATE pgflow.step_states
961
+ SET
962
+ -- Status logic:
963
+ -- - If task not exhausted (retrying): keep current status
964
+ -- - If exhausted AND when_failed='fail': set to 'failed'
965
+ -- - If exhausted AND when_failed IN ('skip', 'skip-cascade'): set to 'skipped'
966
+ status = CASE
967
+ WHEN NOT (select is_exhausted from task_status) THEN pgflow.step_states.status
968
+ WHEN (select when_failed_mode from task_status) = 'fail' THEN 'failed'
969
+ ELSE 'skipped' -- skip or skip-cascade
970
+ END,
971
+ failed_at = CASE
972
+ WHEN (select is_exhausted from task_status) AND (select when_failed_mode from task_status) = 'fail' THEN now()
973
+ ELSE NULL
974
+ END,
975
+ error_message = CASE
976
+ WHEN (select is_exhausted from task_status) THEN fail_task.error_message
977
+ ELSE NULL
978
+ END,
979
+ skip_reason = CASE
980
+ WHEN (select is_exhausted from task_status) AND (select when_failed_mode from task_status) IN ('skip', 'skip-cascade') THEN 'handler_failed'
981
+ ELSE pgflow.step_states.skip_reason
982
+ END,
983
+ skipped_at = CASE
984
+ WHEN (select is_exhausted from task_status) AND (select when_failed_mode from task_status) IN ('skip', 'skip-cascade') THEN now()
985
+ ELSE pgflow.step_states.skipped_at
986
+ END,
987
+ -- Clear remaining_tasks when skipping (required by remaining_tasks_state_consistency constraint)
988
+ remaining_tasks = CASE
989
+ WHEN (select is_exhausted from task_status) AND (select when_failed_mode from task_status) IN ('skip', 'skip-cascade') THEN NULL
990
+ ELSE pgflow.step_states.remaining_tasks
991
+ END
992
+ FROM fail_or_retry_task
993
+ WHERE pgflow.step_states.run_id = fail_task.run_id
994
+ AND pgflow.step_states.step_slug = fail_task.step_slug
995
+ RETURNING pgflow.step_states.*
996
+ )
997
+ -- Update run status: only fail when when_failed='fail' and step was failed
998
+ UPDATE pgflow.runs
999
+ SET status = CASE
1000
+ WHEN (select status from maybe_fail_step) = 'failed' THEN 'failed'
1001
+ ELSE status
1002
+ END,
1003
+ failed_at = CASE
1004
+ WHEN (select status from maybe_fail_step) = 'failed' THEN now()
1005
+ ELSE NULL
1006
+ END,
1007
+ -- Decrement remaining_steps when step was skipped (not failed, run continues)
1008
+ remaining_steps = CASE
1009
+ WHEN (select status from maybe_fail_step) = 'skipped' THEN pgflow.runs.remaining_steps - 1
1010
+ ELSE pgflow.runs.remaining_steps
1011
+ END
1012
+ WHERE pgflow.runs.run_id = fail_task.run_id
1013
+ RETURNING (status = 'failed') INTO v_run_failed;
1014
+
1015
+ -- Capture when_failed mode and check if step was skipped for later processing
1016
+ SELECT s.when_failed INTO v_when_failed
1017
+ FROM pgflow.steps s
1018
+ JOIN pgflow.runs r ON r.flow_slug = s.flow_slug
1019
+ WHERE r.run_id = fail_task.run_id
1020
+ AND s.step_slug = fail_task.step_slug;
1021
+
1022
+ SELECT (status = 'skipped') INTO v_step_skipped
1023
+ FROM pgflow.step_states
1024
+ WHERE pgflow.step_states.run_id = fail_task.run_id
1025
+ AND pgflow.step_states.step_slug = fail_task.step_slug;
1026
+
1027
+ -- Check if step failed by querying the step_states table
1028
+ SELECT (status = 'failed') INTO v_step_failed
1029
+ FROM pgflow.step_states
1030
+ WHERE pgflow.step_states.run_id = fail_task.run_id
1031
+ AND pgflow.step_states.step_slug = fail_task.step_slug;
1032
+
1033
+ -- Send broadcast event for step failure if the step was failed
1034
+ IF v_step_failed THEN
1035
+ PERFORM realtime.send(
1036
+ jsonb_build_object(
1037
+ 'event_type', 'step:failed',
1038
+ 'run_id', fail_task.run_id,
1039
+ 'step_slug', fail_task.step_slug,
1040
+ 'status', 'failed',
1041
+ 'error_message', fail_task.error_message,
1042
+ 'failed_at', now()
1043
+ ),
1044
+ concat('step:', fail_task.step_slug, ':failed'),
1045
+ concat('pgflow:run:', fail_task.run_id),
1046
+ false
1047
+ );
1048
+ END IF;
1049
+
1050
+ -- Handle step skipping (when_failed = 'skip' or 'skip-cascade')
1051
+ IF v_step_skipped THEN
1052
+ -- Send broadcast event for step skipped
1053
+ PERFORM realtime.send(
1054
+ jsonb_build_object(
1055
+ 'event_type', 'step:skipped',
1056
+ 'run_id', fail_task.run_id,
1057
+ 'step_slug', fail_task.step_slug,
1058
+ 'status', 'skipped',
1059
+ 'skip_reason', 'handler_failed',
1060
+ 'error_message', fail_task.error_message,
1061
+ 'skipped_at', now()
1062
+ ),
1063
+ concat('step:', fail_task.step_slug, ':skipped'),
1064
+ concat('pgflow:run:', fail_task.run_id),
1065
+ false
1066
+ );
1067
+
1068
+ -- For skip-cascade: cascade skip to all downstream dependents
1069
+ IF v_when_failed = 'skip-cascade' THEN
1070
+ PERFORM pgflow._cascade_force_skip_steps(fail_task.run_id, fail_task.step_slug, 'handler_failed');
1071
+ END IF;
1072
+
1073
+ -- Try to complete the run (remaining_steps may now be 0)
1074
+ PERFORM pgflow.maybe_complete_run(fail_task.run_id);
1075
+ END IF;
1076
+
1077
+ -- Send broadcast event for run failure if the run was failed
1078
+ IF v_run_failed THEN
1079
+ DECLARE
1080
+ v_flow_slug text;
1081
+ BEGIN
1082
+ SELECT flow_slug INTO v_flow_slug FROM pgflow.runs WHERE pgflow.runs.run_id = fail_task.run_id;
1083
+
1084
+ PERFORM realtime.send(
1085
+ jsonb_build_object(
1086
+ 'event_type', 'run:failed',
1087
+ 'run_id', fail_task.run_id,
1088
+ 'flow_slug', v_flow_slug,
1089
+ 'status', 'failed',
1090
+ 'error_message', fail_task.error_message,
1091
+ 'failed_at', now()
1092
+ ),
1093
+ 'run:failed',
1094
+ concat('pgflow:run:', fail_task.run_id),
1095
+ false
1096
+ );
1097
+ END;
1098
+ END IF;
1099
+
1100
+ -- Archive all active messages (both queued and started) when run fails
1101
+ IF v_run_failed THEN
1102
+ PERFORM pgmq.archive(r.flow_slug, ARRAY_AGG(st.message_id))
1103
+ FROM pgflow.step_tasks st
1104
+ JOIN pgflow.runs r ON st.run_id = r.run_id
1105
+ WHERE st.run_id = fail_task.run_id
1106
+ AND st.status IN ('queued', 'started')
1107
+ AND st.message_id IS NOT NULL
1108
+ GROUP BY r.flow_slug
1109
+ HAVING COUNT(st.message_id) > 0;
1110
+ END IF;
1111
+
1112
+ -- For queued tasks: delay the message for retry with exponential backoff
1113
+ PERFORM (
1114
+ WITH retry_config AS (
1115
+ SELECT
1116
+ COALESCE(s.opt_base_delay, f.opt_base_delay) AS base_delay
1117
+ FROM pgflow.steps s
1118
+ JOIN pgflow.flows f ON f.flow_slug = s.flow_slug
1119
+ JOIN pgflow.runs r ON r.flow_slug = f.flow_slug
1120
+ WHERE r.run_id = fail_task.run_id
1121
+ AND s.step_slug = fail_task.step_slug
1122
+ ),
1123
+ queued_tasks AS (
1124
+ SELECT
1125
+ r.flow_slug,
1126
+ st.message_id,
1127
+ pgflow.calculate_retry_delay((SELECT base_delay FROM retry_config), st.attempts_count) AS calculated_delay
1128
+ FROM pgflow.step_tasks st
1129
+ JOIN pgflow.runs r ON st.run_id = r.run_id
1130
+ WHERE st.run_id = fail_task.run_id
1131
+ AND st.step_slug = fail_task.step_slug
1132
+ AND st.task_index = fail_task.task_index
1133
+ AND st.status = 'queued'
1134
+ )
1135
+ SELECT pgmq.set_vt(qt.flow_slug, qt.message_id, qt.calculated_delay)
1136
+ FROM queued_tasks qt
1137
+ WHERE EXISTS (SELECT 1 FROM queued_tasks)
1138
+ );
1139
+
1140
+ -- For failed tasks: archive the message
1141
+ PERFORM pgmq.archive(r.flow_slug, ARRAY_AGG(st.message_id))
1142
+ FROM pgflow.step_tasks st
1143
+ JOIN pgflow.runs r ON st.run_id = r.run_id
1144
+ WHERE st.run_id = fail_task.run_id
1145
+ AND st.step_slug = fail_task.step_slug
1146
+ AND st.task_index = fail_task.task_index
1147
+ AND st.status = 'failed'
1148
+ AND st.message_id IS NOT NULL
1149
+ GROUP BY r.flow_slug
1150
+ HAVING COUNT(st.message_id) > 0;
1151
+
1152
+ return query select *
1153
+ from pgflow.step_tasks st
1154
+ where st.run_id = fail_task.run_id
1155
+ and st.step_slug = fail_task.step_slug
1156
+ and st.task_index = fail_task.task_index;
1157
+
1158
+ end;
1159
+ $$;
1160
+ -- Modify "start_flow" function
1161
+ CREATE OR REPLACE FUNCTION "pgflow"."start_flow" ("flow_slug" text, "input" jsonb, "run_id" uuid DEFAULT NULL::uuid) RETURNS SETOF "pgflow"."runs" LANGUAGE plpgsql SET "search_path" = '' AS $$
1162
+ declare
1163
+ v_created_run pgflow.runs%ROWTYPE;
1164
+ v_root_map_count int;
1165
+ begin
1166
+
1167
+ -- ==========================================
1168
+ -- VALIDATION: Root map array input
1169
+ -- ==========================================
1170
+ WITH root_maps AS (
1171
+ SELECT step_slug
1172
+ FROM pgflow.steps
1173
+ WHERE steps.flow_slug = start_flow.flow_slug
1174
+ AND steps.step_type = 'map'
1175
+ AND steps.deps_count = 0
1176
+ )
1177
+ SELECT COUNT(*) INTO v_root_map_count FROM root_maps;
1178
+
1179
+ -- If we have root map steps, validate that input is an array
1180
+ IF v_root_map_count > 0 THEN
1181
+ -- First check for NULL (should be caught by NOT NULL constraint, but be defensive)
1182
+ IF start_flow.input IS NULL THEN
1183
+ RAISE EXCEPTION 'Flow % has root map steps but input is NULL', start_flow.flow_slug;
1184
+ END IF;
1185
+
1186
+ -- Then check if it's not an array
1187
+ IF jsonb_typeof(start_flow.input) != 'array' THEN
1188
+ RAISE EXCEPTION 'Flow % has root map steps but input is not an array (got %)',
1189
+ start_flow.flow_slug, jsonb_typeof(start_flow.input);
1190
+ END IF;
1191
+ END IF;
1192
+
1193
+ -- ==========================================
1194
+ -- MAIN CTE CHAIN: Create run and step states
1195
+ -- ==========================================
1196
+ WITH
1197
+ -- ---------- Gather flow metadata ----------
1198
+ flow_steps AS (
1199
+ SELECT steps.flow_slug, steps.step_slug, steps.step_type, steps.deps_count
1200
+ FROM pgflow.steps
1201
+ WHERE steps.flow_slug = start_flow.flow_slug
1202
+ ),
1203
+ -- ---------- Create run record ----------
1204
+ created_run AS (
1205
+ INSERT INTO pgflow.runs (run_id, flow_slug, input, remaining_steps)
1206
+ VALUES (
1207
+ COALESCE(start_flow.run_id, gen_random_uuid()),
1208
+ start_flow.flow_slug,
1209
+ start_flow.input,
1210
+ (SELECT count(*) FROM flow_steps)
1211
+ )
1212
+ RETURNING *
1213
+ ),
1214
+ -- ---------- Create step states ----------
1215
+ -- Sets initial_tasks: known for root maps, NULL for dependent maps
1216
+ created_step_states AS (
1217
+ INSERT INTO pgflow.step_states (flow_slug, run_id, step_slug, remaining_deps, initial_tasks)
1218
+ SELECT
1219
+ fs.flow_slug,
1220
+ (SELECT created_run.run_id FROM created_run),
1221
+ fs.step_slug,
1222
+ fs.deps_count,
1223
+ -- Updated logic for initial_tasks:
1224
+ CASE
1225
+ WHEN fs.step_type = 'map' AND fs.deps_count = 0 THEN
1226
+ -- Root map: get array length from input
1227
+ CASE
1228
+ WHEN jsonb_typeof(start_flow.input) = 'array' THEN
1229
+ jsonb_array_length(start_flow.input)
1230
+ ELSE
1231
+ 1
1232
+ END
1233
+ WHEN fs.step_type = 'map' AND fs.deps_count > 0 THEN
1234
+ -- Dependent map: unknown until dependencies complete
1235
+ NULL
1236
+ ELSE
1237
+ -- Single steps: always 1 task
1238
+ 1
1239
+ END
1240
+ FROM flow_steps fs
1241
+ )
1242
+ SELECT * FROM created_run INTO v_created_run;
1243
+
1244
+ -- ==========================================
1245
+ -- POST-CREATION ACTIONS
1246
+ -- ==========================================
1247
+
1248
+ -- ---------- Broadcast run:started event ----------
1249
+ PERFORM realtime.send(
1250
+ jsonb_build_object(
1251
+ 'event_type', 'run:started',
1252
+ 'run_id', v_created_run.run_id,
1253
+ 'flow_slug', v_created_run.flow_slug,
1254
+ 'input', v_created_run.input,
1255
+ 'status', 'started',
1256
+ 'remaining_steps', v_created_run.remaining_steps,
1257
+ 'started_at', v_created_run.started_at
1258
+ ),
1259
+ 'run:started',
1260
+ concat('pgflow:run:', v_created_run.run_id),
1261
+ false
1262
+ );
1263
+
1264
+ -- ---------- Evaluate conditions on ready steps ----------
1265
+ -- Skip steps with unmet conditions, propagate to dependents
1266
+ IF NOT pgflow.cascade_resolve_conditions(v_created_run.run_id) THEN
1267
+ -- Run was failed due to a condition with when_unmet='fail'
1268
+ RETURN QUERY SELECT * FROM pgflow.runs where pgflow.runs.run_id = v_created_run.run_id;
1269
+ RETURN;
1270
+ END IF;
1271
+
1272
+ -- ---------- Complete taskless steps ----------
1273
+ -- Handle empty array maps that should auto-complete
1274
+ PERFORM pgflow.cascade_complete_taskless_steps(v_created_run.run_id);
1275
+
1276
+ -- ---------- Start initial steps ----------
1277
+ -- Start root steps (those with no dependencies)
1278
+ PERFORM pgflow.start_ready_steps(v_created_run.run_id);
1279
+
1280
+ -- ---------- Check for run completion ----------
1281
+ -- If cascade completed all steps (zero-task flows), finalize the run
1282
+ PERFORM pgflow.maybe_complete_run(v_created_run.run_id);
1283
+
1284
+ RETURN QUERY SELECT * FROM pgflow.runs where pgflow.runs.run_id = v_created_run.run_id;
1285
+
1286
+ end;
1287
+ $$;
1288
+ -- Modify "start_tasks" function
1289
+ CREATE OR REPLACE FUNCTION "pgflow"."start_tasks" ("flow_slug" text, "msg_ids" bigint[], "worker_id" uuid) RETURNS SETOF "pgflow"."step_task_record" LANGUAGE sql SET "search_path" = '' AS $$
1290
+ with tasks as (
1291
+ select
1292
+ task.flow_slug,
1293
+ task.run_id,
1294
+ task.step_slug,
1295
+ task.task_index,
1296
+ task.message_id
1297
+ from pgflow.step_tasks as task
1298
+ join pgflow.runs r on r.run_id = task.run_id
1299
+ where task.flow_slug = start_tasks.flow_slug
1300
+ and task.message_id = any(msg_ids)
1301
+ and task.status = 'queued'
1302
+ -- MVP: Don't start tasks on failed runs
1303
+ and r.status != 'failed'
1304
+ ),
1305
+ start_tasks_update as (
1306
+ update pgflow.step_tasks
1307
+ set
1308
+ attempts_count = attempts_count + 1,
1309
+ status = 'started',
1310
+ started_at = now(),
1311
+ last_worker_id = worker_id
1312
+ from tasks
1313
+ where step_tasks.message_id = tasks.message_id
1314
+ and step_tasks.flow_slug = tasks.flow_slug
1315
+ and step_tasks.status = 'queued'
1316
+ ),
1317
+ runs as (
1318
+ select
1319
+ r.run_id,
1320
+ r.input
1321
+ from pgflow.runs r
1322
+ where r.run_id in (select run_id from tasks)
1323
+ ),
1324
+ deps as (
1325
+ select
1326
+ st.run_id,
1327
+ st.step_slug,
1328
+ dep.dep_slug,
1329
+ -- Read output directly from step_states (already aggregated by writers)
1330
+ dep_state.output as dep_output
1331
+ from tasks st
1332
+ join pgflow.deps dep on dep.flow_slug = st.flow_slug and dep.step_slug = st.step_slug
1333
+ join pgflow.step_states dep_state on
1334
+ dep_state.run_id = st.run_id and
1335
+ dep_state.step_slug = dep.dep_slug and
1336
+ dep_state.status = 'completed' -- Only include completed deps (not skipped)
1337
+ ),
1338
+ deps_outputs as (
1339
+ select
1340
+ d.run_id,
1341
+ d.step_slug,
1342
+ jsonb_object_agg(d.dep_slug, d.dep_output) as deps_output,
1343
+ count(*) as dep_count
1344
+ from deps d
1345
+ group by d.run_id, d.step_slug
1346
+ ),
1347
+ timeouts as (
1348
+ select
1349
+ task.message_id,
1350
+ task.flow_slug,
1351
+ coalesce(step.opt_timeout, flow.opt_timeout) + 2 as vt_delay
1352
+ from tasks task
1353
+ join pgflow.flows flow on flow.flow_slug = task.flow_slug
1354
+ join pgflow.steps step on step.flow_slug = task.flow_slug and step.step_slug = task.step_slug
1355
+ ),
1356
+ -- Batch update visibility timeouts for all messages
1357
+ set_vt_batch as (
1358
+ select pgflow.set_vt_batch(
1359
+ start_tasks.flow_slug,
1360
+ array_agg(t.message_id order by t.message_id),
1361
+ array_agg(t.vt_delay order by t.message_id)
1362
+ )
1363
+ from timeouts t
1364
+ )
1365
+ select
1366
+ st.flow_slug,
1367
+ st.run_id,
1368
+ st.step_slug,
1369
+ -- ==========================================
1370
+ -- INPUT CONSTRUCTION LOGIC
1371
+ -- ==========================================
1372
+ -- This nested CASE statement determines how to construct the input
1373
+ -- for each task based on the step type (map vs non-map).
1374
+ --
1375
+ -- The fundamental difference:
1376
+ -- - Map steps: Receive RAW array elements (e.g., just 42 or "hello")
1377
+ -- - Non-map steps: Receive structured objects with named keys
1378
+ -- (e.g., {"run": {...}, "dependency1": {...}})
1379
+ -- ==========================================
1380
+ CASE
1381
+ -- -------------------- MAP STEPS --------------------
1382
+ -- Map steps process arrays element-by-element.
1383
+ -- Each task receives ONE element from the array at its task_index position.
1384
+ WHEN step.step_type = 'map' THEN
1385
+ -- Map steps get raw array elements without any wrapper object
1386
+ CASE
1387
+ -- ROOT MAP: Gets array from run input
1388
+ -- Example: run input = [1, 2, 3]
1389
+ -- task 0 gets: 1
1390
+ -- task 1 gets: 2
1391
+ -- task 2 gets: 3
1392
+ WHEN step.deps_count = 0 THEN
1393
+ -- Root map (deps_count = 0): no dependencies, reads from run input.
1394
+ -- Extract the element at task_index from the run's input array.
1395
+ -- Note: If run input is not an array, this will return NULL
1396
+ -- and the flow will fail (validated in start_flow).
1397
+ jsonb_array_element(r.input, st.task_index)
1398
+
1399
+ -- DEPENDENT MAP: Gets array from its single dependency
1400
+ -- Example: dependency output = ["a", "b", "c"]
1401
+ -- task 0 gets: "a"
1402
+ -- task 1 gets: "b"
1403
+ -- task 2 gets: "c"
1404
+ ELSE
1405
+ -- Has dependencies (should be exactly 1 for map steps).
1406
+ -- Extract the element at task_index from the dependency's output array.
1407
+ --
1408
+ -- Why the subquery with jsonb_each?
1409
+ -- - The dependency outputs a raw array: [1, 2, 3]
1410
+ -- - deps_outputs aggregates it into: {"dep_name": [1, 2, 3]}
1411
+ -- - We need to unwrap and get just the array value
1412
+ -- - Map steps have exactly 1 dependency (enforced by add_step)
1413
+ -- - So jsonb_each will return exactly 1 row
1414
+ -- - We extract the 'value' which is the raw array [1, 2, 3]
1415
+ -- - Then get the element at task_index from that array
1416
+ (SELECT jsonb_array_element(value, st.task_index)
1417
+ FROM jsonb_each(dep_out.deps_output)
1418
+ LIMIT 1)
1419
+ END
1420
+
1421
+ -- -------------------- NON-MAP STEPS --------------------
1422
+ -- Regular (non-map) steps receive dependency outputs as a structured object.
1423
+ -- Root steps (no dependencies) get empty object - they access flowInput via context.
1424
+ -- Dependent steps get only their dependency outputs.
1425
+ ELSE
1426
+ -- Non-map steps get structured input with dependency keys only
1427
+ -- Example for dependent step: {
1428
+ -- "step1": {"output": "from_step1"},
1429
+ -- "step2": {"output": "from_step2"}
1430
+ -- }
1431
+ -- Example for root step: {}
1432
+ --
1433
+ -- Note: flow_input is available separately in the returned record
1434
+ -- for workers to access via context.flowInput
1435
+ coalesce(dep_out.deps_output, '{}'::jsonb)
1436
+ END as input,
1437
+ st.message_id as msg_id,
1438
+ st.task_index as task_index,
1439
+ -- flow_input: Original run input for worker context
1440
+ -- Only included for root non-map steps to avoid data duplication.
1441
+ -- Root map steps: flowInput IS the array, useless to include
1442
+ -- Dependent steps: lazy load via ctx.flowInput when needed
1443
+ CASE
1444
+ WHEN step.step_type != 'map' AND step.deps_count = 0
1445
+ THEN r.input
1446
+ ELSE NULL
1447
+ END as flow_input
1448
+ from tasks st
1449
+ join runs r on st.run_id = r.run_id
1450
+ join pgflow.steps step on
1451
+ step.flow_slug = st.flow_slug and
1452
+ step.step_slug = st.step_slug
1453
+ left join deps_outputs dep_out on
1454
+ dep_out.run_id = st.run_id and
1455
+ dep_out.step_slug = st.step_slug
1456
+ $$;
1457
+ -- Create "add_step" function
1458
+ CREATE FUNCTION "pgflow"."add_step" ("flow_slug" text, "step_slug" text, "deps_slugs" text[] DEFAULT '{}', "max_attempts" integer DEFAULT NULL::integer, "base_delay" integer DEFAULT NULL::integer, "timeout" integer DEFAULT NULL::integer, "start_delay" integer DEFAULT NULL::integer, "step_type" text DEFAULT 'single', "condition_pattern" jsonb DEFAULT NULL::jsonb, "condition_not_pattern" jsonb DEFAULT NULL::jsonb, "when_unmet" text DEFAULT 'skip', "when_failed" text DEFAULT 'fail') RETURNS "pgflow"."steps" LANGUAGE plpgsql SET "search_path" = '' AS $$
1459
+ DECLARE
1460
+ result_step pgflow.steps;
1461
+ next_idx int;
1462
+ BEGIN
1463
+ -- Validate map step constraints
1464
+ -- Map steps can have either:
1465
+ -- 0 dependencies (root map - maps over flow input array)
1466
+ -- 1 dependency (dependent map - maps over dependency output array)
1467
+ IF COALESCE(add_step.step_type, 'single') = 'map' AND COALESCE(array_length(add_step.deps_slugs, 1), 0) > 1 THEN
1468
+ RAISE EXCEPTION 'Map step "%" can have at most one dependency, but % were provided: %',
1469
+ add_step.step_slug,
1470
+ COALESCE(array_length(add_step.deps_slugs, 1), 0),
1471
+ array_to_string(add_step.deps_slugs, ', ');
1472
+ END IF;
1473
+
1474
+ -- Get next step index
1475
+ SELECT COALESCE(MAX(s.step_index) + 1, 0) INTO next_idx
1476
+ FROM pgflow.steps s
1477
+ WHERE s.flow_slug = add_step.flow_slug;
1478
+
1479
+ -- Create the step
1480
+ INSERT INTO pgflow.steps (
1481
+ flow_slug, step_slug, step_type, step_index, deps_count,
1482
+ opt_max_attempts, opt_base_delay, opt_timeout, opt_start_delay,
1483
+ condition_pattern, condition_not_pattern, when_unmet, when_failed
1484
+ )
1485
+ VALUES (
1486
+ add_step.flow_slug,
1487
+ add_step.step_slug,
1488
+ COALESCE(add_step.step_type, 'single'),
1489
+ next_idx,
1490
+ COALESCE(array_length(add_step.deps_slugs, 1), 0),
1491
+ add_step.max_attempts,
1492
+ add_step.base_delay,
1493
+ add_step.timeout,
1494
+ add_step.start_delay,
1495
+ add_step.condition_pattern,
1496
+ add_step.condition_not_pattern,
1497
+ add_step.when_unmet,
1498
+ add_step.when_failed
1499
+ )
1500
+ ON CONFLICT ON CONSTRAINT steps_pkey
1501
+ DO UPDATE SET step_slug = EXCLUDED.step_slug
1502
+ RETURNING * INTO result_step;
1503
+
1504
+ -- Insert dependencies
1505
+ INSERT INTO pgflow.deps (flow_slug, dep_slug, step_slug)
1506
+ SELECT add_step.flow_slug, d.dep_slug, add_step.step_slug
1507
+ FROM unnest(COALESCE(add_step.deps_slugs, '{}')) AS d(dep_slug)
1508
+ WHERE add_step.deps_slugs IS NOT NULL AND array_length(add_step.deps_slugs, 1) > 0
1509
+ ON CONFLICT ON CONSTRAINT deps_pkey DO NOTHING;
1510
+
1511
+ RETURN result_step;
1512
+ END;
1513
+ $$;
1514
+ -- Drop "add_step" function
1515
+ DROP FUNCTION "pgflow"."add_step" (text, text, text[], integer, integer, integer, integer, text);