Skip to content

Latest commit

 

History

History
957 lines (696 loc) · 38.9 KB

File metadata and controls

957 lines (696 loc) · 38.9 KB

API Reference

Complete reference for all df.* functions with parameter types and auto-wrap behavior.

Auto-Wrap Explained

Auto-wrap means a plain SQL string is automatically converted to a df.sql() node.

-- These are equivalent when auto-wrap is supported:
df.seq('SELECT 1', 'SELECT 2')
df.seq(df.sql('SELECT 1'), df.sql('SELECT 2'))

Parameters marked with ✅ Auto-wrap accept either:

  • A plain SQL string (auto-wrapped to df.sql())
  • A Durofut node (from any df.* function)

Parameters marked with ❌ Literal expect a literal value (not auto-wrapped).


Node Functions

df.sql(query)

Creates a SQL execution node.

Parameter Type Auto-wrap Description
query TEXT ❌ Literal SQL query to execute
df.sql('SELECT * FROM users WHERE id = 1')

df.seq(a, b) / ~> operator

Executes two nodes in sequence.

Parameter Type Auto-wrap Description
a TEXT ✅ Auto-wrap First node to execute
b TEXT ✅ Auto-wrap Second node to execute
df.seq('SELECT 1', 'SELECT 2')
'SELECT 1' ~> 'SELECT 2'               -- operator form
df.sql('SELECT 1') ~> df.sleep(5)      -- mixed

df.as(fut, name) / |=> operator

Binds a result to a variable name.

Parameter Type Auto-wrap Description
fut TEXT ✅ Auto-wrap Node whose result to name
name TEXT ❌ Literal Variable name (no $ prefix)
df.as('SELECT id FROM users LIMIT 1', 'user_id')
'SELECT id FROM users LIMIT 1' |=> 'user_id'  -- operator form

Substitution patterns available on named results:

Pattern Behavior On no rows On NULL
$name First column of first row Error Error
$name.column Specific column of first row Error Error
$name? Null-safe scalar NULL NULL
$name.column? Null-safe column NULL NULL
$name.* Row-set expansion (inline VALUES) Empty relation N/A

df.join(a, b) / & operator

Executes nodes in parallel, waits for all to complete.

Parameter Type Auto-wrap Description
a TEXT ✅ Auto-wrap First parallel branch
b TEXT ✅ Auto-wrap Second parallel branch
df.join('SELECT count(*) FROM a', 'SELECT count(*) FROM b')
'SELECT 1' & 'SELECT 2'                -- operator form

df.join3(a, b, c)

Executes three nodes in parallel, waits for all.

Parameter Type Auto-wrap Description
a TEXT ✅ Auto-wrap First parallel branch
b TEXT ✅ Auto-wrap Second parallel branch
c TEXT ✅ Auto-wrap Third parallel branch
df.join3('SELECT 1', 'SELECT 2', 'SELECT 3')

df.race(a, b) / | operator

Executes nodes in parallel, first to complete wins.

Parameter Type Auto-wrap Description
a TEXT ✅ Auto-wrap First competing branch
b TEXT ✅ Auto-wrap Second competing branch
df.race(df.sleep(10), df.wait_for_signal('cancel'))
df.sleep(10) | df.wait_for_signal('cancel')  -- operator form

df.if(condition, then, else) / ?> !> operators

Conditional execution.

Parameter Type Auto-wrap Description
condition TEXT ✅ Auto-wrap Node that returns truthy/falsy
then TEXT ✅ Auto-wrap Execute if condition is truthy
else TEXT ✅ Auto-wrap Execute if condition is falsy
df.if('SELECT count(*) > 0 FROM q', 'SELECT ''yes''', 'SELECT ''no''')
'SELECT true' ?> 'SELECT ''yes''' !> 'SELECT ''no'''  -- operator form

df.if_rows(result_name, then, else)

Branches based on whether a named result has any rows. Unlike df.if(), no SQL query is executed — the check is done in-memory on the stored result.

Parameter Type Auto-wrap Description
result_name TEXT ❌ Literal Name of a previously stored result (no $ prefix)
then TEXT ✅ Auto-wrap Execute if result has rows
else TEXT ✅ Auto-wrap Execute if result has zero rows
df.if_rows('data', 'SELECT $data.id', 'SELECT ''no data''')

