Contents
Changelog
All notable changes to this project are documented in this file. The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
Pre-1.0 note: while pg_durable is in major version 0, minor releases may include breaking changes.
[0.2.8] - 2026-09-11
Added
- Failure-isolated loops (#377): the unified
df.loop(body, condition DEFAULT NULL, continue_on_failure DEFAULT false)signature supports resilient infinite and conditional loops. Withcontinue_on_failure => true, a consumed typed body activity failure skips the condition and starts the next iteration; after a successful body, the condition is evaluated normally. All errors returned by body SQL, HTTP, and multipart activities are consumable, including query, authorization, connection, and network errors. Condition, graph, protocol, unknown child, and orchestration/runtime failures remain fatal. Thecontinue_on_failuresyntax is experimental and may change in future releases. - Workflow SQL logging control (#378): the new
pg_durable.log_workflow_sqlpostmaster setting controls whether the background worker writes fully substituted workflow SQL to PostgreSQL logs. It defaults toon; set it tooffand restart PostgreSQL to omit statement text from worker traces.
Changed
- Loop lifetime (#377): raises the loop-iteration backstop from 100,000 to
8,388,608 (
2^23), approximately 80 years at five-minute ticks.
Upgrade warning (#377): A loop that already recorded the old terminal-failure path at iteration 100,000 cannot replay under 0.2.8. Drain such long-running loops before upgrading when continuity is required; see Upgrade Testing.
Fixed
- Caller-transaction handoff (#367):
df.start()now tracks the originating transaction until it commits or aborts, so legal caller transactions lasting more than five seconds no longer leave apendingdf.instancesrow paired with a failed engine execution. Graph admission uses durable backoff and bounded-history compaction rather than holding a worker connection while it waits. - PGXN source installation (#370): source builds now initialize cargo-pgrx
from the supplied
PG_CONFIGwhen its configuration is missing, while preserving existing cargo-pgrx configurations.
Security
- Secrets handling (#378): URL user information, query parameter values,
fragments, and URL-bearing HTTP client errors are redacted before reaching
PostgreSQL logs, node errors, or durable execution history. Documentation now
also describes the exposure model for
df.vars.
Documentation
- PGXN installation and search (#373): documented the PGXN source-install and uninstall procedure and excluded internal development documents from PGXN search indexing without removing them from release archives.
[0.2.7] - 2026-08-31
Added
pg_durable.host(#360): a postmaster GUC that selects the PostgreSQL host used by every connection pg_durable creates. When empty or unset,PGHOSTis used, falling back to127.0.0.1.
Changed
- Dependencies: bumped
uuidto 1.26.0 (#355, #366),postgres-protocolto 0.6.12 (#357), andtokio-postgresto 0.7.18 (#358).
Fixed
- Worker connection role names (#364): catalog role names are now passed verbatim when opening workflow connections, preventing quote-wrapped names from being reinterpreted as a different role.
- Restricted HTTP transport (#342, #363): restricted allow-list builds now require HTTPS so credentials and request bodies cannot be sent over plaintext HTTP; development-only
http-allow-allbuilds continue to permit HTTP.
Security
- HTTP allow-list URL parsing (#365): request URLs are now parsed once and the same canonical URL is used for validation and transport, preventing parser differences from approving a different host than the request targets.
[0.2.6] - 2026-08-23
Added
- PGXN source distribution (#344): added PGXN metadata and a reproducible source archive for installing
pg_durablethrough PGXN.
Fixed
- Variable substitution determinism (#320): replacements are now resolved once from left to right, so placeholder-like text inside a variable value is not rescanned and cannot produce replay-dependent results.
- Worker shutdown (#321, #326): graceful PostgreSQL stops no longer hang on background-worker pool cleanup or pay the runtime’s fixed no-drain delay.
- Reconciler child classification (#323): named sub-orchestrations are no longer mistaken for root instances, and failed children can no longer consume the reclaim batch limit and starve orphan cleanup.
- Deep workflow composition (#331, #328): deeply nested graphs no longer hit
serde_json’s recursion limit, and malformed workflow envelopes now raise a clean error instead of silently becoming SQL nodes or panicking indf.explain(). - Extension recreation race (#336): the worker now detects when
DROP EXTENSION/CREATE EXTENSIONreplaces the extension while the runtime is initializing, preventing a stale runtime from being marked ready. - Source installation (#345, #349):
make installnow builds before privilege escalation, supports conventionalpg_configdiscovery andDESTDIR, and works on macOS without relying on GNU-onlyreadlink -f.
Changed
- Independent-start admission control (#322): concurrent
df.start(..., transaction_mode => 'new')launches are cluster-wide limited to two by default. Configure the limit and wait time withpg_durable.max_new_transaction_startsandpg_durable.new_transaction_start_timeout; excess launches fail without leaving partial work. - Workflow graph materialization (#334, #335, #337): nested graph configuration is represented as first-class children, flattened iteratively, and inserted in bounded batches. This removes parser-depth and recursive-traversal limits while reducing SPI round trips. The transient
DurofutJSON envelope changes; persisteddf.nodesrows and in-flight instances are unaffected. - PostgreSQL connection visibility (#347): worker, workflow, monitoring, and internal backend connections now use stable
application_namevalues for operational inspection. - HTTP allowlist preset (#341):
http-allow-azure-domainsnow permitsapi.github.com, which Azure services use for supported GitHub integrations.
Removed
df.ensure_durofut(text)(#332): removed this undocumented internal helper after conditional operators moved todf.if()normalization. The upgrade replaces built-in operator dependencies before dropping the helper withRESTRICT; change or remove customer-owned dependent objects before upgrading.
[0.2.5] - 2026-07-30
Added
df.http_multipart()(#302): sendmultipart/form-datarequests, gated by the sameinclude_http => truegrant asdf.http().- Binary HTTP response bodies:
df.http()anddf.http_multipart()preserve non-text responses as base64 and identify the body format in the response envelope’sencodingfield. - Audio round-trip example: demonstrates piping Azure OpenAI text-to-speech output directly into a Whisper transcription upload.
- Independent starts (#285):
df.start(..., transaction_mode => 'new')commits the start on a separate session, allowing asynchronously started work to survive a caller rollback. The default'caller'mode retains the existing transaction behavior.
Fixed
- Cancellation/failure history event identifiers (#169): terminal
OrchestrationFailedevents now carry the correctinstance_id/execution_idin their history payload. Fixed upstream in duroxide (microsoft/duroxide#35) via theduroxide0.1.30 bump (#305). - Unix-socket worker connections (#292): workers can now connect when
PGHOSTnames a Unix-socket directory.
Changed
Loop execution (#228): root and nested loops now share consistent execution, restart, and
df.break()behavior.Upgrade warning: in-flight JOIN/RACE branches will fail on replay after upgrading, and in-flight loops may fail. Drain in-flight work before upgrading when continuity is required; see
docs/upgrade-testing.mdfor details.HTTP result substitution: HTTP response fields such as
$resp.body,$resp.status,$resp.ok, and$resp.encodingare now directly addressable with dot notation.- Dependencies: bumped
duroxideto 0.1.30 (#305),uuidto 1.24.0 (#293),serde_jsonto 1.0.151, andtokioto 1.53.1 (#298).
Documentation
- Documented how to detect signal, schedule, and timer waits with
df.instance_nodes()(#300), and corrected the documentedresultandstatus_detailscolumn types and JSON-cast examples (#301).
[0.2.4] - 2026-07-02
Provider-line note: v0.2.4 stays in the duroxide-pg provider compatibility line, so the upgrade source is v0.2.3 (sql/pg_durable--0.2.3--0.2.4.sql).
Added
- Instance retention/pruning (#265): terminal instances are now pruned by a hard cap and a retention window, bounding unbounded growth of
df.instances. df.list_instances()pagination & filtering (#278): added alabel_filterand a paginated overload with keyset pagination (after_cursor/next_cursor) that also returnscreated_at/completed_attimestamps and anext_cursorcolumn.
Changed
df.wait_for_schedule()cron timing: the next cron tick is now computed at execution time using duroxide’s deterministic clock (ctx.utc_now()) inside theexecute_function_graphorchestration, instead of being pre-computed atdf.start()time. This makes recurring@>schedules and any start-to-execution delay target the correct upcoming tick (#130).⚠️ Replay-breaking for in-flight
wait_for_scheduleinstances. This change adds a recordedutc_now()decision before the WAIT_SCHEDULE timer, altering the orchestration’s history sequence. Any durable function that was started under a<= 0.2.3binary and is mid-wait_for_schedule(parked on its timer) when this.sois loaded will fail with a duroxide nondeterminism error on replay, because its recorded history no longer matches the new code. Drain or allow such in-flightwait_for_scheduleinstances to complete before upgrading. Instances that are not currently inside await_for_schedulenode are unaffected. We accepted this break (rather than introducing orchestration versioning) given the early pre-1.0 stage of the project.Instance/node ID collision hardening (#129):
df.start()now reserves IDs withINSERT ... ON CONFLICT DO NOTHING RETURNING idand re-rolls the random 8-hex value on collision — instances arbitrate on thedf.instancesprimary key (id), nodes on the new compositePRIMARY KEY (instance_id, id)— replacing the previousSELECT EXISTSpre-check. Doing the conflict check at the index level (rather than a pre-checkSELECT) closes a TOCTOU window and, for instances, an RLS blind spot where the pre-check could not see another role’s rows.df.nodesnow uses the compositePRIMARY KEY (instance_id, id)instead of a global single-column key, so the random 8-hex node ID is no longer the sole cross-instance collision guard. Theupdate-node-statusactivity now scopes itsdf.nodesupdate byinstance_id(a required activity-input field) and asserts it affects exactly one row. IDs stayVARCHAR(8)HEX; the0.2.3 → 0.2.4upgrade restructures thedf.nodeskeys in place (#238).- Breaking for in-flight work: the new activity-input shape changes the string duroxide records in orchestration history, and duroxide validates activity inputs by exact equality on replay, so any instance left in flight across the 0.2.3 → 0.2.4 binary upgrade cannot complete. Drain or cancel in-flight instances before deploying 0.2.4. The in-place
df.nodeskey restructure also takes anACCESS EXCLUSIVElock whose duration scales with table size — run the upgrade in a maintenance window. See the #129 section ofdocs/upgrade-testing.mdfor the full drain-before-upgrade contract.
- Breaking for in-flight work: the new activity-input shape changes the string duroxide records in orchestration history, and duroxide validates activity inputs by exact equality on replay, so any instance left in flight across the 0.2.3 → 0.2.4 binary upgrade cannot complete. Drain or cancel in-flight instances before deploying 0.2.4. The in-place
df.grant_usage()/df.revoke_usage(): dropped the explicit per-functionEXECUTEallowlist. SchemaUSAGEondfis the real access gate for ordinarydf.*functions, so the helpers now grant/revoke schemaUSAGE, the table privileges, andEXECUTEonly on the sensitive functions (df.http,df.grant_usage,df.revoke_usage). Function signatures are unchanged and existing privileges are unaffected (#242).df.list_instances()page-size cap is now a loud error (#146):df.list_instances()previously truncatedlimit_countsilently to a fixed ceiling of 10000. It now raises an error whenlimit_countexceeds the newpg_durable.list_instances_max_limitGUC (SUSETcontext, default1000, range1–1000000), so an over-cap request fails fast instead of returning a silently short page that is indistinguishable from “no more rows”. Both the basic and paginated overloads enforce the cap; clients needing more rows should lowerlimit_countor use the paginated overload (after_cursor/next_cursor). A superuser can change the cap at runtime without a restart; by default an ordinary caller cannot.- Renamed
df.wait_for_completion()(#164): the function was renamed and hardened against unsafe use. Breaking: callers of the old name must update to the new name. - Node statuses derived from execution lineage (#263, #283): node status is now derived from the durable engine’s execution lineage, reconciling the
dfcontrol-plane with the engine so reported statuses match actual execution. df.start()fails fast on engine hand-off failure (#282): if the hand-off to the durable engine fails,df.start()now returns an error immediately instead of leaving a stuck instance behind.- Dependencies: bumped
reqwestto 0.13.4 (#260) anduuidto 1.23.4 (#273).
Fixed
explainrace branches (#276): race (|) branches now render correctly indf.explain()output.- Loop safety (#254):
df.loop()now enforces a max-iteration guard and detects malformed loop configuration instead of looping unboundedly or misbehaving. $name.*expansion cap (#255): a row-count limit (10,000) is now enforced when expanding$name.*, preventing unbounded expansion.df.http()User-Agent (#270): requests now send a defaultUser-Agentheader.- Connection reliability (#251, #252): the client is now recoverable after a connection failure, and epoch/extension polling is isolated onto a dedicated connection pool so it can no longer contend with execution work.
df.list_instances()N+1 (#275): instance-info lookups are now batched, removing an N+1 query pattern.- Indexes (#271): added a
created_atindex and a composite status index ondf.instancesto speed up listing and status queries.
Security
- SSRF CGNAT range (#253): the
100.64.0.0/10CGNAT range is now blocked by SSRF protection indf.http(). df.metrics()access (#184):df.metrics()is now gated behind an explicitEXECUTEgrant rather than being callable by default.
Removed
df.debug_connection(): removed from the SQL surface as non-security, surface-reduction cleanup (#110). The function returned the worker connection string (postgres://role@host:port/db) with no password or credential, and the worker role is already visible through native PostgreSQL channels (the world-readablepg_durable.worker_roleGUC andpg_stat_activity.usename) — so issue #110 is reclassified from a security finding to cleanup. Fresh installs no longer create the function and the0.2.3 → 0.2.4upgrade drops it; a binary-compatibility shim retains the underlying C symbol so pre-0.2.4 schemas keep resolving the function untilALTER EXTENSION pg_durable UPDATEruns.
Documentation
- Documented
SECURITY DEFINER df.start()behavior (#185), corrected documentation examples (#257), and clarified thatdf.status()/df.result()take aninstance_idrather than a label (#167).
[0.2.3] - 2026-06-17
Provider-line note: v0.2.3 stays in the duroxide-pg provider compatibility line started in v0.2.2, so the upgrade source is v0.2.2 (sql/pg_durable--0.2.2--0.2.3.sql).
Added
- Debian release packages: AMD64
.debpackages for PostgreSQL 17 and 18, built and validated by the Package Release workflow on tagged releases (#190, #203). - Public Docker images:
ghcr.io/microsoft/pg_durableimages are published from the released.debpackages for PG 17 and 18. These images are for evaluating and learning pg_durable only - not for production (#218, #222, #223).
Changed
- duroxide provider schema: fresh installs now use
_duroxideas the duroxide-pg provider schema, while installations upgraded from earlier versions keep the legacyduroxideschema. The active schema is resolved at runtime viadf.duroxide_schema(), so the change is transparent to existing deployments (#201). - Default worker role: the background worker’s default role is now
postgresinstead ofazuresu(#206). df.break()internals:df.break()now carries its value as a typedNodeErrorinstead of a JSON sentinel, with a compatibility fallback for envelopes written before #148 (#229).- JSON conversion: internal SQL-to-JSON value conversion now goes through
try_from_json()for more robust error handling (#235). - Dependencies: bumped
reqwestto 0.13 to match the lockfile (#237) and updated five crates in the cargo dependency group (#236). Added Dependabot for weekly cargo updates (#231).
Fixed
- Reliability audit: fixed a set of correctness and safety bugs found during a reliability audit (#220):
df.if()/df.loop()conditions whose SQL returns zero rows now correctly evaluate as false instead of true (previously the empty result envelope was treated as truthy).- Graphs nested deeper than 256 levels are now rejected, preventing stack overflow from deeply nested operator chains.
- Graphs with more than 10,000 nodes are now rejected, preventing unbounded INSERT storms and out-of-memory conditions.
- Per-user SQL connections now have a 30-second connect timeout, so a stalled connection can no longer hold an execution slot indefinitely.
- Non-finite floats: SQL columns containing
NaNorInfinitynow map to JSONnullinstead of failing the workflow (#144). - Execution-history errors:
df.instance_executionsnow surfaces execution-history lookup failures instead of silently hiding them (#225, closes #168).
Security
- Docker/GHCR hardening: hardened the published Docker image and the GHCR publish workflow, including least-privilege permissions and provenance/SBOM attestations on published images (#223).
Documentation
- Clarified
df.http()security scope versus SQL extension execution (#216). - Corrected stale identity-model documentation and examples (#219, #224).
- Added the documentation website, refreshed the README, and standardized terminology to “durable functions” (#198, #204, #205, #207, #208, #211).
- Referenced the
pg_durable.databaseGUC instead of thePGDATABASEenvironment variable (#200).
[0.2.2] - 2026-05-28
First open-source release of pg_durable on GitHub under the PostgreSQL License.
Open Source Release
- License: changed project licensing from MIT to PostgreSQL License (#187).
- Repository: moved to
github.com/microsoft/pg_durableand updated crate metadata accordingly. - Community files: added
CONTRIBUTING.md,CODE_OF_CONDUCT.md, andNOTICE(direct third-party dependency inventory).README.mdnow includes Support, Code of Conduct, Security, Privacy & Telemetry (no telemetry), and Trademarks sections. - Source headers: added PostgreSQL License headers to Microsoft-authored Rust, SQL, shell, Python, Makefile, Dockerfile, and config files; pre-existing notices preserved.
- Sanitized internal references: removed internal Azure Container Registry defaults from
Makefile,.env.example,scripts/deploy-acr.sh,docs/TESTING.md, and release prompts;ACR_REGISTRYis now caller-provided.
Breaking Changes
- Provider compatibility boundary:
pg_durablenow uses the crates.ioduroxide-pgprovider instead of theduroxide-pg-optsubmodule. This is the first open-source release in theduroxide-pgprovider line. Upgrade testing treatsv0.2.2as the compatibility start for this line; Azure’s fork owns upgrade compatibility for the earlierduroxide-pg-optline (#158). df.join/df.join3result shape: join results are now a proper JSON array of objects instead of an array of double-encoded JSON strings. Consumers that previously unescaped each element, for example(elem #>> '{}')::jsonb, must now read the element directly (#143).
Added
- Typed SQL result decoding: SQL node execution now preserves richer PostgreSQL column types in JSON results instead of treating all values as strings (#135).
- Composite capture support:
|=>captures are now honored on composite THEN / IF / LOOP nodes (#163).
Changed
- Dependencies: switched from
duroxide-pg-optto crates.ioduroxide-pg = 0.1.34and bumpedduroxideto0.1.29(#158). - Cancel status spelling: status handling and documentation now consistently use
cancelled(#145, #160). - Signal payloads:
df.signal(text)now accepts non-JSON text payloads as its SQL signature implies (#173).
Fixed
- JOIN/RACE branch state: variables, labels, and named results now propagate correctly through JOIN/RACE subtrees (#137, #138).
- Instance status transition: instances are marked
runningbefore graph execution begins, so monitoring reflects active work promptly (#136). - Loop throttling:
df.loop()enforces a minimum iteration delay to avoid busy-spin behavior (#141). - Branch breaks:
df.break()is no longer silently ignored inside JOIN/RACE branches (#140). - Signals in sub-orchestrations:
df.signal()now propagates events to running sub-orchestrations spawned bydf.race,df.join, anddf.join3, sodf.wait_for_signalinside a parallel branch wakes as expected. Known limitation: signals raised before the target sub-orchestration is in theRunningstate are not yet redelivered when it starts; a proper fix requires unmatched-event forwarding in duroxide (#154). - Quoted role names:
df.start(), RLS policies ondf.instances/df.nodes/df.vars, anddf.varsreads/writes no longer fail withrole "..." does not existwhencurrent_userrequires quoting, such as mixed case, spaces, or embedded quotes. Schema upgrade DDL is insql/pg_durable--0.2.1--0.2.2.sql(#161, #162).
Security
- Workflow composition hardening: variable setup helpers are rejected inside workflow composition where they would mutate session state during graph construction (#153).
- Dependency update: bumped
opensslfrom0.10.78to0.10.80(#176).
Documentation
- Clarified that
df.break(value)takes a literal value, not SQL (#157). - Clarified text payload guidance for
df.signal(text)(#174).
v0.2.1 (Released)
- Dependency: upgrade duroxide
0.1.26→0.1.28and duroxide-pg-optv0.1.23→v0.1.26; adds cached-plan retryability, instance stats API, and error propagation fixes; switches TLS backend tonative-tls, removing theringcrate entirely (#116) - Dependency:
cargo updateto refresh transitive dependencies (#116) - Security: harden
df.explain()to reject non-DSL input before SPI evaluation (#112) - Security: harden SPI queries against search_path poisoning (#114)
- Security: add annotations for raw variable substitution (#111)
- Fix: improvements and fixes to
df.grant_usage()/df.revoke_usage()helpers (#109) - Fix: enable
superuser_instancesGUC in Docker CI (#117) - New: Azure HTTP domains validation example (#115)
v0.2.0 (Released)
- Tag: v0.2.0
- Commit:
f5607fb - Breaking change:
df.varsnow uses per-user scoping via RLS. After upgrading fromv0.1.1, all pre-existing variables are re-homed to the role that ranALTER EXTENSION pg_durable UPDATE; other users will lose access to any variables they had set before the upgrade. - Security: harden SQL execution against injection (#51)
- Fix:
is_truthynow correctly treats “false”, “no”, and “f” as falsy (#57) - Docs: add “Debugging Failed Workflows” section to User Guide (#71)
- New: Azure Functions integration example (#69)
- Named result substitution now supports dot-notation for column access (
$name.col), null-safe variants ($name?,$name.col?), and row-set expansion ($name.*). Referencing a named result that returned no rows or a NULL value now fails the orchestration by default; append?to substituteNULLinstead. - New DSL function
df.if_rows(): branches on whether a named result returned any rows, without executing a SQL condition query. - New: Connection limits — four Postmaster-context GUCs (
max_management_connections,max_duroxide_connections,max_user_connections,execution_acquire_timeout) control the background worker’s connection budget. User-execution connections are gated by a semaphore with configurable backpressure timeout. The former polling and activity pools are consolidated into a single management pool. Backend provider pools reduced to 1 connection. - Breaking change: simplified user isolation by dropping
login_rolefromdf.instancesanddf.nodes. User isolation now captures onlycurrent_userassubmitted_by, and the background worker connects directly assubmitted_byinstead of connecting aslogin_roleand runningSET ROLE.df.start()now validates thatcurrent_userhas theLOGINattribute. The new binary remains compatible with the v0.1.1 schema shape, but any pending or running v0.1.1 instance whosesubmitted_byis a NOLOGIN role from the oldSET ROLEworkflow will fail after upgrade and must be recreated under the new model. - Breaking change: fresh installs no longer grant
PUBLICaccess to thedfschema. An administrator must explicitly grant privileges to each role that needs to use pg_durable (e.g.,GRANT USAGE ON SCHEMA df TO my_role; GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA df TO my_role;). Existing v0.1.1 installations are not modified by the upgrade — their current permissions remain intact.
v0.1.1 (Released)
- Tag: v0.1.1
- Commit:
b83dc78828b4f5a4d6fb03a6b97cc46fff834df9