@pgflow/core 0.0.5-prealpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/LICENSE.md +660 -0
  2. package/README.md +373 -0
  3. package/__tests__/mocks/index.ts +1 -0
  4. package/__tests__/mocks/postgres.ts +37 -0
  5. package/__tests__/types/PgflowSqlClient.test-d.ts +59 -0
  6. package/dist/LICENSE.md +660 -0
  7. package/dist/README.md +373 -0
  8. package/dist/index.js +54 -0
  9. package/docs/options_for_flow_and_steps.md +75 -0
  10. package/docs/pgflow-blob-reference-system.md +179 -0
  11. package/eslint.config.cjs +22 -0
  12. package/example-flow.mermaid +5 -0
  13. package/example-flow.svg +1 -0
  14. package/flow-lifecycle.mermaid +83 -0
  15. package/flow-lifecycle.svg +1 -0
  16. package/out-tsc/vitest/__tests__/mocks/index.d.ts +2 -0
  17. package/out-tsc/vitest/__tests__/mocks/index.d.ts.map +1 -0
  18. package/out-tsc/vitest/__tests__/mocks/postgres.d.ts +15 -0
  19. package/out-tsc/vitest/__tests__/mocks/postgres.d.ts.map +1 -0
  20. package/out-tsc/vitest/__tests__/types/PgflowSqlClient.test-d.d.ts +2 -0
  21. package/out-tsc/vitest/__tests__/types/PgflowSqlClient.test-d.d.ts.map +1 -0
  22. package/out-tsc/vitest/tsconfig.spec.tsbuildinfo +1 -0
  23. package/out-tsc/vitest/vite.config.d.ts +3 -0
  24. package/out-tsc/vitest/vite.config.d.ts.map +1 -0
  25. package/package.json +28 -0
  26. package/pkgs/core/dist/index.js +54 -0
  27. package/pkgs/core/dist/pkgs/core/LICENSE.md +660 -0
  28. package/pkgs/core/dist/pkgs/core/README.md +373 -0
  29. package/pkgs/dsl/dist/index.js +123 -0
  30. package/pkgs/dsl/dist/pkgs/dsl/README.md +11 -0
  31. package/project.json +125 -0
  32. package/prompts/architect.md +87 -0
  33. package/prompts/condition.md +33 -0
  34. package/prompts/declarative_sql.md +15 -0
  35. package/prompts/deps_in_payloads.md +20 -0
  36. package/prompts/dsl-multi-arg.ts +48 -0
  37. package/prompts/dsl-options.md +39 -0
  38. package/prompts/dsl-single-arg.ts +51 -0
  39. package/prompts/dsl-two-arg.ts +61 -0
  40. package/prompts/dsl.md +119 -0
  41. package/prompts/fanout_steps.md +1 -0
  42. package/prompts/json_schemas.md +36 -0
  43. package/prompts/one_shot.md +286 -0
  44. package/prompts/pgtap.md +229 -0
  45. package/prompts/sdk.md +59 -0
  46. package/prompts/step_types.md +62 -0
  47. package/prompts/versioning.md +16 -0
  48. package/queries/fail_permanently.sql +17 -0
  49. package/queries/fail_task.sql +21 -0
  50. package/queries/sequential.sql +47 -0
  51. package/queries/two_roots_left_right.sql +59 -0
  52. package/schema.svg +1 -0
  53. package/scripts/colorize-pgtap-output.awk +72 -0
  54. package/scripts/run-test-with-colors +5 -0
  55. package/scripts/watch-test +7 -0
  56. package/src/PgflowSqlClient.ts +85 -0
  57. package/src/database-types.ts +759 -0
  58. package/src/index.ts +3 -0
  59. package/src/types.ts +103 -0
  60. package/supabase/config.toml +32 -0
  61. package/supabase/migrations/000000_schema.sql +150 -0
  62. package/supabase/migrations/000005_create_flow.sql +29 -0
  63. package/supabase/migrations/000010_add_step.sql +48 -0
  64. package/supabase/migrations/000015_start_ready_steps.sql +45 -0
  65. package/supabase/migrations/000020_start_flow.sql +46 -0
  66. package/supabase/migrations/000030_read_with_poll_backport.sql +70 -0
  67. package/supabase/migrations/000040_poll_for_tasks.sql +100 -0
  68. package/supabase/migrations/000045_maybe_complete_run.sql +30 -0
  69. package/supabase/migrations/000050_complete_task.sql +98 -0
  70. package/supabase/migrations/000055_calculate_retry_delay.sql +11 -0
  71. package/supabase/migrations/000060_fail_task.sql +124 -0
  72. package/supabase/migrations/000_edge_worker_initial.sql +86 -0
  73. package/supabase/seed.sql +202 -0
  74. package/supabase/tests/add_step/basic_step_addition.test.sql +29 -0
  75. package/supabase/tests/add_step/circular_dependency.test.sql +21 -0
  76. package/supabase/tests/add_step/flow_isolation.test.sql +26 -0
  77. package/supabase/tests/add_step/idempotent_step_addition.test.sql +20 -0
  78. package/supabase/tests/add_step/invalid_step_slug.test.sql +16 -0
  79. package/supabase/tests/add_step/nonexistent_dependency.test.sql +16 -0
  80. package/supabase/tests/add_step/nonexistent_flow.test.sql +13 -0
  81. package/supabase/tests/add_step/options.test.sql +66 -0
  82. package/supabase/tests/add_step/step_with_dependency.test.sql +36 -0
  83. package/supabase/tests/add_step/step_with_multiple_dependencies.test.sql +46 -0
  84. package/supabase/tests/complete_task/archives_message.test.sql +67 -0
  85. package/supabase/tests/complete_task/completes_run_if_no_more_remaining_steps.test.sql +62 -0
  86. package/supabase/tests/complete_task/completes_task_and_updates_dependents.test.sql +64 -0
  87. package/supabase/tests/complete_task/decrements_remaining_steps_if_completing_step.test.sql +62 -0
  88. package/supabase/tests/complete_task/saves_output_when_completing_run.test.sql +57 -0
  89. package/supabase/tests/create_flow/flow_creation.test.sql +27 -0
  90. package/supabase/tests/create_flow/idempotency_and_duplicates.test.sql +26 -0
  91. package/supabase/tests/create_flow/invalid_slug.test.sql +13 -0
  92. package/supabase/tests/create_flow/options.test.sql +57 -0
  93. package/supabase/tests/fail_task/exponential_backoff.test.sql +70 -0
  94. package/supabase/tests/fail_task/mark_as_failed_if_no_retries_available.test.sql +49 -0
  95. package/supabase/tests/fail_task/respects_flow_retry_settings.test.sql +48 -0
  96. package/supabase/tests/fail_task/respects_step_retry_settings.test.sql +48 -0
  97. package/supabase/tests/fail_task/retry_task_if_retries_available.test.sql +39 -0
  98. package/supabase/tests/is_valid_slug.test.sql +72 -0
  99. package/supabase/tests/poll_for_tasks/builds_proper_input_from_deps_outputs.test.sql +35 -0
  100. package/supabase/tests/poll_for_tasks/hides_messages.test.sql +35 -0
  101. package/supabase/tests/poll_for_tasks/increments_attempts_count.test.sql +35 -0
  102. package/supabase/tests/poll_for_tasks/multiple_task_processing.test.sql +24 -0
  103. package/supabase/tests/poll_for_tasks/polls_only_queued_tasks.test.sql +35 -0
  104. package/supabase/tests/poll_for_tasks/reads_messages.test.sql +38 -0
  105. package/supabase/tests/poll_for_tasks/returns_no_tasks_if_no_step_task_for_message.test.sql +34 -0
  106. package/supabase/tests/poll_for_tasks/returns_no_tasks_if_queue_is_empty.test.sql +19 -0
  107. package/supabase/tests/poll_for_tasks/returns_no_tasks_when_qty_set_to_0.test.sql +22 -0
  108. package/supabase/tests/poll_for_tasks/sets_vt_delay_based_on_opt_timeout.test.sql +41 -0
  109. package/supabase/tests/poll_for_tasks/tasks_reapppear_if_not_processed_in_time.test.sql +59 -0
  110. package/supabase/tests/start_flow/creates_run.test.sql +24 -0
  111. package/supabase/tests/start_flow/creates_step_states_for_all_steps.test.sql +25 -0
  112. package/supabase/tests/start_flow/creates_step_tasks_only_for_root_steps.test.sql +54 -0
  113. package/supabase/tests/start_flow/returns_run.test.sql +24 -0
  114. package/supabase/tests/start_flow/sends_messages_on_the_queue.test.sql +50 -0
  115. package/supabase/tests/start_flow/starts_only_root_steps.test.sql +21 -0
  116. package/supabase/tests/step_dsl_is_idempotent.test.sql +34 -0
  117. package/tsconfig.json +16 -0
  118. package/tsconfig.lib.json +26 -0
  119. package/tsconfig.spec.json +35 -0
  120. package/vite.config.ts +57 -0