df.loop(body, condition DEFAULT NULL, continue_on_failure DEFAULT false)

Experimental: The continue_on_failure syntax is subject to change in future releases.

Repeats body forever or while condition is true. The supported call shapes are:

df.loop(body)
df.loop(body, condition)
df.loop(body, continue_on_failure => true)
df.loop(body, condition, continue_on_failure => true)
Parameter Type Auto-wrap Description
body TEXT ✅ Auto-wrap Node to repeat
condition TEXT ✅ Auto-wrap (Optional) Evaluate after a successful body; continue while truthy
continue_on_failure BOOLEAN ❌ Literal (Optional) Continue after a body activity failure; default false
-- Infinite loop
df.loop('SELECT process_item()' ~> df.sleep(1))

-- While loop
df.loop('SELECT process_item()', 'SELECT count(*) > 0 FROM queue')

-- Infinite loop that continues after body activity failures
df.loop(
    'SELECT process_item()' ~> df.sleep(1),
    continue_on_failure => true
)

-- Conditional loop that continues after body activity failures
df.loop(
    'SELECT process_item()',
    'SELECT count(*) > 0 FROM queue',
    continue_on_failure => true
)

By default, and when continue_on_failure is false, loop execution is fail-fast. When it is true, each body iteration runs in a child orchestration. After a successful body iteration, the child's results are merged into the parent result map before condition evaluation. A consumed typed application failure from a body activity skips condition evaluation and starts the next iteration. This includes any error returned by a body SQL, HTTP, or multipart activity, such as a query, authorization, connection, or network error. Condition failures, malformed graph or child data, unrecognized child errors, child-ID collisions, and orchestration/runtime failures remain fatal.

The @> operator remains an infinite, fail-fast loop:

@> ('SELECT process_item()' ~> df.sleep(1))

df.break([value])

Exits the enclosing loop.

Parameter Type Auto-wrap Description
value TEXT ❌ Literal (Optional) JSON value to return
df.break()                           -- exit with null
df.break('{"status": "done"}')       -- exit with value

Note: The value parameter is a literal JSON string, NOT auto-wrapped.


df.sleep(seconds)

Pauses execution for N seconds.

Parameter Type Auto-wrap Description
seconds INTEGER ❌ Literal Duration in seconds
df.sleep(60)

df.wait_for_schedule(cron_expr)

Waits until cron expression matches.

Parameter Type Auto-wrap Description
cron_expr TEXT ❌ Literal 5-part cron expression
df.wait_for_schedule('*/5 * * * *')   -- every 5 minutes
df.wait_for_schedule('0 9 * * 1-5')   -- weekdays at 9am

df.wait_for_signal(name [, timeout])

Waits for an external signal.

Parameter Type Auto-wrap Description
name TEXT ❌ Literal Signal name to wait for
timeout INTEGER ❌ Literal (Optional) Timeout in seconds
df.wait_for_signal('approval')         -- wait forever
df.wait_for_signal('approval', 3600)   -- 1 hour timeout

df.http(url [, method, body, headers, timeout])

Makes an HTTP request.

In restricted builds, the destination must be permitted by pg_durable.http_allowed_domains. The same policy applies to df.http_multipart().

Parameter Type Auto-wrap Description
url TEXT or df.http_endpoint ❌ Literal Request URL or df.endpoint(...) value (path supports workflow substitution)
method TEXT ❌ Literal HTTP method (default: POST)
body TEXT ❌ Literal Request body JSON (supports $var)
headers JSONB ❌ Literal Request headers
timeout INTEGER ❌ Literal Timeout in seconds (default: 30)
df.http('https://api.example.com/users', 'GET')
df.http('https://api.example.com', 'POST', '{"key": "$value"}')
df.http(url, 'GET', NULL, '{"Auth": "Bearer token"}'::jsonb, 60)

Returns a JSON envelope: status, body, encoding, headers, ok, duration_ms. Address its fields directly with dot notation:

