Create Scenario Test for Durable SQL Function

Objective

Generate an E2E test file in tests/e2e/sql/ that models a real-world durable function pattern. The test should validate complex orchestration behavior using pg_durable’s DSL.

Input

Describe a durable function pattern you want to test (e.g., “order processing pipeline”, “batch ETL job”, “cron-style health check”).

Output

Create a new test file in tests/e2e/sql/ following the established pattern.

Test File Structure

File Location

  • Path: tests/e2e/sql/NN_scenario_<descriptive_name>.sql
  • Numbering: Use next available number (11+) for scenarios

File Template

-- Scenario Test: [Pattern Name]
-- Based on: [Real-world pattern or USER_GUIDE example]
-- Demonstrates: [list of DSL features used]
--
-- Pattern: [Brief description of what this tests]

-- ============================================================================
-- Setup: Create tables and helper functions
-- ============================================================================

DROP TABLE IF EXISTS test_table CASCADE;
CREATE TABLE test_table (
    id SERIAL PRIMARY KEY,
    -- ... fields relevant to the scenario
    status TEXT DEFAULT 'pending',
    created_at TIMESTAMP DEFAULT now()
);

-- Mock helper functions (if needed)
CREATE OR REPLACE FUNCTION mock_process(item_id INT) RETURNS INT AS $$
BEGIN
    -- Simulate processing
    PERFORM pg_sleep(0.1);
    UPDATE test_table SET status = 'completed' WHERE id = item_id;
    RETURN item_id;
END;
$$ LANGUAGE plpgsql;

-- Seed test data
INSERT INTO test_table (...) VALUES (...);

-- ============================================================================
-- Test: Main Durable Function
-- ============================================================================

CREATE TEMP TABLE _test_state (instance_id TEXT);

INSERT INTO _test_state SELECT df.start(
    -- DSL expression here
    -- Use operators: ~>, |=>, &, |, ?>, !>, @>
    -- Use functions: df.sql(), df.sleep(), df.join(), df.if(), df.loop(), etc.
    'step 1' |=> 'result1'
    ~> 'step 2 using $result1',
    'scenario-name'
);

-- ============================================================================
-- Wait and Verify
-- ============================================================================

-- For non-loop scenarios: wait for completion
SELECT pg_sleep(N);

-- For loop scenarios: wait for iterations, then cancel
-- SELECT pg_sleep(N);
-- SELECT df.cancel((SELECT instance_id FROM _test_state), 'Test complete');

DO $$
DECLARE
    inst_id TEXT;
    inst_status TEXT;
    -- Additional variables for verification
    attempts INT := 0;
BEGIN
    SELECT instance_id INTO inst_id FROM _test_state;
    RAISE NOTICE 'Testing scenario: %', inst_id;

    -- Wait for completion (with timeout)
    LOOP
        SELECT s INTO inst_status FROM df.status(inst_id) s;
        EXIT WHEN lower(inst_status) IN ('completed', 'failed', 'cancelled') OR attempts > 300;
        PERFORM pg_sleep(0.1);
        attempts := attempts + 1;
    END LOOP;

    -- Verify expected status
    IF lower(inst_status) != 'completed' THEN
        RAISE EXCEPTION 'TEST FAILED: status = %', inst_status;
    END IF;

    -- Additional assertions specific to scenario
    -- Example: verify data was processed
    -- IF (SELECT COUNT(*) FROM test_table WHERE status = 'completed') < 1 THEN
    --     RAISE EXCEPTION 'TEST FAILED: expected completed items';
    -- END IF;

    RAISE NOTICE 'TEST PASSED: scenario_name';
END $$;

-- ============================================================================
-- Cleanup
-- ============================================================================

DROP TABLE _test_state;
DROP TABLE test_table;
DROP FUNCTION IF EXISTS mock_process(INT);
SELECT 'TEST PASSED' AS result;

DSL Features Reference

Operators

Operator Name Example
~> Sequence 'step1' ~> 'step2'
|=> Name 'SELECT 1' |=> 'myvar'
& Join 'task1' & 'task2'
| Race 'fast' | df.sleep(30)
?> If-Then 'cond' ?> 'then_branch'
!> Else 'cond' ?> 'then' !> 'else'
@> Loop @> (body ~> df.sleep(60))

Functions

Function Description
df.sql(query) Execute SQL (usually auto-wrapped)
df.sleep(seconds) Pause for N seconds
df.join(a, b) Run in parallel, wait for all
df.join3(a, b, c) Three-way parallel
df.race(a, b) Run in parallel, first wins
df.if(cond, then, else) Conditional branch
df.loop(body) Repeat forever
df.wait_for_schedule(cron) Wait for cron match