@@ -0,0 +1,98 @@
1
+ -- drop function if exists pgflow.complete_task(uuid, text, int, jsonb);
2
+ create or replace function pgflow.complete_task(
3
+ run_id uuid,
4
+ step_slug text,
5
+ task_index int,
6
+ output jsonb
7
+ )
8
+ returns setof pgflow.step_tasks
9
+ language plpgsql
10
+ volatile
11
+ set search_path to ''
12
+ as $$
13
+ begin
14
+
15
+ WITH run_lock AS (
16
+ SELECT * FROM pgflow.runs
17
+ WHERE pgflow.runs.run_id = complete_task.run_id
18
+ FOR UPDATE
19
+ ),
20
+ step_lock AS (
21
+ SELECT * FROM pgflow.step_states
22
+ WHERE pgflow.step_states.run_id = complete_task.run_id
23
+ AND pgflow.step_states.step_slug = complete_task.step_slug
24
+ FOR UPDATE
25
+ ),
26
+ task AS (
27
+ UPDATE pgflow.step_tasks
28
+ SET
29
+ status = 'completed',
30
+ output = complete_task.output
31
+ WHERE pgflow.step_tasks.run_id = complete_task.run_id
32
+ AND pgflow.step_tasks.step_slug = complete_task.step_slug
33
+ AND pgflow.step_tasks.task_index = complete_task.task_index
34
+ RETURNING *
35
+ ),
36
+ step_state AS (
37
+ UPDATE pgflow.step_states
38
+ SET
39
+ status = CASE
40
+ WHEN pgflow.step_states.remaining_tasks = 1 THEN 'completed' -- Will be 0 after decrement
41
+ ELSE 'started'
42
+ END,
43
+ remaining_tasks = pgflow.step_states.remaining_tasks - 1
44
+ FROM task
45
+ WHERE pgflow.step_states.run_id = complete_task.run_id
46
+ AND pgflow.step_states.step_slug = complete_task.step_slug
47
+ RETURNING pgflow.step_states.*
48
+ ),
49
+ -- Find all dependent steps if the current step was completed
50
+ dependent_steps AS (
51
+ SELECT d.step_slug AS dependent_step_slug
52
+ FROM pgflow.deps d
53
+ JOIN step_state s ON s.status = 'completed' AND d.flow_slug = s.flow_slug
54
+ WHERE d.dep_slug = complete_task.step_slug
55
+ ORDER BY d.step_slug -- Ensure consistent ordering
56
+ ),
57
+ -- Lock dependent steps before updating
58
+ dependent_steps_lock AS (
59
+ SELECT * FROM pgflow.step_states
60
+ WHERE pgflow.step_states.run_id = complete_task.run_id
61
+ AND pgflow.step_states.step_slug IN (SELECT dependent_step_slug FROM dependent_steps)
62
+ FOR UPDATE
63
+ ),
64
+ -- Update all dependent steps
65
+ dependent_steps_update AS (
66
+ UPDATE pgflow.step_states
67
+ SET remaining_deps = pgflow.step_states.remaining_deps - 1
68
+ FROM dependent_steps
69
+ WHERE pgflow.step_states.run_id = complete_task.run_id
70
+ AND pgflow.step_states.step_slug = dependent_steps.dependent_step_slug
71
+ )
72
+ -- Only decrement remaining_steps, don't update status
73
+ UPDATE pgflow.runs
74
+ SET remaining_steps = pgflow.runs.remaining_steps - 1
75
+ FROM step_state
76
+ WHERE pgflow.runs.run_id = complete_task.run_id
77
+ AND step_state.status = 'completed';
78
+
79
+ PERFORM pgmq.archive(
80
+ queue_name => (SELECT run.flow_slug FROM pgflow.runs AS run WHERE run.run_id = complete_task.run_id),
81
+ msg_id => (SELECT message_id FROM pgflow.step_tasks AS step_task
82
+ WHERE step_task.run_id = complete_task.run_id
83
+ AND step_task.step_slug = complete_task.step_slug
84
+ AND step_task.task_index = complete_task.task_index)
85
+ );
86
+
87
+ PERFORM pgflow.start_ready_steps(complete_task.run_id);
88
+
89
+ PERFORM pgflow.maybe_complete_run(complete_task.run_id);
90
+
91
+ RETURN QUERY SELECT *
92
+ FROM pgflow.step_tasks AS step_task
93
+ WHERE step_task.run_id = complete_task.run_id
94
+ AND step_task.step_slug = complete_task.step_slug
95
+ AND step_task.task_index = complete_task.task_index;
96
+
97
+ end;
98
+ $$;
@@ -0,0 +1,11 @@
1
+ create or replace function pgflow.calculate_retry_delay(
2
+ base_delay numeric,
3
+ attempts_count int
4
+ )
5
+ returns int
6
+ language sql
7
+ immutable
8
+ parallel safe
9
+ as $$
10
+ select floor(base_delay * power(2, attempts_count))::int
11
+ $$;
@@ -0,0 +1,124 @@
1
+ create or replace function pgflow.fail_task(
2
+ run_id uuid,
3
+ step_slug text,
4
+ task_index int,
5
+ error_message text
6
+ )
7
+ returns setof pgflow.step_tasks
8
+ language plpgsql
9
+ volatile
10
+ set search_path to ''
11
+ as $$
12
+ begin
13
+
14
+ WITH run_lock AS (
15
+ SELECT * FROM pgflow.runs
16
+ WHERE pgflow.runs.run_id = fail_task.run_id
17
+ FOR UPDATE
18
+ ),
19
+ step_lock AS (
20
+ SELECT * FROM pgflow.step_states
21
+ WHERE pgflow.step_states.run_id = fail_task.run_id
22
+ AND pgflow.step_states.step_slug = fail_task.step_slug
23
+ FOR UPDATE
24
+ ),
25
+ flow_info AS (
26
+ SELECT r.flow_slug
27
+ FROM pgflow.runs r
28
+ WHERE r.run_id = fail_task.run_id
29
+ ),
30
+ config AS (
31
+ SELECT
32
+ COALESCE(s.opt_max_attempts, f.opt_max_attempts) AS opt_max_attempts,
33
+ COALESCE(s.opt_base_delay, f.opt_base_delay) AS opt_base_delay
34
+ FROM pgflow.steps s
35
+ JOIN pgflow.flows f ON f.flow_slug = s.flow_slug
36
+ JOIN flow_info fi ON fi.flow_slug = s.flow_slug
37
+ WHERE s.flow_slug = fi.flow_slug AND s.step_slug = fail_task.step_slug
38
+ ),
39
+
40
+ fail_or_retry_task as (
41
+ UPDATE pgflow.step_tasks as task
42
+ SET
43
+ status = CASE
44
+ WHEN task.attempts_count < (SELECT opt_max_attempts FROM config) THEN 'queued'
45
+ ELSE 'failed'
46
+ END,
47
+ error_message = fail_task.error_message
48
+ WHERE task.run_id = fail_task.run_id
49
+ AND task.step_slug = fail_task.step_slug
50
+ AND task.task_index = fail_task.task_index
51
+ AND task.status = 'queued'
52
+ RETURNING *
53
+ ),
54
+ maybe_fail_step AS (
55
+ UPDATE pgflow.step_states
56
+ SET
57
+ status = CASE
58
+ WHEN (select fail_or_retry_task.status from fail_or_retry_task) = 'failed' THEN 'failed'
59
+ ELSE pgflow.step_states.status
60
+ END
61
+ FROM fail_or_retry_task
62
+ WHERE pgflow.step_states.run_id = fail_task.run_id
63
+ AND pgflow.step_states.step_slug = fail_task.step_slug
64
+ RETURNING pgflow.step_states.*
65
+ )
66
+ UPDATE pgflow.runs
67
+ SET status = CASE
68
+ WHEN (select status from maybe_fail_step) = 'failed' THEN 'failed'
69
+ ELSE status
70
+ END
71
+ WHERE pgflow.runs.run_id = fail_task.run_id;
72
+
73
+ -- For queued tasks: delay the message for retry with exponential backoff
74
+ PERFORM (
75
+ WITH retry_config AS (
76
+ SELECT
77
+ COALESCE(s.opt_base_delay, f.opt_base_delay) AS base_delay
78
+ FROM pgflow.steps s
79
+ JOIN pgflow.flows f ON f.flow_slug = s.flow_slug
80
+ JOIN pgflow.runs r ON r.flow_slug = f.flow_slug
81
+ WHERE r.run_id = fail_task.run_id
82
+ AND s.step_slug = fail_task.step_slug
83
+ ),
84
+ queued_tasks AS (
85
+ SELECT
86
+ r.flow_slug,
87
+ st.message_id,
88
+ pgflow.calculate_retry_delay((SELECT base_delay FROM retry_config), st.attempts_count) AS calculated_delay
89
+ FROM pgflow.step_tasks st
90
+ JOIN pgflow.runs r ON st.run_id = r.run_id
91
+ WHERE st.run_id = fail_task.run_id
92
+ AND st.step_slug = fail_task.step_slug
93
+ AND st.task_index = fail_task.task_index
94
+ AND st.status = 'queued'
95
+ )
96
+ SELECT pgmq.set_vt(qt.flow_slug, qt.message_id, qt.calculated_delay)
97
+ FROM queued_tasks qt
98
+ WHERE EXISTS (SELECT 1 FROM queued_tasks)
99
+ );
100
+
101
+ -- For failed tasks: archive the message
102
+ PERFORM (
103
+ WITH failed_tasks AS (
104
+ SELECT r.flow_slug, st.message_id
105
+ FROM pgflow.step_tasks st
106
+ JOIN pgflow.runs r ON st.run_id = r.run_id
107
+ WHERE st.run_id = fail_task.run_id
108
+ AND st.step_slug = fail_task.step_slug
109
+ AND st.task_index = fail_task.task_index
110
+ AND st.status = 'failed'
111
+ )
112
+ SELECT pgmq.archive(ft.flow_slug, ft.message_id)
113
+ FROM failed_tasks ft
114
+ WHERE EXISTS (SELECT 1 FROM failed_tasks)
115
+ );
116
+
117
+ return query select *
118
+ from pgflow.step_tasks st
119
+ where st.run_id = fail_task.run_id
120
+ and st.step_slug = fail_task.step_slug
121
+ and st.task_index = fail_task.task_index;
122
+
123
+ end;
124
+ $$;
@@ -0,0 +1,86 @@
1
+ create extension if not exists pgmq version '1.4.4';
2
+
3
+ create schema if not exists edge_worker;
4
+
5
+ -------------------------------------------------------------------------------
6
+ -- Workers Table --------------------------------------------------------------
7
+ -------------------------------------------------------------------------------
8
+ create table if not exists edge_worker.workers (
9
+ worker_id uuid not null primary key,
10
+ queue_name text not null,
11
+ function_name text not null,
12
+ started_at timestamptz not null default now(),
13
+ stopped_at timestamptz,
14
+ last_heartbeat_at timestamptz not null default now()
15
+ );
16
+
17
+ --------------------------------------------------------------------------------
18
+ -- Read With Poll --------------------------------------------------------------
19
+ -- --
20
+ -- This is a backport of the pgmq.read_with_poll function from version 1.5.0 --
21
+ -- It is required because it fixes a bug with high CPU usage and Supabase --
22
+ -- is still using version 1.4.4. --
23
+ -- --
24
+ -- It is slightly modified (removed headers which are not available in 1.4.1) --
25
+ -- --
26
+ -- This will be removed once Supabase upgrades to 1.5.0 or higher. --
27
+ --------------------------------------------------------------------------------
28
+ create function edge_worker.read_with_poll(
29
+ queue_name text,
30
+ vt integer,
31
+ qty integer,
32
+ max_poll_seconds integer default 5,
33
+ poll_interval_ms integer default 100,
34
+ conditional jsonb default '{}'
35
+ )
36
+ returns setof pgmq.message_record as $$
37
+ DECLARE
38
+ r pgmq.message_record;
39
+ stop_at timestamp;
40
+ sql text;
41
+ qtable text := pgmq.format_table_name(queue_name, 'q');
42
+ BEGIN
43
+ stop_at := clock_timestamp() + make_interval(secs => max_poll_seconds);
44
+ LOOP
45
+ IF (SELECT clock_timestamp() >= stop_at) THEN
46
+ RETURN;
47
+ END IF;
48
+
49
+ sql := FORMAT(
50
+ $QUERY$
51
+ WITH cte AS
52
+ (
53
+ SELECT msg_id
54
+ FROM pgmq.%I
55
+ WHERE vt <= clock_timestamp() AND CASE
56
+ WHEN %L != '{}'::jsonb THEN (message @> %2$L)::integer
57
+ ELSE 1
58
+ END = 1
59
+ ORDER BY msg_id ASC
60
+ LIMIT $1
61
+ FOR UPDATE SKIP LOCKED
62
+ )
63
+ UPDATE pgmq.%I m
64
+ SET
65
+ vt = clock_timestamp() + %L,
66
+ read_ct = read_ct + 1
67
+ FROM cte
68
+ WHERE m.msg_id = cte.msg_id
69
+ RETURNING m.msg_id, m.read_ct, m.enqueued_at, m.vt, m.message;
70
+ $QUERY$,
71
+ qtable, conditional, qtable, make_interval(secs => vt)
72
+ );
73
+
74
+ FOR r IN
75
+ EXECUTE sql USING qty
76
+ LOOP
77
+ RETURN NEXT r;
78
+ END LOOP;
79
+ IF FOUND THEN
80
+ RETURN;
81
+ ELSE
82
+ PERFORM pg_sleep(poll_interval_ms::numeric / 1000);
83
+ END IF;
84
+ END LOOP;
85
+ END;
86
+ $$ language plpgsql;
@@ -0,0 +1,202 @@
1
+ create schema if not exists pgflow_tests;
2
+
3
+ --------------------------------------------------------------------------------
4
+ --------- reset_db - clears all tables and drops all queues --------------------
5
+ --------------------------------------------------------------------------------
6
+ create or replace function pgflow_tests.reset_db() returns void as $$
7
+ DELETE FROM pgflow.step_tasks;
8
+ DELETE FROM pgflow.step_states;
9
+ DELETE FROM pgflow.runs;
10
+ DELETE FROM pgflow.deps;
11
+ DELETE FROM pgflow.steps;
12
+ DELETE FROM pgflow.flows;
13
+
14
+ SELECT pgmq.drop_queue(queue_name) FROM pgmq.list_queues();
15
+ $$ language sql;
16
+
17
+ --------------------------------------------------------------------------------
18
+ --------- setup_flow - creates a predefined flow and adds steps to it ----------
19
+ --------------------------------------------------------------------------------
20
+ create or replace function pgflow_tests.setup_flow(
21
+ flow_slug text
22
+ ) returns void as $$
23
+ begin
24
+
25
+ if flow_slug = 'sequential' then
26
+ PERFORM pgflow.create_flow('sequential', timeout => 1);
27
+ PERFORM pgflow.add_step('sequential', 'first');
28
+ PERFORM pgflow.add_step('sequential', 'second', ARRAY['first']);
29
+ PERFORM pgflow.add_step('sequential', 'last', ARRAY['second']);
30
+ elsif flow_slug = 'sequential_other' then
31
+ PERFORM pgflow.create_flow('other', timeout => 1);
32
+ PERFORM pgflow.add_step('other', 'first');
33
+ PERFORM pgflow.add_step('other', 'second', ARRAY['first']);
34
+ PERFORM pgflow.add_step('other', 'last', ARRAY['second']);
35
+ elsif flow_slug = 'two_roots' then
36
+ PERFORM pgflow.create_flow('two_roots', timeout => 1);
37
+ PERFORM pgflow.add_step('two_roots', 'root_a');
38
+ PERFORM pgflow.add_step('two_roots', 'root_b');
39
+ PERFORM pgflow.add_step('two_roots', 'last', ARRAY['root_a', 'root_b']);
40
+ elsif flow_slug = 'two_roots_left_right' then
41
+ PERFORM pgflow.create_flow('two_roots_left_right', timeout => 1);
42
+ PERFORM pgflow.add_step('two_roots_left_right', 'connected_root');
43
+ PERFORM pgflow.add_step('two_roots_left_right', 'disconnected_root');
44
+ PERFORM pgflow.add_step('two_roots_left_right', 'left', ARRAY['connected_root']);
45
+ PERFORM pgflow.add_step('two_roots_left_right', 'right', ARRAY['connected_root']);
46
+ else
47
+ RAISE EXCEPTION 'Unknown test flow: %', flow_slug;
48
+ end if;
49
+
50
+ end;
51
+ $$ language plpgsql;
52
+
53
+ --------------------------------------------------------------------------------
54
+ ------- poll_and_fail - polls for a task and fails it immediately --------------
55
+ --------------------------------------------------------------------------------
56
+ create or replace function pgflow_tests.poll_and_fail(
57
+ flow_slug text,
58
+ vt integer default 1,
59
+ qty integer default 1
60
+ ) returns setof pgflow.step_tasks as $$
61
+ -- Poll for a task and complete it in one step
62
+ WITH task AS (
63
+ SELECT * FROM pgflow.poll_for_tasks(flow_slug, vt, qty) LIMIT 1
64
+ )
65
+ SELECT pgflow.fail_task(
66
+ (SELECT run_id FROM task),
67
+ (SELECT step_slug FROM task),
68
+ 0,
69
+ concat(task.step_slug, ' FAILED')
70
+ )
71
+ FROM task;
72
+ $$ language sql;
73
+
74
+ --------------------------------------------------------------------------------
75
+ ------- poll_and_complete - polls for a task and completes it immediately ------
76
+ --------------------------------------------------------------------------------
77
+ create or replace function pgflow_tests.poll_and_complete(
78
+ flow_slug text,
79
+ vt integer default 1,
80
+ qty integer default 1
81
+ ) returns setof pgflow.step_tasks as $$
82
+ -- Poll for a task and complete it in one step
83
+ WITH task AS (
84
+ SELECT * FROM pgflow.poll_for_tasks(flow_slug, vt, qty) LIMIT 1
85
+ )
86
+ SELECT pgflow.complete_task(
87
+ (SELECT run_id FROM task),
88
+ (SELECT step_slug FROM task),
89
+ 0,
90
+ jsonb_build_object('input', task.input)
91
+ )
92
+ FROM task;
93
+ $$ language sql;
94
+
95
+ --------------------------------------------------------------------------------
96
+ ------- message_timing - returns messages with added vt_seconds int ------------
97
+ --------------------------------------------------------------------------------
98
+ create or replace function pgflow_tests.message_timing(step_slug text, queue_name text)
99
+ returns table (
100
+ msg_id bigint,
101
+ read_ct int,
102
+ enqueued_at timestamptz,
103
+ vt timestamptz,
104
+ message jsonb,
105
+ vt_seconds int
106
+ )
107
+ language plpgsql
108
+ as $$
109
+ DECLARE
110
+ qtable TEXT;
111
+ query TEXT;
112
+ BEGIN
113
+ qtable := pgmq.format_table_name(queue_name, 'q');
114
+
115
+ query := format('
116
+ SELECT
117
+ q.msg_id,
118
+ q.read_ct,
119
+ q.enqueued_at,
120
+ q.vt,
121
+ q.message,
122
+ extract(epoch from (q.vt - q.enqueued_at))::int as vt_seconds
123
+ FROM pgmq.%s q
124
+ JOIN pgflow.step_tasks st ON st.message_id = q.msg_id
125
+ WHERE st.step_slug = $1', qtable);
126
+
127
+ RETURN QUERY EXECUTE query USING step_slug;
128
+ END;
129
+ $$;
130
+
131
+ --------------------------------------------------------------------------------
132
+ ------- reset_message_visibility -----------------------------------------------
133
+ --------------------------------------------------------------------------------
134
+ --
135
+ -- Makes all hidden messages in a queue immediately visible by setting their
136
+ -- visibility time (vt) to the current timestamp.
137
+ --
138
+ -- This is a test utility that allows testing retry logic without waiting for
139
+ -- actual delays to expire. It directly modifies the pgmq queue table.
140
+ --
141
+ -- @param queue_name The name of the queue to modify
142
+ -- @return The number of messages that were made visible
143
+ --
144
+ create or replace function pgflow_tests.reset_message_visibility(
145
+ queue_name text
146
+ ) returns integer as $$
147
+ DECLARE
148
+ qtable TEXT;
149
+ query TEXT;
150
+ updated_count INTEGER;
151
+ BEGIN
152
+ -- Get the formatted table name for the queue
153
+ qtable := pgmq.format_table_name(queue_name, 'q');
154
+
155
+ -- Construct and execute the query to update all messages' visibility time
156
+ query := format('
157
+ UPDATE pgmq.%s
158
+ SET vt = clock_timestamp()
159
+ WHERE vt > clock_timestamp()
160
+ RETURNING 1', qtable);
161
+
162
+ -- Execute the query and count the number of updated rows
163
+ EXECUTE query INTO updated_count;
164
+
165
+ -- Return the number of messages that were made visible
166
+ RETURN COALESCE(updated_count, 0);
167
+ END;
168
+ $$ language plpgsql;
169
+
170
+
171
+ --------------------------------------------------------------------------------
172
+ ------- assert_retry_delay -----------------------------------------------------
173
+ --------------------------------------------------------------------------------
174
+ --
175
+ -- Asserts that the calculated retry delay matches the expected value.
176
+ --
177
+ -- @param step_slug The slug of the step to check
178
+ -- @param queue_name The name of the queue to check
179
+ -- @param expected_delay The expected delay value
180
+ -- @param description A description of the test case
181
+ -- @return TEXT result from the is() function
182
+ --
183
+ create or replace function pgflow_tests.assert_retry_delay(
184
+ queue_name text,
185
+ step_slug text,
186
+ expected_delay integer,
187
+ description text
188
+ ) returns text as $$
189
+ DECLARE
190
+ actual_delay INTEGER;
191
+ BEGIN
192
+ SELECT vt_seconds INTO actual_delay
193
+ FROM pgflow_tests.message_timing(step_slug, queue_name)
194
+ LIMIT 1;
195
+
196
+ RETURN is(
197
+ actual_delay,
198
+ expected_delay,
199
+ description
200
+ );
201
+ END;
202
+ $$ language plpgsql;
@@ -0,0 +1,29 @@
1
+ begin;
2
+ select plan(3);
3
+ select pgflow_tests.reset_db();
4
+
5
+ -- Setup
6
+ select pgflow.create_flow('test_flow');
7
+
8
+ -- Test
9
+ select pgflow.add_step('test_flow', 'first_step');
10
+ select results_eq(
11
+ $$ SELECT step_slug FROM pgflow.steps WHERE flow_slug = 'test_flow' $$,
12
+ array['first_step']::text [],
13
+ 'Step should be added to the steps table'
14
+ );
15
+ select is_empty(
16
+ $$ SELECT * FROM pgflow.deps WHERE flow_slug = 'test_flow' $$,
17
+ 'No dependencies should be added for step with no dependencies'
18
+ );
19
+ select is(
20
+ (
21
+ select deps_count::int from pgflow.steps
22
+ where flow_slug = 'test_flow'
23
+ ),
24
+ 0::int,
25
+ 'deps_count should be 0 because there are no dependencies'
26
+ );
27
+
28
+ select * from finish();
29
+ rollback;
@@ -0,0 +1,21 @@
1
+ begin;
2
+ select plan(1);
3
+ select pgflow_tests.reset_db();
4
+
5
+ -- Setup
6
+ select pgflow.create_flow('test_flow');
7
+ select pgflow.add_step('test_flow', 'first_step');
8
+ select pgflow.add_step('test_flow', 'second_step', array['first_step']);
9
+ select pgflow.add_step('test_flow', 'third_step', array['second_step']);
10
+ select
11
+ pgflow.add_step('test_flow', 'fourth_step', array['second_step', 'third_step']);
12
+
13
+ -- Test
14
+ select throws_ok(
15
+ $$ SELECT pgflow.add_step('test_flow', 'circular_step', ARRAY['fourth_step', 'circular_step']) $$,
16
+ 'new row for relation "deps" violates check constraint "deps_check"',
17
+ 'Should not allow self-depending steps'
18
+ );
19
+
20
+ select * from finish();
21
+ rollback;
@@ -0,0 +1,26 @@
1
+ begin;
2
+ select plan(1);
3
+ select pgflow_tests.reset_db();
4
+
5
+ -- Setup
6
+ select pgflow.create_flow('test_flow');
7
+ select pgflow.add_step('test_flow', 'first_step');
8
+ select pgflow.create_flow('another_flow');
9
+
10
+ -- Test
11
+ select pgflow.add_step('another_flow', 'first_step');
12
+ select pgflow.add_step('another_flow', 'another_step', array['first_step']);
13
+ select set_eq(
14
+ $$
15
+ SELECT flow_slug, step_slug
16
+ FROM pgflow.steps WHERE flow_slug = 'another_flow'
17
+ $$,
18
+ $$ VALUES
19
+ ('another_flow', 'another_step'),
20
+ ('another_flow', 'first_step')
21
+ $$,
22
+ 'Steps in second flow should be isolated from first flow'
23
+ );
24
+
25
+ select * from finish();
26
+ rollback;
@@ -0,0 +1,20 @@
1
+ begin;
2
+ select plan(1);
3
+ select pgflow_tests.reset_db();
4
+
5
+ -- Setup
6
+ select pgflow.create_flow('test_flow');
7
+ select pgflow.add_step('test_flow', 'first_step');
8
+
9
+ -- Test
10
+ select pgflow.add_step('test_flow', 'first_step');
11
+ select results_eq(
12
+ $$
13
+ SELECT count(*)::int FROM pgflow.steps WHERE flow_slug = 'test_flow' AND step_slug = 'first_step'
14
+ $$,
15
+ array[1]::int [],
16
+ 'Calling add_step again for same step does not create a duplicate'
17
+ );
18
+
19
+ select * from finish();
20
+ rollback;
@@ -0,0 +1,16 @@
1
+ begin;
2
+ select plan(1);
3
+ select pgflow_tests.reset_db();
4
+
5
+ -- Setup
6
+ select pgflow.create_flow('test_flow');
7
+
8
+ -- Test
9
+ select throws_ok(
10
+ $$ SELECT pgflow.add_step('test_flow', '1invalid-slug') $$,
11
+ 'new row for relation "steps" violates check constraint "steps_step_slug_check"',
12
+ 'Should detect and prevent invalid step slug'
13
+ );
14
+
15
+ select * from finish();
16
+ rollback;
@@ -0,0 +1,16 @@
1
+ begin;
2
+ select plan(1);
3
+ select pgflow_tests.reset_db();
4
+
5
+ -- Setup
6
+ select pgflow.create_flow('test_flow');
7
+
8
+ -- Test
9
+ select throws_ok(
10
+ $$ SELECT pgflow.add_step('test_flow', 'invalid_dep_step', ARRAY['nonexistent_step']) $$,
11
+ 'insert or update on table "deps" violates foreign key constraint "deps_flow_slug_dep_slug_fkey"',
12
+ 'Should detect and prevent dependency on non-existent step'
13
+ );
14
+
15
+ select * from finish();
16
+ rollback;
@@ -0,0 +1,13 @@
1
+ begin;
2
+ select plan(1);
3
+ select pgflow_tests.reset_db();
4
+
5
+ -- Test
6
+ select throws_ok(
7
+ $$ SELECT pgflow.add_step('nonexistent_flow', 'some_step') $$,
8
+ 'insert or update on table "steps" violates foreign key constraint "steps_flow_slug_fkey"',
9
+ 'Should not allow adding step to non-existent flow'
10
+ );
11
+
12
+ select * from finish();
13
+ rollback;