df.http('https://api.example.com/thing', 'GET') |=> 'resp'
~> df.if('SELECT $resp.ok', 'SELECT ''hooray''', 'SELECT ''oh no''')

encoding is text when the response Content-Type is textual, and base64 when it is not — in which case body holds the base64 of the raw bytes. See df.http_multipart for feeding those bytes into a subsequent upload.


df.endpoint(server, path)

Returns a df.http_endpoint composite value with server TEXT and path TEXT fields for df.http or df.http_multipart. Both arguments are required. Construction reads no endpoint catalogs or credentials. Pass the typed value directly to an HTTP constructor; TEXT arguments are URLs and are never interpreted as endpoint references. No implicit TEXT-to-endpoint conversion is installed.

df.http(df.endpoint('partner_api', '/v1/items'), 'GET')
df.http_multipart(df.endpoint('partner_api', '/upload'),
                  parts => '[{"name":"file","data_b64":"aGVsbG8="}]'::jsonb)

Manage HTTP access with df.grant_usage(..., include_http => true) and df.revoke_usage(...); the helpers cover URL and endpoint requests together.

The server name is fixed; the path supports existing workflow substitutions and is appended to the base URL's path prefix. Invalid path shapes, traversal, routing overrides and credential overrides fail explicitly. Resolution uses the submitting role in the control database where pg_durable is installed and re-checks server USAGE in addition to the corresponding HTTP function grant. The workflow's SQL target does not select the credential catalog. Each request resolves its endpoint and named bindings from one consistent catalog snapshot, using a shared user-connection slot that is released before HTTP I/O. See Calling an Endpoint.

Endpoint Credential Catalog

pg_durable_fdw stores endpoint configuration in native foreign servers and per-role credentials in user mappings. It has no handler and does not support foreign tables. df.endpoint_option_validator(options text[], catalog oid) is the FDW validator invoked by PostgreSQL on creation and alteration; it returns void or raises an error without echoing credential values.

The server options are base_url, auth_scheme, and header_name (only for header authentication). auth_scheme is required; base_url may be omitted only with auth_scheme 'none' for named-secret storage. A supplied URL retains all validation requirements, and endpoint requests fail explicitly if it is absent. Mapping options are token, header_value, query_string, and individual "secret.<key>" values. Named credentials support native per-option ADD, SET and DROP. See Endpoint Credential Catalog for option combinations, grants, rotation and backup implications.

df.http_multipart(url [, method, parts, headers, timeout])

Makes an HTTP request with a multipart/form-data body. Requires the same include_http => true grant as df.http().

Parameter Type Auto-wrap Description
url TEXT or df.http_endpoint ❌ Literal Request URL or df.endpoint(...) value (path supports workflow substitution)
method TEXT ❌ Literal HTTP method (default: POST)
parts JSONB ❌ Literal Array of part objects (see below)
headers JSONB ❌ Literal Request headers
timeout INTEGER ❌ Literal Timeout in seconds (default: 30)

Each element of parts is an object:

Key Required Description
name Form field name (supports $var)
data_b64 Part content, base64-encoded
filename Sets the part's filename, making it a file upload (supports $var)
content_type Part Content-Type

data_b64 accepts base64 with embedded whitespace, so PostgreSQL's encode(bytea, 'base64') output — which wraps at 76 columns — can be used directly.

df.http_multipart(
    'https://api.example.com/upload', 'POST',
    jsonb_build_array(
        jsonb_build_object(
            'name', 'file', 'filename', 'report.pdf',
            'content_type', 'application/pdf',
            'data_b64', encode(pg_read_binary_file('report.pdf'), 'base64'))),
    '{"Authorization": "Bearer token"}'::jsonb, 60)

data_b64 substitution is whole-value only. A reference must be the entire value:

'data_b64', '$speech.body'     -- ✅ substituted
'data_b64', 'prefix$speech'    -- ❌ error: mixes a reference with other text

This is deliberate. Splicing a variable into the middle of a base64 string cannot produce valid base64, so pg_durable reports it as a node failure rather than sending a corrupt part. Other fields (url, name, filename, headers) interpolate normally.

Returns the same envelope as df.http().


df.secret(server, key)