Variable Substitution

'SELECT id FROM orders LIMIT 1' |=> 'order_id'
~> 'UPDATE orders SET status = ''processing'' WHERE id = $order_id'

Scenario Patterns to Model

1. Sequential Pipeline

-- ETL: Extract -> Transform -> Load
'SELECT * FROM staging' |=> 'data'
~> 'INSERT INTO target SELECT * FROM staging WHERE ...'
~> 'DELETE FROM staging WHERE processed = true'

2. Parallel Processing with Join

-- Run multiple queries in parallel
'SELECT COUNT(*) FROM users' & 'SELECT COUNT(*) FROM orders'
~> 'INSERT INTO metrics (users, orders) VALUES (...)'

3. Conditional Logic

-- Process based on condition
'SELECT COUNT(*) > 10 FROM task_queue'
    ?> 'INSERT INTO logs VALUES (''high load'')'
    !> 'INSERT INTO logs VALUES (''normal load'')'

4. Race with Timeout

-- Process with timeout protection
(
    'SELECT long_running_process($id)'
    | df.sleep(30)  -- 30 second timeout
)

5. Cron-Style Loop

-- Run every 5 minutes
@> (
    df.wait_for_schedule('*/5 * * * *')
    ~> 'INSERT INTO heartbeats (ts) VALUES (now())'
)

6. Batch Processing Loop

-- Process items in batches until queue empty
@> (
    'SELECT get_next_batch(10)' |=> 'batch'
    ~> 'SELECT $batch IS NOT NULL'
        ?> 'SELECT process_batch($batch)'
        !> df.sleep(60)  -- Wait if queue empty
)

Test Design Principles

1. Keep Tests Fast

  • Use short sleep durations (1-2 seconds max)
  • Limit iterations for loops (2-3 before cancel)
  • Use 30 second max wait time for completion

2. Model Real Patterns

  • Don’t over-simplify; keep realistic complexity
  • Preserve business logic flow
  • Use mock functions that return deterministic results

3. Comprehensive Assertions

  • Check status is ‘completed’ (or ‘cancelled’ for loops)
  • Verify data was processed correctly
  • Check expected side effects (table updates, etc.)

4. Clean Up Properly

  • Drop all test tables
  • Drop all test functions
  • Use CASCADE when dropping tables with dependencies

5. Handle Loop Cancellation

-- df.cancel() sets df.instances.status = 'cancelled' (British spelling, lowercase).
IF lower(inst_status) != 'cancelled' THEN
    RAISE EXCEPTION 'TEST FAILED: expected cancelled, got %', inst_status;
END IF;

Variable Substitution Limitations

Note: Variable substitution replaces $name with the raw value. This works well for: - Integer IDs: $order_id123 - Simple strings without special characters

For complex string values, use a job/ID pattern instead: ```sql – ❌ May break with special characters ‘SELECT content FROM docs LIMIT 1’ |=> ‘content’ ~> ‘SELECT process($content)’ – Breaks if content has quotes

– ✅ Use ID reference pattern ‘SELECT id FROM docs LIMIT 1’ |=> ‘doc_id’ ~> ‘SELECT process_doc($doc_id)’ – Function fetches content by ID ```

Validation Checklist

Before finalizing the test:

  • [ ] Test file follows naming convention: NN_scenario_<name>.sql
  • [ ] Header comments explain what’s being tested
  • [ ] Setup creates all needed tables/functions
  • [ ] Test uses appropriate DSL features
  • [ ] Wait time is sufficient but not excessive
  • [ ] Assertions verify expected behavior
  • [ ] Cleanup removes all test artifacts
  • [ ] Test passes: ./scripts/test-e2e-local.sh NN_scenario
  • [ ] Test runs in < 30 seconds

Running the Test

# Run specific test
./scripts/test-e2e-local.sh NN_scenario

# Run with debugging
./scripts/test-e2e-local.sh --keep NN_scenario

# Then connect to investigate
psql -h localhost -p 28817 -d postgres

# Check instance status
SELECT * FROM df.list_instances();
SELECT * FROM df.instance_info('instance_id');

Ask Before Creating

If the scenario: - Requires external services - Uses features not yet implemented - Would take more than 30 seconds to run - Needs more than 3 helper functions

Then summarize the proposed test and ask for guidance before proceeding.