SQL-native durable functions for PostgreSQL
pg_durable brings durable execution to PostgreSQL. It lets you author long-running, fault-tolerant workflows entirely in SQL—no external orchestrators, no YAML, no separate deployment.
Modern applications need workflows that:
- Survive failures — A crashed process shouldn't lose hours of work
- Span long durations — Wait for human approval, schedule nightly jobs, retry for days
- Coordinate complex operations — Fan-out/fan-in, conditional branching, parallel execution
- React to database state — Wait for idle, check replica lag, respond to triggers
Today's solutions require external systems (Temporal, Airflow, Step Functions) with their own infrastructure, languages, and deployment complexity. But for database-centric workloads, the database itself is the natural home for this logic.
pg_durable embeds a durable execution engine into PostgreSQL:
-- A workflow that runs forever, processing embeddings only when the database is idle
SELECT durable.start(
durable.loop(
durable.wait_idle(0.20, 3)
~> durable.func('generate_embeddings', '{"table": "documents"}')
)
);Key properties:
- Durable — Workflow state is persisted to PostgreSQL. Survives crashes, restarts, and failovers.
- SQL-native — Author workflows in SQL using composable functions and operators.
- Database-aware — First-class primitives for waiting on idle, replica lag, table conditions.
- Transactional — Workflow state changes are ACID. No split-brain, no lost updates.
- SQL is the interface — No new languages. If you know SQL, you can write workflows.
- PostgreSQL is the source of truth — Workflow state lives in tables. Query it, back it up, replicate it.
- External runtime, minimal extension — The SQL extension defines the DSL. A separate Rust process executes workflows, connecting via standard PostgreSQL protocol.
- Composition over configuration — Simple primitives combine into complex workflows.
The runtime is powered by duroxide, a durable task framework for Rust. Orchestrations are deterministic and survive crashes via replay:
async fn order_workflow(ctx: OrchestrationContext, order_json: String) -> Result<String, String> {
ctx.trace_info("Starting order workflow");
// Each await is a persistence point — workflow resumes here after crash
let reserved = ctx.schedule_activity("ReserveInventory", order_json.clone())
.into_activity().await?;
let payment = ctx.schedule_activity("ChargePayment", reserved)
.into_activity().await?;
// Parallel fan-out
let notifications = vec![
ctx.schedule_activity("SendEmail", payment.clone()),
ctx.schedule_activity("SendSMS", payment.clone()),
];
let _ = ctx.join(notifications).await;
// Timer (durable — survives restart)
ctx.schedule_timer(Duration::from_secs(3600)).into_timer().await;
// Wait for external event with timeout
let approval = ctx.schedule_wait("ApprovalEvent");
let timeout = ctx.schedule_timer(Duration::from_secs(86400));
let (winner, _) = ctx.select2(approval, timeout).await;
if winner == 1 {
return Err("Approval timed out".to_string());
}
Ok(payment)
}Learn more: GitHub · Docs · Examples
Simple building blocks that combine into complex workflows.
Execute steps one after another:
durable.sql('INSERT INTO logs VALUES (''started'')')
~> durable.func('do_work', '{}')
~> durable.sql('INSERT INTO logs VALUES (''finished'')')Execute steps concurrently, wait for all to complete:
(
durable.func('fetch_users', '{}')
& durable.func('fetch_orders', '{}')
& durable.func('fetch_products', '{}')
) => 'data'Execute steps concurrently, return first to complete:
durable.http_get('https://api1.example.com/data')
| durable.http_get('https://api2.example.com/data')
| (durable.sleep('5 seconds') ~> durable.value('{"error": "timeout"}'))Pause execution for a duration:
durable.func('send_email', '{"to": "user@example.com"}')
~> durable.sleep('1 hour')
~> durable.func('send_reminder', '{"to": "user@example.com"}')Capture a result and reference it later:
durable.sql('SELECT id, email FROM users WHERE id = 1') => 'user'
~> durable.func('send_email', '{"to": "$user.rows[0].email"}')Repeat until a condition is met:
durable.loop(
durable.sql('SELECT count(*) as pending FROM jobs WHERE status = ''pending''') => 'result'
~> durable.if(
'$result.rows[0].pending = 0',
durable.break(),
durable.func('process_next_job', '{}') ~> durable.sleep('1 second')
)
)Process each row from a query:
durable.sql('SELECT id, url FROM images WHERE thumbnail IS NULL') => 'images'
~> durable.for_each('img', $images.rows,
durable.func('generate_thumbnail', '{"id": "$img.id", "url": "$img.url"}')
)Run expensive operations (embeddings, analytics, cleanup) only when the database has spare capacity:
SELECT durable.start(
durable.loop(
durable.wait_idle(0.20, 3)
~> durable.sql('SELECT id FROM documents WHERE embedding IS NULL LIMIT 100') => 'batch'
~> durable.if(
'$batch.row_count > 0',
durable.for_each('doc', $batch.rows,
durable.if(
durable.is_idle(0.30, 5),
durable.func('generate_embedding', '{"id": "$doc.id"}'),
durable.break()
)
),
durable.sleep('30 seconds')
)
)
);Wait for maintenance windows, coordinate multi-step operations:
SELECT durable.start(
durable.loop(
durable.wait_stat('SELECT EXTRACT(HOUR FROM now()) BETWEEN 2 AND 4', '1 minute')
~> durable.wait_idle(0.05, 1)
~> (
durable.sql('VACUUM ANALYZE events')
& durable.sql('VACUUM ANALYZE users')
)
~> durable.sql('REINDEX TABLE CONCURRENTLY events')
~> durable.sleep('20 hours')
)
);Wait for external events with timeouts:
SELECT durable.start(
durable.func('send_approval_request', '{"doc": "doc-123"}')
~> durable.wait_trigger('approval', '7 days') => 'decision'
~> durable.if(
'$decision.approved',
durable.func('publish_document', '{"doc": "doc-123"}'),
durable.func('notify_rejection', '{"reason": "$decision.reason"}')
)
);
-- Later, from application code:
SELECT durable.fire_trigger('instance-id', 'approval', '{"approved": true}');ETL with parallel extraction, error handling, and progress tracking:
SELECT durable.start(
durable.loop(
durable.wait_stat('SELECT EXTRACT(MINUTE FROM now()) < 5', '1 minute')
~> (
durable.sql('INSERT INTO staging.events SELECT * FROM raw.events WHERE NOT processed RETURNING count(*)')
& durable.sql('INSERT INTO staging.users SELECT * FROM raw.users WHERE NOT processed RETURNING count(*)')
) => 'extracted'
~> durable.if(
'$extracted[0].count + $extracted[1].count > 0',
durable.func('transform_and_load', '{"events": $extracted[0].count}') => 'loaded'
~> durable.sql('UPDATE raw.events SET processed = true WHERE NOT processed'),
durable.noop()
)
~> durable.sleep('55 minutes')
)
);Two-phase commit across database shards:
SELECT durable.start(
(
durable.sql('SELECT dblink_exec(''shard1'', ''PREPARE TRANSACTION ''''txn'''')')
& durable.sql('SELECT dblink_exec(''shard2'', ''PREPARE TRANSACTION ''''txn'''')')
) => 'prepared'
~> durable.if(
durable.all_ok('$prepared'),
(
durable.sql('SELECT dblink_exec(''shard1'', ''COMMIT PREPARED ''''txn'''')')
& durable.sql('SELECT dblink_exec(''shard2'', ''COMMIT PREPARED ''''txn'''')')
),
(
durable.sql('SELECT dblink_exec(''shard1'', ''ROLLBACK PREPARED ''''txn'''')')
& durable.sql('SELECT dblink_exec(''shard2'', ''ROLLBACK PREPARED ''''txn'''')')
)
)
);These scenarios demonstrate capabilities similar to pgflow and pgai—building RAG applications, document processing, and LLM-powered workflows entirely in SQL.
Continuously embed new documents with chunking, rate limiting, and automatic sync:
SELECT durable.start(
durable.loop(
durable.wait_idle(0.15, 2)
~> durable.sql('SELECT id, content FROM documents WHERE embedding IS NULL LIMIT 50') => 'docs'
~> durable.if(
'$docs.row_count > 0',
durable.for_each('doc', $docs.rows,
durable.func('chunk_text', '{"id": "$doc.id", "size": 512, "overlap": 50}') => 'chunks'
~> durable.for_each('chunk', $chunks,
durable.http_post('https://api.openai.com/v1/embeddings',
'{"model": "text-embedding-3-small", "input": "$chunk.text"}',
'{"Authorization": "Bearer $ENV.OPENAI_API_KEY"}'
) => 'embedding'
~> durable.sql('INSERT INTO embeddings (doc_id, chunk_idx, vector, text) VALUES ($1, $2, $3, $4)',
$doc.id, $chunk.idx, $embedding.data[0].embedding, $chunk.text)
)
)
~> durable.sleep('5 seconds'),
durable.sleep('30 seconds')
)
)
);Retrieve context, call LLM, and store the response:
SELECT durable.start(
-- Generate query embedding
durable.http_post('https://api.openai.com/v1/embeddings',
'{"model": "text-embedding-3-small", "input": "$input.question"}',
'{"Authorization": "Bearer $ENV.OPENAI_API_KEY"}'
) => 'query_emb'
-- Semantic search for relevant chunks
~> durable.sql($$
SELECT text, 1 - (vector <=> $1::vector) as score
FROM embeddings
ORDER BY vector <=> $1::vector
LIMIT 5
$$, $query_emb.data[0].embedding) => 'context'
-- Call LLM with retrieved context
~> durable.http_post('https://api.openai.com/v1/chat/completions',
'{
"model": "gpt-4o",
"messages": [
{"role": "system", "content": "Answer based on the following context:\\n$context.rows[*].text"},
{"role": "user", "content": "$input.question"}
]
}',
'{"Authorization": "Bearer $ENV.OPENAI_API_KEY"}'
) => 'answer'
-- Store the interaction
~> durable.sql('INSERT INTO chat_history (question, context, answer) VALUES ($1, $2, $3)',
$input.question, $context, $answer.choices[0].message.content)
);Try multiple LLM providers, use first successful response:
SELECT durable.start(
durable.sql('SELECT id, content FROM articles WHERE summary IS NULL LIMIT 10') => 'articles'
~> durable.for_each('article', $articles.rows,
(
-- Race: try multiple providers, take first success
durable.http_post('https://api.openai.com/v1/chat/completions',
'{"model": "gpt-4o-mini", "messages": [{"role": "user", "content": "Summarize: $article.content"}]}',
'{"Authorization": "Bearer $ENV.OPENAI_API_KEY"}'
)
| durable.http_post('https://api.anthropic.com/v1/messages',
'{"model": "claude-3-haiku-20240307", "messages": [{"role": "user", "content": "Summarize: $article.content"}]}',
'{"x-api-key": "$ENV.ANTHROPIC_API_KEY", "anthropic-version": "2023-06-01"}'
)
| (durable.sleep('10 seconds') ~> durable.value('{"error": "all providers failed"}'))
) => 'result'
~> durable.if(
'$result.error IS NULL',
durable.sql('UPDATE articles SET summary = $1 WHERE id = $2',
$result.choices[0].message.content, $article.id),
durable.sql('INSERT INTO failed_jobs (article_id, error) VALUES ($1, $2)',
$article.id, $result.error)
)
)
);Parse PDFs/HTML, chunk, and vectorize from object storage:
SELECT durable.start(
durable.loop(
durable.sql('SELECT id, s3_uri, mime_type FROM pending_imports WHERE status = ''pending'' LIMIT 20') => 'files'
~> durable.if(
'$files.row_count > 0',
durable.for_each('file', $files.rows,
-- Download and parse
durable.func('s3_download', '{"uri": "$file.s3_uri"}') => 'raw'
~> durable.case_when(
'$file.mime_type = ''application/pdf''' := durable.func('parse_pdf', '{"content": "$raw"}'),
'$file.mime_type = ''text/html''' := durable.func('parse_html', '{"content": "$raw"}'),
otherwise := durable.value('{"text": "$raw"}')
) => 'parsed'
-- Chunk the text
~> durable.func('chunk_text', '{"text": "$parsed.text", "size": 1000}') => 'chunks'
-- Embed all chunks in parallel batches
~> durable.for_each('chunk', $chunks,
durable.http_post('https://api.openai.com/v1/embeddings',
'{"model": "text-embedding-3-small", "input": "$chunk.text"}',
'{"Authorization": "Bearer $ENV.OPENAI_API_KEY"}'
) => 'emb'
~> durable.sql('INSERT INTO doc_chunks (file_id, chunk_idx, text, embedding) VALUES ($1, $2, $3, $4)',
$file.id, $chunk.idx, $chunk.text, $emb.data[0].embedding)
)
~> durable.sql('UPDATE pending_imports SET status = ''completed'' WHERE id = $1', $file.id)
)
~> durable.sleep('10 seconds'),
durable.sleep('1 minute')
)
)
);LLM decides which tools to call, loop until done:
SELECT durable.start(
durable.sql('SELECT context FROM agent_sessions WHERE id = $1', $input.session_id) => 'session'
~> durable.loop(
-- Ask LLM what to do next
durable.http_post('https://api.openai.com/v1/chat/completions',
'{
"model": "gpt-4o",
"messages": $session.messages,
"tools": [
{"type": "function", "function": {"name": "search_db", "parameters": {...}}},
{"type": "function", "function": {"name": "send_email", "parameters": {...}}},
{"type": "function", "function": {"name": "complete", "parameters": {...}}}
]
}',
'{"Authorization": "Bearer $ENV.OPENAI_API_KEY"}'
) => 'response'
~> durable.case_when(
'$response.choices[0].finish_reason = ''tool_calls'' AND $response.choices[0].message.tool_calls[0].function.name = ''search_db''' :=
durable.sql('SELECT * FROM knowledge_base WHERE content ILIKE $1',
$response.choices[0].message.tool_calls[0].function.arguments.query) => 'tool_result'
~> durable.sql('UPDATE agent_sessions SET messages = messages || $1 WHERE id = $2',
'[{"role": "tool", "content": "$tool_result"}]', $input.session_id),
'$response.choices[0].finish_reason = ''tool_calls'' AND $response.choices[0].message.tool_calls[0].function.name = ''send_email''' :=
durable.func('send_email', $response.choices[0].message.tool_calls[0].function.arguments) => 'tool_result'
~> durable.sql('UPDATE agent_sessions SET messages = messages || $1 WHERE id = $2',
'[{"role": "tool", "content": "email sent"}]', $input.session_id),
otherwise := durable.break()
)
)
~> durable.sql('UPDATE agent_sessions SET status = ''completed'' WHERE id = $1', $input.session_id)
);Rebuild embeddings when model changes, with zero downtime:
SELECT durable.start(
-- Create new embeddings table
durable.sql('CREATE TABLE embeddings_v2 (LIKE embeddings INCLUDING ALL)')
-- Reprocess all documents with new model
~> durable.sql('SELECT id, text FROM embeddings') => 'existing'
~> durable.for_each('item', $existing.rows,
durable.wait_idle(0.25, 3)
~> durable.http_post('https://api.openai.com/v1/embeddings',
'{"model": "text-embedding-3-large", "input": "$item.text"}', -- new model
'{"Authorization": "Bearer $ENV.OPENAI_API_KEY"}'
) => 'new_emb'
~> durable.sql('INSERT INTO embeddings_v2 (id, text, vector) VALUES ($1, $2, $3)',
$item.id, $item.text, $new_emb.data[0].embedding)
)
-- Atomic swap
~> durable.sql('ALTER TABLE embeddings RENAME TO embeddings_old')
~> durable.sql('ALTER TABLE embeddings_v2 RENAME TO embeddings')
~> durable.sql('DROP TABLE embeddings_old')
);Classify documents using multiple categories in parallel:
SELECT durable.start(
durable.sql('SELECT id, content FROM documents WHERE categories IS NULL') => 'docs'
~> durable.for_each('doc', $docs.rows,
-- Run all classifiers in parallel
(
durable.http_post('https://api.openai.com/v1/chat/completions',
'{"model": "gpt-4o-mini", "messages": [{"role": "user", "content": "Classify sentiment (positive/negative/neutral): $doc.content"}]}',
'{"Authorization": "Bearer $ENV.OPENAI_API_KEY"}'
) => 'sentiment'
& durable.http_post('https://api.openai.com/v1/chat/completions',
'{"model": "gpt-4o-mini", "messages": [{"role": "user", "content": "Extract topics as JSON array: $doc.content"}]}',
'{"Authorization": "Bearer $ENV.OPENAI_API_KEY"}'
) => 'topics'
& durable.http_post('https://api.openai.com/v1/chat/completions',
'{"model": "gpt-4o-mini", "messages": [{"role": "user", "content": "Rate urgency 1-5: $doc.content"}]}',
'{"Authorization": "Bearer $ENV.OPENAI_API_KEY"}'
) => 'urgency'
)
~> durable.sql('UPDATE documents SET sentiment = $1, topics = $2, urgency = $3, categories = true WHERE id = $4',
$sentiment.choices[0].message.content,
$topics.choices[0].message.content,
$urgency.choices[0].message.content,
$doc.id
)
)
);Execute actions that produce results.
| Function | Description |
|---|---|
durable.sql(query, ...args) |
Execute SQL, return result as JSON |
durable.sql_bool(query) |
Execute SQL, return boolean result |
durable.func(name, args) |
Call registered activity function |
durable.http_get(url, headers) |
HTTP GET request, return response as JSON |
durable.http_post(url, body, headers) |
HTTP POST request, return response as JSON |
durable.sleep(duration) |
Sleep for interval (e.g., '5 minutes') |
durable.value(json) |
Return literal JSON value |
durable.noop() |
Do nothing, return null |
Block until a condition is met.
| Function | Description |
|---|---|
durable.wait_idle(max_cpu, max_sessions) |
Wait until database load drops below thresholds |
durable.wait_stat(query, poll_interval) |
Wait until query returns true |
durable.wait_trigger(name, timeout) |
Wait for external trigger (with optional timeout) |
durable.wait_replica_lag(max_bytes, replica) |
Wait until replica lag is below threshold |
durable.is_idle(max_cpu, max_sessions) |
Check if idle (non-blocking, returns boolean) |
Compose futures into larger workflows.
| Function | Description |
|---|---|
durable.then(a, b) |
Sequential: run a, then b |
durable.join(array) |
Parallel: run all, wait for all to complete |
durable.race(array) |
Parallel: run all, return first to complete |
durable.if(cond, then, else) |
Conditional branching |
durable.case_when(cases, otherwise) |
Multi-branch conditional (see below) |
durable.loop(body) |
Repeat body forever |
durable.for_each(var, source, body) |
Iterate over result set (see below) |
durable.batch(source, size) |
Group items into batches of N |
durable.as(name, fut) |
Name a future's result for later reference |
Iterate over rows from a result set with an explicit loop variable:
durable.for_each(variable_name, source, body)| Parameter | Type | Description |
|---|---|---|
variable_name |
text | Name of the loop variable (without $) |
source |
step result | The result set to iterate over (e.g., $blobs) |
body |
step | The workflow step(s) to execute for each row |
Example:
durable.sql('SELECT path, bytes FROM files') => 'files'
~> durable.for_each('file', $files,
durable.sql('INSERT INTO imports VALUES ($1)', $file.path)
)The loop variable $file is available within the body to access columns like $file.path, $file.bytes.
Multi-branch conditional based on expressions:
durable.case_when(
'$value < 100' := durable.func('small_handler', ...),
'$value < 1000' := durable.func('medium_handler', ...),
otherwise := durable.func('large_handler', ...)
)Conditions are evaluated in order; first match wins.
Control iteration within loops.
| Function | Description |
|---|---|
durable.break() |
Exit current for_each or loop |
durable.continue() |
Skip to next iteration |
Manage workflow instances.
| Function | Description |
|---|---|
durable.start(fut) |
Start a workflow, return instance ID |
durable.status(id) |
Get workflow status |
durable.cancel(id) |
Cancel a running workflow |
durable.fire_trigger(id, name, payload) |
Fire external trigger |
Operators provide concise syntax for common patterns.
| Operator | Equivalent Function | Description |
|---|---|---|
a ~> b |
durable.then(a, b) |
Sequential composition |
a => 'name' |
durable.as('name', a) |
Name result for $name reference |
a & b |
durable.join(ARRAY[a, b]) |
Parallel execution (all) |
a | b |
durable.race(ARRAY[a, b]) |
Parallel execution (first) |
Results named with => 'name' can be referenced using $name:
durable.sql('SELECT * FROM orders LIMIT 10') => 'orders'
~> durable.for_each('order', $orders.rows, ...)Access nested fields with dot notation:
$orders.row_count— Number of rows$orders.rows[0].id— First row's id field$order.amount— Current iteration item's amount (loop variable fromfor_each)
Process documents for retrieval-augmented generation with load awareness:
SELECT durable.start(
durable.loop(
durable.wait_idle(0.20, 3)
-- Get documents, pre-batched for efficiency
~> durable.sql($$
SELECT batch_num, json_agg(json_build_object('id', id, 'content', content)) as docs
FROM (
SELECT id, content, ntile(10) OVER (ORDER BY id) as batch_num
FROM documents WHERE embedding IS NULL LIMIT 100
) t
GROUP BY batch_num ORDER BY batch_num
$$) => 'batches'
~> durable.if(
'$batches.row_count > 0',
durable.for_each('batch', $batches.rows,
durable.if(
durable.is_idle(0.30, 5),
-- Process batch: chunk then embed
durable.for_each('doc', $batch.docs,
durable.func('chunk_document', '{"id": "$doc.id", "chunk_size": 512}') => 'chunks'
~> durable.func('generate_embeddings', '{"chunks": $chunks}')
),
durable.break()
)
)
~> durable.sleep('5 seconds'),
durable.sleep('1 minute')
)
)
);Process orders with validation:
SELECT durable.start(
durable.sql('SELECT * FROM orders WHERE status = ''pending'' LIMIT 10') => 'orders'
~> durable.if(
'$orders.row_count = 0',
durable.sql('INSERT INTO log (msg) VALUES (''No pending orders'')'),
durable.for_each('order', $orders.rows,
-- Skip invalid orders
durable.if('$order.amount < 10', durable.continue(), durable.noop())
-- Process the order
~> durable.func('process_order', '{"id": "$order.id"}')
)
)
);Wait for payment or timeout, handle both cases:
SELECT durable.start(
durable.func('send_invoice', '{"id": "inv-123"}')
~> (
durable.wait_trigger('payment_received')
| (durable.sleep('24 hours') ~> durable.value('{"timeout": true}'))
) => 'result'
~> durable.if(
'$result.timeout',
durable.func('escalate_invoice', '{"id": "inv-123"}'),
durable.func('complete_order', '{"payment": "$result"}')
)
);Run migrations only when safe:
SELECT durable.start(
durable.wait_idle(0.10, 5)
~> durable.wait_stat(
'SELECT NOT EXISTS(SELECT 1 FROM pg_stat_activity WHERE state = ''active'' AND query_start < now() - ''5 min''::interval)',
'30 seconds'
)
~> durable.func('create_backup', '{"label": "pre_migration"}') => 'backup'
~> durable.func('run_migration', '{"version": "v42"}') => 'result'
~> durable.func('verify_migration', '{"version": "v42"}')
);Import large files from Azure Storage with batching, progress tracking, and automatic resume on failure.
Prerequisites:
CREATE EXTENSION azure_storage;
SELECT azure_storage.account_add('mystorageaccount', 'ACCESS_KEY_HERE');Workflow:
SELECT durable.start(
-- Get last sync checkpoint
durable.sql($$
SELECT last_path FROM sync_checkpoints
WHERE source = 'azure-events'
$$) => 'checkpoint'
-- List blobs after checkpoint
~> durable.sql($$
SELECT path, bytes
FROM azure_storage.blob_list('mystorageaccount', 'events', '*.parquet')
WHERE path > $1
ORDER BY path
$$, COALESCE($checkpoint.last_path, '')) => 'blobs'
-- Process each blob
~> durable.for_each('blob', $blobs,
-- Stream and insert
durable.sql($$
WITH imported AS (
INSERT INTO events
SELECT * FROM azure_storage.blob_get(
'mystorageaccount', 'events', $1,
options := azure_storage.options_parquet_get()
)
RETURNING 1
)
SELECT count(*) as cnt FROM imported
$$, $blob.path) => 'result'
-- Update checkpoint after each file
~> durable.sql($$
INSERT INTO sync_checkpoints (source, last_path, rows_imported, updated_at)
VALUES ('azure-events', $1, $2, now())
ON CONFLICT (source) DO UPDATE SET
last_path = EXCLUDED.last_path,
rows_imported = sync_checkpoints.rows_imported + EXCLUDED.rows_imported,
updated_at = now()
$$, $blob.path, $result.cnt)
)
);This workflow:
- Resumes from last checkpoint on restart
- Tracks per-file progress in
sync_checkpoints - Survives crashes mid-import (durability)
- Processes files in order for deterministic resume
The same workflow using explicit function calls (no operators):
SELECT durable.start(
durable.loop(
durable.then(
durable.wait_idle(0.20, 3),
durable.then(
durable.as('batch',
durable.sql('SELECT id FROM documents WHERE embedding IS NULL LIMIT 100')
),
durable.if(
'$batch.row_count > 0',
durable.then(
durable.for_each('item', $batch.rows,
durable.if(
durable.is_idle(0.30, 5),
durable.func('generate_embedding', '{"id": "$item.id"}'),
durable.break()
)
),
durable.sleep('5 seconds')
),
durable.sleep('1 minute')
)
)
)
)
);┌─────────────────────────────────────────────────────────────────────┐
│ PostgreSQL │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ pg_durable Extension (pgrx) │ │
│ │ │ │
│ │ ┌──────────────────────────────────────────────────────────┐ │ │
│ │ │ SQL DSL Layer │ │ │
│ │ │ │ │ │
│ │ │ • SQL functions (durable.*) │ │ │
│ │ │ • Operators (~>, =>, &, |) │ │ │
│ │ │ • Composite types (durofut) │ │ │
│ │ │ │ │ │
│ │ └──────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ ┌──────────────────────────────────────────────────────────┐ │ │
│ │ │ duroxide Runtime (background worker) │ │ │
│ │ │ │ │ │
│ │ │ • Polls duro_instances for new work │ │ │
│ │ │ • Loads workflow graph from duro_nodes │ │ │
│ │ │ • Executes as duroxide orchestration │ │ │
│ │ │ • Each step = duroxide activity (checkpointed) │ │ │
│ │ │ • Survives crash via replay │ │ │
│ │ │ │ │ │
│ │ │ Built-in Activities: │ │ │
│ │ │ execute_sql — Run SQL via SPI │ │ │
│ │ │ wait_idle — Poll pg_stat_activity │ │ │
│ │ │ wait_trigger — Wait for external event │ │ │
│ │ │ http_request — Make HTTP calls │ │ │
│ │ │ │ │ │
│ │ └──────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ └────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ durable Schema │ │
│ │ │ │
│ │ • duro_nodes — Workflow graph (DAG of futures) │ │
│ │ • duro_instances — Running workflow instances │ │
│ │ • duro_triggers — Pending external events │ │
│ │ • duro_history — Execution history (audit log) │ │
│ │ • duroxide tables — Managed by duroxide-pg │ │
│ │ │ │
│ └────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
Key insight: The duroxide runtime runs inside the PostgreSQL extension as a background worker, not as a separate process. This simplifies deployment and ensures the runtime has direct access to PostgreSQL internals via SPI.
-
User calls
durable.start(fut)— Extension builds workflow graph, stores induro_nodes, creates instance induro_instances, notifies background worker, returns instance ID. -
Background worker polls for work — Queries for pending instances.
-
Worker executes nodes — Based on node type:
THEN— Execute left, then rightJOIN— Execute children in parallel, wait for allRACE— Execute children in parallel, return firstFUNC— Dispatch to registered activityWAIT_*— Poll or subscribe until condition met
-
State persisted via duroxide-pg — After each activity, duroxide persists checkpoint. Workflow survives PostgreSQL restart.
-
Completion — When root node completes, instance marked done in
duro_instances.
-- Workflow graph nodes
CREATE TABLE durable.duro_nodes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
instance_id UUID NOT NULL,
node_type TEXT NOT NULL, -- THEN, JOIN, RACE, IF, LOOP, FOR_EACH, FUNC, SQL, WAIT_*, etc.
config JSONB, -- Node-specific configuration
status TEXT DEFAULT 'Pending', -- Pending, Running, Completed, Failed
result JSONB, -- Output when completed
result_name TEXT, -- Variable name for $ref substitution
left_node UUID, -- For THEN: first step
right_node UUID, -- For THEN: second step
body_node UUID, -- For LOOP, FOR_EACH: body to repeat
children UUID[], -- For JOIN, RACE: parallel children
created_at TIMESTAMPTZ DEFAULT now(),
completed_at TIMESTAMPTZ
);
-- Workflow instances
CREATE TABLE durable.duro_instances (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
root_node UUID NOT NULL REFERENCES durable.duro_nodes(id),
status TEXT DEFAULT 'Running', -- Running, Completed, Failed, Cancelled
created_at TIMESTAMPTZ DEFAULT now(),
completed_at TIMESTAMPTZ
);
-- External triggers
CREATE TABLE durable.duro_triggers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
instance_id UUID NOT NULL,
node_id UUID NOT NULL,
trigger_name TEXT NOT NULL,
payload JSONB,
timeout_at TIMESTAMPTZ,
fired_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT now()
);
-- Execution history (for debugging/audit)
CREATE TABLE durable.duro_history (
id BIGSERIAL PRIMARY KEY,
instance_id UUID NOT NULL,
node_id UUID NOT NULL,
event_type TEXT NOT NULL, -- Started, Completed, Failed, Retried
event_data JSONB,
created_at TIMESTAMPTZ DEFAULT now()
);When a node references $varname, the executor:
- Walks up the execution context to find a node with
result_name = 'varname' - Retrieves that node's
resultJSONB - Applies the path (e.g.,
$orders.rows[0].id→result->'rows'->0->>'id') - Substitutes into the current node's config
This happens at execution time, not at graph construction time.
The PostgreSQL extension is built with pgrx and provides:
Composite Type:
CREATE TYPE durable.durofut AS (
node_id UUID
);Graph-Building Functions:
Each durable.* function inserts a row into duro_nodes and returns a durofut:
#[pg_extern]
fn then(left: Durofut, right: Durofut) -> Durofut {
let node_id = Uuid::new_v4();
Spi::run(&format!(
"INSERT INTO durable.duro_nodes (id, node_type, left_node, right_node)
VALUES ('{}', 'THEN', '{}', '{}')",
node_id, left.node_id, right.node_id
));
Durofut { node_id }
}Operators:
CREATE OPERATOR ~> (
LEFTARG = durofut, RIGHTARG = durofut,
FUNCTION = durable.then
);The runtime is a standalone Rust binary using:
duroxide— Durable task orchestration frameworkduroxide-pg— PostgreSQL backend for duroxidesqlx— Async PostgreSQL drivertokio— Async runtime
Main Loop:
async fn run_orchestrator(pool: PgPool) {
loop {
// Find instances with executable work
let instances = sqlx::query_as::<_, Instance>(
"SELECT * FROM durable.duro_instances WHERE status = 'Running'"
).fetch_all(&pool).await?;
for instance in instances {
// Execute the DAG from root
execute_node(&pool, instance.root_node).await?;
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
}Node Execution:
async fn execute_node(pool: &PgPool, node_id: Uuid) -> Result<JsonValue> {
let node = fetch_node(pool, node_id).await?;
match node.node_type.as_str() {
"THEN" => {
let left_result = execute_node(pool, node.left_node.unwrap()).await?;
let right_result = execute_node(pool, node.right_node.unwrap()).await?;
Ok(right_result)
}
"JOIN" => {
let futures: Vec<_> = node.children.iter()
.map(|id| execute_node(pool, *id))
.collect();
let results = futures::future::join_all(futures).await;
Ok(json!(results))
}
"FUNC" => {
let activity_name = node.config["name"].as_str().unwrap();
let args = substitute_vars(&node.config["args"], &context);
execute_activity(pool, activity_name, args).await
}
"WAIT_IDLE" => {
loop {
if check_idle(pool, &node.config).await? {
return Ok(json!({"idle": true}));
}
tokio::time::sleep(Duration::from_secs(1)).await;
}
}
// ... other node types
}
}Built-in activities implemented in Rust:
execute_sql:
async fn execute_sql(pool: &PgPool, query: &str) -> Result<JsonValue> {
let rows = sqlx::query(query).fetch_all(pool).await?;
Ok(json!({
"row_count": rows.len(),
"rows": rows_to_json(&rows)
}))
}wait_idle:
async fn wait_idle(pool: &PgPool, max_cpu: f64, max_sessions: i32) -> Result<JsonValue> {
loop {
let stats: (i64, f64) = sqlx::query_as(
"SELECT count(*), COALESCE(avg(EXTRACT(EPOCH FROM (now() - query_start))), 0)
FROM pg_stat_activity WHERE state = 'active'"
).fetch_one(pool).await?;
if stats.0 <= max_sessions as i64 {
return Ok(json!({"idle": true, "active_sessions": stats.0}));
}
tokio::time::sleep(Duration::from_secs(1)).await;
}
}The goal is to prove the core architecture works: SQL DSL → Graph storage → duroxide execution.
MVP Scope:
durable.sql(query)— Execute SQL, return result as JSONdurable.then(a, b)— Sequential compositiondurable.as(name, fut)— Name a result for later reference~>operator — Shorthand forthen()=>operator — Shorthand foras()durable.start(fut)— Start a workflow instance
What MVP Proves:
- SQL functions can build a workflow graph and store it in tables
- The duroxide runtime can load and execute that graph
- Activity results persist and are available to subsequent steps
- Workflow state survives runtime restart (durable execution)
MVP Example:
-- A simple 3-step workflow
SELECT durable.start(
durable.sql('SELECT count(*) as total FROM users') => 'users'
~> durable.sql('SELECT count(*) as total FROM orders') => 'orders'
~> durable.sql('INSERT INTO stats (users, orders) VALUES ($1, $2)',
$users.rows[0].total, $orders.rows[0].total)
);See MVP.md for detailed implementation plan and additional examples.
Step 1: duroxide-pg Hello World
- Create runtime crate with duroxide + duroxide-pg dependencies
- Configure duroxide-pg to use
durableschema for its internal tables - Implement a trivial "hello world" orchestration
- Verify orchestration state persists across runtime restarts
- Validate replay works after simulated crash
Step 2: Generic SQL Activity
- Implement
execute_sqlactivity that connects to the PG instance - Accept query string and parameters
- Return result as JSON (
{rows: [...], row_count: N}) - Register as a duroxide activity
Step 3: Extension Skeleton
- Initialize pgrx project
- Create
durableschema - Define
durofutcomposite type - Create
duro_nodes,duro_instancestables - Implement
durable.sql()— builds SQL node, stores induro_nodes
Step 4: Basic Combinators
-
durable.then(a, b)— builds THEN node linking two nodes -
durable.as(name, fut)— wraps node with result name -
~>operator forthen() -
=>operator foras()
Step 5: Control Plane
-
durable.start(fut)— creates instance, returns ID - Runtime polls
duro_instancesfor new work - Runtime loads graph from
duro_nodes - Runtime executes via duroxide orchestration
Step 6: End-to-End Test
- Create a 3-step workflow via SQL
- Verify runtime picks it up and executes
- Kill runtime mid-execution
- Restart runtime, verify it resumes from checkpoint
- Verify final result is correct
Combinators:
-
durable.join(array)— parallel, wait all -
durable.race(array)— parallel, first wins -
durable.if(cond, then, else)— conditional -
&operator (join) -
|operator (race)
Primitives:
-
durable.func(name, args)— call registered UDF -
durable.sleep(interval)— durable timer -
durable.value(json)— return literal -
durable.noop()— do nothing
-
durable.loop(body) -
durable.for_each(var, source, body) -
durable.case_when(cases, otherwise) -
durable.break() -
durable.continue() - Variable substitution (
$name.path)
-
durable.wait_idle(max_cpu, max_sessions) -
durable.wait_stat(query, poll_interval) -
durable.wait_trigger(name, timeout) -
durable.fire_trigger(id, name, payload) -
durable.wait_replica_lag(max_bytes, replica) -
durable.is_idle(max_cpu, max_sessions)
-
durable.http_get(url, headers) -
durable.http_post(url, body, headers) - Environment variable substitution (
$ENV.VAR)
-
durable.status(id)— query workflow status -
durable.cancel(id)— cancel workflow -
durable.batch(source, size)— batch processing - Execution history in
duro_history - Query interface for debugging
- Documentation
DATABASE_URL=postgres://user:pass@localhost:5432/mydb
DUROXIDE_WORKER_CONCURRENCY=10
DUROXIDE_ORCHESTRATION_CONCURRENCY=5
DUROXIDE_POLL_INTERVAL_MS=100
RUST_LOG=info,pg_durable=debugshared_preload_libraries = 'pg_durable' # optional[dependencies]
pgrx = "0.12"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
uuid = { version = "1.0", features = ["v4", "serde"] }[dependencies]
duroxide = "0.1"
duroxide-pg = "0.1"
tokio = { version = "1", features = ["full"] }
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "uuid", "json"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
uuid = { version = "1.0", features = ["v4", "serde"] }
anyhow = "1.0"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
clap = { version = "4", features = ["derive"] }- Workflows defined entirely in SQL
- Workflow state survives runtime restart
- Workflow state survives database restart (with WAL)
- Parallel execution works correctly (join, race)
- Nested loops and conditionals work
- Variable substitution works across nodes
- External triggers wake waiting workflows
- Status queries return accurate state