Returns JSONB {"server":"...","key":"..."} without reading credentials. Both arguments are required, nonempty and cannot contain control characters. key selects "secret.<key>" in the submitting role's user mapping. Keys are case-sensitive and cannot contain =; values are opaque text, including empty strings. Endpoint-authentication options are not searched as a fallback. Only explicit binding slots interpret this descriptor; ordinary values never do. Header slots may add a literal prefix; unknown descriptor fields are rejected.

df.secret('partner_api', 'api_key')
df.secret('partner_api', 'token') || '{"prefix":"Bearer "}'::jsonb

See Explicit Secret Bindings.

df.with_http_options(fut, options)

Added in 0.2.9.

HTTP-specific modifier entry point. Returns the JSON-encoded TEXT node for use in a workflow, not an HTTP response. Neither existing HTTP function changes signature.

Parameter Type Auto-wrap Description
fut TEXT ❌ Literal A single HTTP or HTTP_MULTIPART node, optionally named with |=>
options JSONB ❌ Literal Object containing secret_bindings and/or form_fields; SQL NULL and {} are no-ops
df.with_http_options(df.http('https://api.github.com/', 'GET'), '{}'::jsonb)
  |=> 'response'

secret_bindings contains named headers, query and form reference maps. form_fields contains literal form strings. A supplied option replaces the entire previous option; omitted options remain intact. See Explicit Secret Bindings for shapes, encoding and conflict rules. Unknown keys, non-object JSON values (including JSON null), malformed nodes, SQL nodes, and compound graphs raise an error. SQL NULL and {} return the original node text byte-for-byte, preserving its config and result name. Apply the helper to each HTTP node before combining nodes. It neither resolves secrets nor grants HTTP access; activity-time permission and network checks still apply. Existing installations must update the extension schema to 0.2.9 or later to use this helper, but not to keep using the original HTTP functions. Replacing the binary alone does not add the helper.


Control Functions

df.start(fut [, label] [, database] [, transaction_mode])

Starts a durable function.

Parameter Type Auto-wrap Description
fut TEXT ✅ Auto-wrap Root node of the function
label TEXT ❌ Literal (Optional) Human-readable label
database TEXT ❌ Literal (Optional) Target database on the cluster
transaction_mode TEXT ❌ Literal (Optional) 'caller' (default) or 'new'
df.start('SELECT 1')                      -- auto-wrapped
df.start(df.sleep(10) ~> 'SELECT 2')      -- explicit nodes
df.start('SELECT 1', 'my-job')            -- with label

transaction_mode

Selects which transaction the start itself runs in. It changes nothing about the durable function that gets started.

  • 'caller' (default) — the start joins the caller's transaction, so a ROLLBACK discards the durable function along with everything else.
  • 'new' — the start runs in its own transaction on a separate PostgreSQL session, so it commits independently and survives a rollback of the caller's transaction. This provides the same rollback-survival outcome as an Oracle autonomous transaction for asynchronously started work. It is not a synchronous autonomous routine: the returned ID confirms the launch, while completion and execution errors are observed through monitoring APIs.
BEGIN;
  INSERT INTO employees (id, name) VALUES (999, 'Test User');
  SELECT df.start('INSERT INTO audit_log (message) VALUES (''x'')', 'audit',
                  transaction_mode => 'new');
ROLLBACK;  -- the employees insert is undone; the durable function still runs

An unrecognised value raises an error rather than falling back to the default.

Note: under 'new' the separate session sees only committed rows, so the captured df.vars snapshot excludes variables set earlier in the caller's open transaction. Each admitted call also opens an extra backend connection, capped cluster-wide by pg_durable.max_new_transaction_starts (default 2); extra callers wait up to pg_durable.new_transaction_start_timeout seconds (default 5) before failing without opening the loopback session. The inner df.start() statement itself is still bounded by a 30 s lock_timeout/statement_timeout. 'new' is rejected inside a workflow, where a plain df.start() is already independent of any caller transaction. Avoid per-row triggers and other high-fan-out call sites unless you have validated them against that admission cap, and make target operations idempotent because a connection failure can make launch outcome uncertain. See the Transaction Semantics section of USER_GUIDE.md for details.


df.signal(instance_id, signal_name [, signal_data])

Sends a signal to a running instance.

Parameter Type Auto-wrap Description
instance_id TEXT ❌ Literal Target instance ID
signal_name TEXT ❌ Literal Signal name
signal_data TEXT ❌ Literal Optional signal payload text (default: '{}'). Valid JSON is preserved; other text is sent as a JSON string.
df.signal('a1b2c3d4', 'approval', '{"approved": true}')

df.cancel(instance_id [, reason])

Cancels a running instance.

Parameter Type Auto-wrap Description
instance_id TEXT ❌ Literal Target instance ID
reason TEXT ❌ Literal Cancellation reason
df.cancel('a1b2c3d4', 'Manual stop')

df.status(instance_id)

Gets instance status.

Note: the argument is an instance_id (returned by df.start()), not a label. Passing a label returns NULL, since no instance has that ID. To check a labeled run, resolve the label to an instance_id first (see example below).

Parameter Type Auto-wrap Description
instance_id TEXT ❌ Literal Target instance ID from df.start() (not a label)
-- By instance_id. Returns a lowercase status:
-- 'pending', 'running', 'completed', 'failed', or 'cancelled'.
SELECT df.status('a1b2c3d4');

-- Have a label instead of an instance_id? Resolve it first:
SELECT df.status(instance_id)
FROM df.list_instances()
WHERE label = 'my-job';

If you reuse a label across runs, multiple instances can match — pass the specific instance_id you want.


df.list_instances(...)

Lists your durable function instances, newest-first. Results are RLS-scoped to your own instances (superusers see all). The function comes in two overloads, distinguished by argument count:

Overload Call shape Returned columns
Basic (0–2 args) df.list_instances([status_filter[, limit_count]]) 6 columns (no timestamps or cursor)
Paginated (3–4 args) df.list_instances(status_filter, limit_count, label_filter[, after_cursor]) 9 columns (adds created_at, completed_at, next_cursor)

The two overloads have non-overlapping arities (basic matches 0–2 arguments, paginated matches 3–4), so a call is never ambiguous. To reach the paginated overload you must pass at least the first three arguments — use NULL for any you don't want to filter on (e.g. df.list_instances(NULL, 100, NULL)).

Parameter Type Default Description
status_filter TEXT NULL (basic only) Only instances with this status (lowercase: pending, running, completed, failed, cancelled). NULL = any.
limit_count INTEGER 100 (basic only) Max rows per page (must be ≥ 1). A request above pg_durable.list_instances_max_limit (default 1000) raises an error instead of being silently truncated — lower limit_count, or use the paginated overload (after_cursor) for larger result sets.
label_filter TEXT — (required to select the paginated overload) Only instances whose label equals this value (issue #87). NULL = any.
after_cursor TEXT NULL Opaque keyset cursor from a prior page's next_cursor; returns the page that sorts strictly after it (issue #146). NULL = first page.

status_filter and limit_count default only in the basic overload. The paginated overload requires all three of status_filter, limit_count, and label_filter to be supplied positionally (pass NULL to skip a filter); only after_cursor is optional.

Basic overload columns: instance_id, label, function_name, status, execution_count, output.

Paginated overload columns: the six above plus created_at, completed_at, next_cursor.

  • created_at / completed_at are the submit and completion timestamps from df.instances. completed_at is NULL until the instance reaches completed (it stays NULL for failed/cancelled).
  • Rows are ordered created_at DESC, id ASC (deterministic, served by the (created_at DESC, id) indexes on df.instances).
  • next_cursor is the token to fetch the page after this one. It is the same value on every row of a page and NULL on the final page.
-- Basic overload: most recent 50 completed runs (6 columns, no timestamps/cursor)
SELECT instance_id, status FROM df.list_instances('completed', 50);

-- Paginated overload: all instances carrying a given label (9 columns)
SELECT instance_id, status, created_at, completed_at, next_cursor
FROM df.list_instances(NULL, 100, 'nightly-report');

-- Keyset pagination: pass the previous page's next_cursor back in as after_cursor
SELECT * FROM df.list_instances(NULL, 50, NULL, '323032362d...');

Pagination note: next_cursor is computed over df.instances (the authoritative, RLS-filtered set) independently of the per-row execution-metadata lookup, so it normally advances correctly. In a brief start-up window an instance can exist in df.instances before its execution metadata is queryable; such a row is omitted from the current page. Edge case: if every row of a non-final page is omitted this way, that page returns zero rows and you cannot read next_cursor (it is carried on each row) — retry shortly. A malformed after_cursor raises an error; always pass a next_cursor value back verbatim.


df.result(instance_id)

Gets instance result (for completed instances).

Parameter Type Auto-wrap Description
instance_id TEXT ❌ Literal Target instance ID
SELECT df.result('a1b2c3d4');

df.instance_nodes(instance_id)

Returns one row per node in an instance's graph, with each node's stored physical status alongside a read-time derived status. This is the primary tool for inspecting where an instance is and why a branch did or did not run.

Parameter Type Auto-wrap Description
instance_id TEXT ❌ Literal Target instance ID

Return columns:

Column Type Description
node_id TEXT Node id (unique within the instance)
node_type TEXT SQL, THEN, IF, JOIN, RACE, LOOP, SLEEP, SIGNAL, HTTP, …
query TEXT SQL text for SQL nodes; a JSON config for compound/leaf nodes
result_name TEXT Capture name (|=>), or NULL
left_node TEXT First child node id, or NULL
right_node TEXT Second child node id, or NULL
status TEXT Physical stored status: pending, running, completed, failed
result TEXT JSON text payload for completed/failed nodes, else NULL
status_details TEXT JSON text metadata (see below), or NULL if never transitioned
inferred_status TEXT Derived status: physical status plus skipped, and loop re-entry surfaced as pending
inferred_status_from_ancestor_id TEXT Ancestor node id that drove a derived skipped/pending, or NULL
updated_at TIMESTAMPTZ Last physical status change

result and status_details are returned as TEXT for compatibility. Cast to jsonb when you need JSON operators (for example, status_details::jsonb->>'execution_id').

status_details JSON contract. Written by the worker through the update-node-status activity and stored verbatim in df.nodes.status_details:

  • execution_id — the node's full execution stamp, {instance_path}::{generation}, e.g. a1b2c3d4::1::7f9a0012::2. The last ::-token is the generation (continue-as-new count) of the orchestration that transitioned the node, and the preceding {instance_path} is that orchestration's instance id. instance_path encodes sub-orchestration lineage: it starts with the root function instance id and appends a ::{parent_generation}::{branch_or_loop_node_id} segment for each nested JOIN/RACE branch and each non-root df.loop() (which runs as its own child sub-orchestration). Instance ids and node ids are 8-char hex and never contain ::, so the path is unambiguous. Supersession is evaluated per scope: a node is superseded when a newer generation exists for its own instance_path, or when any ancestor scope in its path has advanced to a newer generation. For a plain root-level loop this reduces to the second ::-token being the loop generation.

inferred_status and inferred_status_from_ancestor_id are computed at read time and are not stored in df.nodes.status_details.

Derived statuses. skipped is never written to df.nodes.status (it is not a member of the nodes_status_chk constraint) — it exists only in inferred_status:

  • skipped — a non-terminal node whose nearest terminal ancestor already decided the branch will not run: the untaken arm of a completed df.if(), the right side of a failed df.then()/~>, or the abandoned (still-running) loser of a resolved df.race(). A loser that already reached completed/failed keeps its physical status.
  • pending (derived) — a node from an older loop generation that a newer ancestor generation has superseded; it will re-run, so it reads back as pending rather than showing the previous iteration's terminal status.

df.explain() renders the same derived status for each node, so the two views always agree.

SELECT node_id, node_type, status AS physical, inferred_status,
  status_details::jsonb->>'execution_id' AS execution_id
FROM df.instance_nodes('a1b2c3d4')
ORDER BY node_id;

Variable Functions

df.setvar(name, value)

Sets a workflow variable for the current user (before df.start()). Each user has their own variable namespace — variables set by one user are invisible to others. df.setvar is a setup helper, not a workflow node: do not use it inside df.seq, df.join, df.race, etc.

Parameter Type Auto-wrap Description
name TEXT ❌ Literal Variable name
value TEXT ❌ Literal Variable value
SELECT df.setvar('api_url', 'https://api.example.com');

Not for credentials. Values are stored as plaintext in df.vars, and df.start() copies every variable you own into durable execution history. Using {varname} keeps the value out of df.nodes.query but not out of history. See Variables and secrets.


df.getvar(name)

Gets a workflow variable owned by the current user.

Parameter Type Auto-wrap Description
name TEXT ❌ Literal Variable name
SELECT df.getvar('api_url');

df.unsetvar(name)

Removes a workflow variable owned by the current user. df.unsetvar is a setup helper, not a workflow node.

Parameter Type Auto-wrap Description
name TEXT ❌ Literal Variable name
SELECT df.unsetvar('api_url');

df.clearvars()

Clears all workflow variables owned by the current user. df.clearvars is a setup helper, not a workflow node.

SELECT df.clearvars();

Quick Reference: Auto-Wrap Summary

Function Parameters with Auto-Wrap
df.seq(a, b) a, b
df.as(fut, name) fut
df.join(a, b) a, b
df.join3(a, b, c) a, b, c
df.race(a, b) a, b
df.if(cond, then, else) cond, then, else
df.loop(body, condition, continue_on_failure) body, condition
df.start(fut, label) fut
All others No auto-wrap (literals only)

Rule of thumb: If a parameter expects a "node" (something that executes), it supports auto-wrap. If it expects a configuration value (name, URL, timeout), it's a literal.


Administration Functions

df.grant_usage(role_name [, include_http] [, with_grant])

Grants the privileges a role needs to use pg_durable. By default this grants general df usage but does not grant EXECUTE on df.http(). Pass include_http => true to opt a role into HTTP access. Pass with_grant => true to allow the role to delegate access to others.

Authorization is enforced by PostgreSQL’s native mechanisms: EXECUTE on this function is revoked from PUBLIC (so only roles explicitly granted access can call it), and the inner GRANT statements run as the caller via SECURITY INVOKER, so the caller must hold the underlying privileges WITH GRANT OPTION.

Parameter Type Description
role_name TEXT The role to grant privileges to
include_http BOOLEAN Optional, defaults to false; when true, also grants EXECUTE on df.http(text, text, text, jsonb, integer)
with_grant BOOLEAN Optional, defaults to false; when true, grants all privileges WITH GRANT OPTION and retains EXECUTE on df.grant_usage / df.revoke_usage
SELECT df.grant_usage('app_role');
SELECT df.grant_usage('app_role', include_http => true);
SELECT df.grant_usage('admin_role', with_grant => true);

df.revoke_usage(role_name)

Revokes all privileges previously granted by df.grant_usage(), including any df.http() access. Authorization is enforced the same way as df.grant_usage() — EXECUTE is revoked from PUBLIC, and the inner REVOKE statements run as the caller. On upgraded installs, revoking df.http() from PUBLIC is still a separate manual step.

Parameter Type Description
role_name TEXT The role to revoke privileges from
SELECT df.revoke_usage('app_role');

Server Configuration (GUCs)

These settings are configured via ALTER SYSTEM SET or postgresql.conf. See each setting for reload or restart requirements.


pg_durable.http_allowed_domains

The complete destination allow-list for df.http() and df.http_multipart() in restricted builds. Available since v0.2.9.

Property Value
Type string
Default Azure subdomain patterns and api.github.com with http-allow-azure-domains; also httpbingo.org with http-allow-test-domains; empty otherwise
Context POSTMASTER (requires a PostgreSQL restart, not just a reload)
Visibility All users can read the active value
# postgresql.conf
pg_durable.http_allowed_domains = 'api.github.com, *.blob.core.windows.net'

Entries are comma-separated exact hostnames or *.domain patterns, with optional surrounding whitespace. A pattern permits subdomains at any depth, not the apex itself. Matching is case-insensitive and uses IDNA/Punycode normalization. Use UTF-8 internationalized names or ASCII/Punycode.

An explicit value replaces all defaults, including test domains. An empty or whitespace-only value denies all domains in restricted builds. Malformed entries reject the whole setting; a malformed startup value prevents server startup. URLs, ports, IPs, CIDRs, percent escapes, trailing dots, standalone *, and empty entries within a nonempty list are not accepted.

Session, role, and database settings cannot override this policy. An authorized ALTER SYSTEM SET can change the startup configuration, but PostgreSQL must restart before requests use it.

The GUC does not override Cargo feature gates: HTTP remains disabled in builds without an HTTP feature, and http-allow-all bypasses the list even when it is empty. Other HTTP safeguards are unchanged. See HTTP security for the default domains and execution-time behavior.


pg_durable.enable_superuser_instances

Controls whether pg_durable allows durable function instances whose submitted_by role is a PostgreSQL superuser.

Property Value
Type boolean
Default off
Context SUSET (superuser can change at runtime; no restart needed)
Visibility Hidden from SHOW ALL and pg_settings for non-superusers

When off (default):

  • df.start() raises an error immediately if current_user is a superuser.
  • The background worker rejects any instance whose submitted_by resolves to a superuser at execution time, even if the row was tampered with after submission.

When on:

  • Superusers may submit durable functions. Their SQL nodes execute with superuser privileges.
  • Intended for administrative tasks in single-tenant or fully-trusted deployments.
-- Enable (requires superuser)
ALTER SYSTEM SET pg_durable.enable_superuser_instances = on;
SELECT pg_reload_conf();

-- Disable (default; recommended for multi-tenant)
ALTER SYSTEM SET pg_durable.enable_superuser_instances = off;
SELECT pg_reload_conf();

-- Check current value (superuser only)
SHOW pg_durable.enable_superuser_instances;

Security note: Setting this GUC to on in a multi-tenant environment allows any role with BYPASSRLS to forge submitted_by to a superuser OID and execute arbitrary SQL as superuser. Keep off unless you have a specific need and understand the risk. See docs/superuser_guc.md for the full threat analysis.


pg_durable.list_instances_max_limit

Maximum number of rows df.list_instances() returns in a single call. A request for more rows than this raises an error instead of silently truncating the result, so external clients paginate explicitly (via after_cursor/next_cursor) rather than relying on a silent cap.

Property Value
Type integer
Default 1000
Range 11000000
Context SUSET (superuser can change at runtime; no restart needed)

Both df.list_instances() overloads (basic and paginated) enforce this cap. By default an ordinary (non-superuser) caller cannot raise it, so the guardrail holds from a user session — a superuser may delegate that ability with GRANT SET ON PARAMETER pg_durable.list_instances_max_limit TO <role>, but without that grant it stays superuser-settable only.

Sizing note: df.list_instances() materializes up to limit_count rows per call, so raise the cap only as high as a single response should reasonably hold. For very large exports, prefer paging with after_cursor/next_cursor over one huge page rather than setting the cap near its maximum.

-- Inspect the current cap
SHOW pg_durable.list_instances_max_limit;

-- Raise it for an admin reporting workload (requires superuser)
ALTER SYSTEM SET pg_durable.list_instances_max_limit = 5000;
SELECT pg_reload_conf();

Behavior change (v0.2.4): prior to v0.2.4, df.list_instances() silently truncated limit_count to 10000. It now raises an error when limit_count exceeds this GUC (default 1000). Callers that previously requested very large pages should lower limit_count or use the paginated overload (after_cursor/next_cursor).


pg_durable.log_workflow_sql

Controls whether the background worker writes the SQL text of an executed workflow node to the PostgreSQL server log.

Property Value
Type boolean
Default on
Context POSTMASTER (set in postgresql.conf; requires restart)

The SQL is logged after variable substitution, so credentials substituted into a query reach the server log in cleartext. SQL cannot be reliably redacted after substitution, so this is an on/off switch.

# postgresql.conf
pg_durable.log_workflow_sql = off

Turning it off keeps the submitting role and any explicit target database in the worker trace but drops the statement text. It does not suppress workflow results or error messages, which can also contain sensitive data.

PostgreSQL's own statement logging is independent: settings such as log_statement and log_min_duration_statement can still log executed SQL when this GUC is off. Keep credentials out of SQL even with this setting disabled. See What reaches the server log and Variables and secrets.