pg_durable follows a two-phase upgrade model:
- Binary update (maintenance window): The
.soshared library is replaced. PostgreSQL processes load the new.soon next connection. This happens during scheduled maintenance. - Schema update (customer-initiated):
ALTER EXTENSION pg_durable UPDATE TO '<version>'runs the upgrade SQL script. Customers may defer this for days, months, or indefinitely.
This means the new .so must be backward compatible with the previous version's schema. The .so and the upgrade script are not atomic — there is an extended period where the new binary runs against the old schema.
Compatibility is scoped to a provider compatibility line. A provider line is the set of pg_durable versions that use the same durable-state provider family and are expected to upgrade in place. The open-source line starts at v0.2.2, where pg_durable switches from duroxide-pg-opt to crates.io duroxide-pg. Versions before v0.2.2 used duroxide-pg-opt; they are not upgrade sources for the duroxide-pg line because the provider schemas and runtime state are different. Azure's fork owns upgrade testing for the duroxide-pg-opt line.
We never downgrade. Downgrade scripts are not needed.
Scenarios A and B2 are chain tests: PostgreSQL applies upgrade scripts sequentially (v0.2.2→v0.2.3→v0.3.0 within the current provider line), so each step is validated transitively by its own version's CI. Testing the current upgrade script against the immediately previous compatible version is sufficient.
Scenario B1 is a direct-contact test: the .so faces whatever raw schema the customer has, with no intermediate transformation. There is no chain — a customer on v0.2.2 who receives the v0.5.0 binary without ever upgrading has a v0.2.2 schema with a v0.5.0 .so. That's why B1 must test against all previous compatible versions in the same provider line.
All three scenarios scope to versions within the same provider compatibility line. A provider-line boundary is stronger than a major-version boundary: the new .so does not need to execute against provider state from another line, and the upgrade tests should not treat that crossing as a required customer path.
- B1: Tests all previous schemas in the current provider line. It skips versions before
PROVIDER_COMPAT_START_VERSION. - A and B2: Test the immediately previous version only when that previous version is in the current provider line. If the current version is the first version in a provider line, A and B2 are skipped because there is no valid previous upgrade source for that line.
- Major versions: A major version bump can still be used as a compatibility boundary. When no provider-line split is involved, the previous same-major rules continue to apply.
Goal: Verify that ALTER EXTENSION UPDATE produces an identical schema to a fresh CREATE EXTENSION.
Contract: For a not-yet-released version, the fresh-install schema is expected to match what an existing customer would get by starting from the immediately previous compatible shipped version and applying the shipped upgrade chain to the new version. In other words, Scenario A treats the upgrade result as the reference shape for already-shipped versions in the current provider line. If fresh install and upgrade differ before release, prefer aligning the new version's fresh-install DDL with the upgrade path unless there is a deliberate reason to change the contract.
Method:
- Install current
.soand all upgrade SQL files - In a clean test database, run
CREATE EXTENSION pg_durable VERSION '<prev>'→ALTER EXTENSION pg_durable UPDATE TO '<current>', then capture a schema snapshot - In the same clean test database (after dropping the extension) or in a second clean database, run
CREATE EXTENSION pg_durableand capture a fresh-install snapshot - Compare schemas: tables, columns, types, constraints, indexes, RLS policies, grants
What it catches:
- Missing DDL in upgrade script (forgotten tables, columns, policies)
- Wrong column types, defaults, or constraint names
- Ordering issues in upgrade SQL
Why only the immediately previous compatible version? Upgrade scripts are frozen once shipped. The chain of upgrades (v0.2.2 → v0.2.3 → v0.3.0 within a provider line) is validated transitively — each version's CI tests its own upgrade script. Only the current work-in-progress upgrade script might introduce an inconsistency, so testing it against the immediately previous compatible version is sufficient.
Priority: High — foundational test, catches the most common class of upgrade bugs.
Goal: Verify that the new .so works correctly against all previous compatible versions' schemas, not just the immediately previous one. Customers may never run ALTER EXTENSION UPDATE, so the new binary must work against any older schema in the same provider line.
This is the most deployment-critical test because the new binary may run against any older compatible schema indefinitely. A customer on v0.2.2 who receives the v0.5.0 binary without ever upgrading must still be able to use the extension.
We test against all previous versions in the same provider compatibility line. The line starts at PROVIDER_COMPAT_START_VERSION in scripts/test-upgrade.sh, which can be overridden by downstream forks or CI environments.
Method:
- Install the new
.so - For each previous compatible version: create the extension with that version's install SQL
- Exercise all SQL-callable functions against each schema
- Verify: no errors, correct results
What to test (expand per-version as the API surface grows):
| Area | Functions |
|---|---|
| Variable functions | df.setvar(), df.getvar(), df.unsetvar(), df.clearvars() |
| Variable capture | df.start() with vars set |
| DSL construction | df.sql(), df.seq(), df.if(), df.loop(), df.sleep(), df.http() |
| Execution | Starting and completing orchestrations |
| Monitoring | df.status(), df.result(), df.list_instances(), df.instance_info() |
| In-flight work | Orchestrations started before .so swap complete after swap (except across an activity-input change — see #129) |
What it catches:
- SQL queries in Rust code referencing columns/constraints that don't exist in the old schema
- Changed function signatures that conflict with old SQL wrappers
- Behavioral regressions for customers who haven't run the upgrade script
Priority: Critical — this reflects the real-world deployment state for potentially all customers.
Goal: Verify that data created under the previous compatible version remains accessible and functional after ALTER EXTENSION UPDATE.
This is a chain test (like Scenario A) — upgrade scripts are applied sequentially within the provider compatibility line, so testing against the immediately previous compatible version is sufficient. Each intermediate upgrade was validated by its own version's CI.
Method:
- Create extension at previous version
- Insert test data (vars, completed instances, and optionally in-flight work)
- Run
ALTER EXTENSION UPDATE - Verify: existing data is accessible, functions work on the new schema
What to test (expand per-version as changes accumulate):
| Area | What to verify |
|---|---|
| Variables | Pre-existing vars accessible via df.getvar() after upgrade |
| Pre-existing instances | df.result(), df.instance_info(), and df.list_instances() work for instances created before upgrade |
| In-flight work | Work started before ALTER EXTENSION UPDATE can still complete afterward (except across an activity-input change — see #129) |
| New operations | df.start() works with new schema |
Priority: High — validates the upgrade doesn't corrupt or lose existing data.
When a new .so must support both old and new schemas (Scenario B1), code should detect the schema state at runtime. Approaches:
Check column/table existence and branch accordingly:
// Example: check if a column exists before referencing it
let has_column = Spi::get_one::<bool>(
"SELECT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'df' AND table_name = 'example' AND column_name = 'new_col'
)"
).unwrap_or(Some(false)).unwrap_or(false);
if has_column {
// New schema: use new query
} else {
// Old schema: use compatible query
}Cache the result per-session to avoid repeated catalog queries.
Write queries valid regardless of which schema version is active. Not always possible (e.g., ON CONFLICT must name actual constraint columns).
SELECT extversion FROM pg_extension WHERE extname = 'pg_durable'Returns the version that was last installed/updated. Compare against known thresholds.
sql/pg_durable--0.1.1.sql— first install SQL for the current major versionsql/pg_durable--0.2.2.sql— install SQL fixture at the start of the current provider compatibility line (PROVIDER_COMPAT_START_VERSION). The harness reconstructs a target version from the highest install fixture at or below it (seebase_fixture_for_versioninscripts/test-upgrade.sh), then chainsALTER EXTENSION UPDATE. A fixture is required at the provider-compat-start boundary so reconstruction never has to chain across it — the pre-0.2.2 install SQL embeds a hand-writtenduroxideschema that is incompatible with the duroxide-pg provider's migration tracking (_duroxide_migrations).sql/pg_durable--0.1.1--0.2.0.sql— upgrade script (initially empty, populated by subsequent PRs)scripts/test-upgrade.sh— runs Scenarios A, B1, and B2. ThePROVIDER_COMPAT_START_VERSIONenvironment variable/default controls the first version in the current provider compatibility line. Versions before that boundary are excluded from B1 and cannot be used as A/B2 upgrade sources.- CI step in
.github/workflows/ci.yml
Each PR that changes the extension schema or modifies SQL queries in Rust code should:
- Add the necessary DDL to the upgrade script (
sql/pg_durable--<prev>--<current>.sql) - Ensure the
.sois backward compatible with all previous schemas in the same provider compatibility line (Scenario B1) - Keep all new DDL — in the Rust install SQL and in any new upgrade script — schema-qualified so it passes the pgspot SQL security gate (
scripts/pgspot-gate.sh): qualify operators asOPERATOR(pg_catalog.<op>), functions/types/objects by schema (e.g.pg_catalog.now()), and qualify references inside anonymousDOblocks (they run under the session search_path). New upgrade scripts are gated automatically. - Add version-specific notes to this document under "Version-Specific Changes" below
- Pass upgrade and pgspot tests in CI
- pg_regress-style upgrade test: pg_cron and similar extensions use
pg_regresswith expected-output diffs to validate upgrade scripts. This is simpler than our Scenario A schema snapshot approach but less thorough — it doesn't cover B1 (binary backward compat) or B2 (data survival). Consider adopting as a complementary check ifpg_regressintegration becomes worthwhile.
Minor release (e.g. 0.2.0 → 0.3.0):
- Create empty
sql/pg_durable--<N>--<N+1>.sqlupgrade script - Bump
Cargo.tomlversion to<N+1> - If this release starts a new provider compatibility line, update the
PROVIDER_COMPAT_START_VERSIONdefault inscripts/test-upgrade.sh, check in an install SQL fixture (sql/pg_durable--<start>.sql) at that boundary so reconstruction never chains across it, and document the boundary under "Version-Specific Changes". Downstream forks can instead overridePROVIDER_COMPAT_START_VERSIONin CI to keep the script shared. When you advancePROVIDER_COMPAT_START_VERSION, also audit for binary-compatibility shims annotated#[pg_extern(sql = false)](e.g.df.debug_connection()insrc/dsl.rs, retained for #110) and delete any whose C symbol is no longer referenced by a supported schema in the new line.
If this is the first minor after a new major (e.g. 1.0.0 → 1.1.0), also:
- Check in
sql/pg_durable--<major>.sqlas the first install SQL fixture for the new major (e.g. copy the generatedpg_durable--1.0.0.sqlfrom the extension directory) - Optionally delete the previous major's install SQL fixture and upgrade scripts — they are no longer needed by any of A, B1, or B2
No additional fixture is needed for subsequent minors — intermediate versions are reconstructed by chaining ALTER EXTENSION UPDATE from the first version's install SQL.
Major release (e.g. 0.x → 1.0.0):
- Create empty
sql/pg_durable--<N>--<1.0.0>.sqlupgrade script - Bump
Cargo.tomlversion to<1.0.0>
cargo pgrx package generates the new major's install SQL. The previous major's install SQL and upgrade scripts are still needed for the A/B2 upgrade chain when the provider line continues across the major bump. B1 will be a no-op if there are no previous compatible versions within the new major, or if PROVIDER_COMPAT_START_VERSION marks the new major as the start of a new provider line.
The pgspot gate scans every upgrade script matching *--*--*.sql, except a small
hardcoded list of pre-pgspot legacy scripts in scripts/pgspot-gate.sh (authored
before the install DDL was schema-qualified, and immutable now that they're
released). Every new upgrade script is gated and must pass — keep its DDL
schema-qualified (see step 3 above). Scripts written after qualification pass the
gate, so they never need to be added to the exclude list.
Each schema-changing PR should add a section here documenting what changed, what the upgrade script handles, and any backward compatibility considerations.
- DDL change (df schema):
df.grant_usage()no longer loops over a hard-codedfunc_sigsarray issuingGRANT EXECUTEper function. Fresh installs (src/lib.rs) and the upgrade script (sql/pg_durable--0.2.3--0.2.4.sql) bothCREATE OR REPLACEthe function with a body that grantsUSAGE ON SCHEMA dfplus the table privileges, and conditionally grantsdf.http()/ the admin helpers. The signaturedf.grant_usage(text, boolean, boolean)is unchanged. - DDL change (df schema):
df.revoke_usage()is made symmetric with the newgrant_usage(). It no longer loops over everydf.*function inpg_procissuingREVOKE EXECUTE(which, post-simplification, only produced "no privileges could be revoked" warnings since ordinary functions are never granted per-function EXECUTE). The new body revokes only whatgrant_usage()grants: schemaUSAGE, EXECUTE on the sensitive functions (df.http,df.grant_usage,df.revoke_usage), and the table privileges. The signaturedf.revoke_usage(text)is unchanged. - Rationale: The ordinary
df.*functions retain PostgreSQL's default PUBLICEXECUTE, so schemaUSAGEis the real access gate; the per-function grants/revokes were redundant. The sensitive functions have PUBLICEXECUTErevoked at install time and were never in the allowlist, so their protection is unchanged. - Behavioral note: A newly added
df.*function is now callable by any role with schemaUSAGEby default. To keep a future function private,REVOKE EXECUTE ... FROM PUBLICat install time and grant it explicitly indf.grant_usage(). - Legacy cleanup caveat: A role that was granted under the old
grant_usage()(explicit per-function EXECUTE) and is later revoked under the newrevoke_usage()may retain inert EXECUTE entries on ordinary functions. These are harmless — revoking schemaUSAGEfully locks the role out — and clear on the next drop/regrant cycle. - Scenario A considerations: Signatures are identical on the fresh-install and upgrade paths (only the bodies differ), so the function-signature equivalence contract passes.
- Scenario B1/B2 considerations: No schema/data migration and no new objects. The replaced bodies work against the existing schema and change no privileges already granted.
- DDL change (df schema): Adds
df.await_instance(text, integer)as the canonical C binding for the helper formerly exposed asdf.wait_for_completion(text, integer). The old SQL function remains present and the new.socontinues exportingwait_for_completion_wrapperas a shim, so existing customer scripts keep working. - Grant behavior: No explicit grant migration is required. PostgreSQL grants
EXECUTEon newly created functions toPUBLICby default, anddf.await_instanceis not a sensitive helper whose default PUBLIC grant is revoked. - Scenario A considerations: Fresh installs and upgraded schemas must both expose
df.await_instance(text, integer)anddf.wait_for_completion(text, integer). - Scenario B1 considerations: The new
.soremains compatible with v0.2.3 schemas that have not runALTER EXTENSION UPDATE: existing catalog entries still binddf.wait_for_completiontowait_for_completion_wrapper, which is retained as a Rust shim todf.await_instance. - Scenario B2 considerations: No data migration. Existing instances are unaffected; the upgrade only adds a SQL function binding.
- DDL change (df schema): The upgrade script
sql/pg_durable--0.2.3--0.2.4.sqlrunsDROP FUNCTION IF EXISTS df.debug_connection();. Fresh v0.2.4 installs never create the function: its#[pg_extern]insrc/dsl.rsis annotated#[pg_extern(sql = false)], so pgrx emits noCREATE FUNCTIONfor it (the generated schema records-- Skipped due to #[pgrx(sql = false)]). The function returned the worker connection string (no credential) and is dropped as surface-reduction, because the worker role is already exposed to any role via native PostgreSQL channels — the world-readablepg_durable.worker_roleGUC andpg_stat_activity.usename(see security-review item I-6); the remaining fields (database, host/port, schema) are connection-topology metadata, not secrets (the host comes fromPGHOST, defaulting to loopback). Reclassified from security to cleanup; see issue #110. - Interaction with the
df.grant_usage()simplification: Earlier in this releasedf.grant_usage()carried'df.debug_connection()'in its explicit per-function allowlist (func_sigs), so dropping the function would have required editing that allowlist. The grant_usage simplification above (#242) removed the allowlist entirely in this same release, so the upgrade no longer needs anygrant_usagechange to account for the removed function — it simply dropsdf.debug_connection(). - Scenario A considerations:
df.debug_connection()is absent on both the fresh-install and upgrade paths after this release, keeping thedfschema shapes equivalent. - Scenario B1 considerations (symbol retention): Removing
df.debug_connection()from the SQL surface required care to preserve binary backward compatibility. Pre-0.2.4 schemas (0.2.2, 0.2.3) define the function asAS 'MODULE_PATHNAME','debug_connection_wrapper', and PostgreSQL validates that C symbol atCREATE FUNCTIONtime (check_function_bodies = onby default). Fully deleting the#[pg_extern]would drop thedebug_connection_wrappersymbol from the.so, so the new binary could no longer instantiate any previously shipped schema — failing Scenario B1. The fix is#[pg_extern(sql = false)]: pgrx still compiles the C wrapper symbol into the binary (verified withnm) but emits no SQL, so old schemas keep resolving the symbol while fresh installs omit the function. The retained Rust body still returns the same non-secret connection string, so a binary-only swap (noALTER EXTENSION UPDATE) leaves any pre-existingdf.debug_connection()working until the customer upgrades. The shim is a temporary binary-compat detail — remove it oncePROVIDER_COMPAT_START_VERSIONadvances past 0.2.3 and no supported schema references the symbol. - Scenario B2 considerations: No data migration. Existing instances, nodes, and vars are untouched. After
ALTER EXTENSION UPDATE,df.debug_connection()no longer exists; the simplifieddf.grant_usage()never references it. - Dependent-object note: The upgrade runs
DROP FUNCTION IF EXISTS df.debug_connection()with PostgreSQL's defaultRESTRICTbehavior. If a customer created their own object that depends on the function (e.g. a view or SQL function that calls it),ALTER EXTENSION UPDATEaborts with a dependency error and the customer must drop or repoint that object first. This is intentional for a removed debug helper — the script deliberately does notCASCADE, to avoid silently dropping customer-owned objects. The fresh-install (tests/e2e/sql/18_delegated_grants.sql) and upgrade (scripts/test-upgrade.shB2 grant test) suites assert the function is absent and thatdf.grant_usage()still works after the drop.
- DDL change (df schema):
df.nodespreviously had a single-columnPRIMARY KEY (id)plus a separate compositeUNIQUE (instance_id, id)(nodes_instance_node_key). The single-column key forced the random 8-hex node ID to be globally unique, so it was the sole cross-instance collision guard. Node IDs only need to be unique per instance, so the composite key is promoted to be the primary key and the global single-column key is dropped. Fresh installs (src/lib.rs) declareid/instance_idasNOT NULLand createnodes_pkey PRIMARY KEY (instance_id, id)directly; the upgrade script (sql/pg_durable--0.2.3--0.2.4.sql) restructures the existing keys in place. The three same-instance foreign keys (nodes_left_node_same_instance_fkey,nodes_right_node_same_instance_fkey,instances_root_node_same_instance_fkey) reference the composite key, so the upgrade drops them first, swaps the keys, then recreates them with their originalDEFERRABLE INITIALLY DEFERRED NOT VALIDdefinition.nodes_instance_identity_fkeyreferencesdf.instances, notdf.nodes, and is left untouched. IDs remainVARCHAR(8)HEX. - Companion runtime change (#129):
df.start()now reserves the instance ID by attempting the insert itself —INSERT INTO df.instances ... ON CONFLICT (id) DO NOTHING RETURNING id— and re-rolling the random 8-hex ID when zero rows come back (a collision); there is no separateSELECT EXISTSpre-check. BecauseON CONFLICTarbitration runs against the globalidindex below row-level security, this also re-rolls on collisions with another role's instance that the caller cannotSELECT. Node inserts use the same pattern against the composite key —INSERT INTO df.nodes ... ON CONFLICT (instance_id, id) DO NOTHING RETURNING id— re-rolling on a per-instance collision.df.start()pre-generates the root node's ID and reserves the instance withroot_nodeset to that value;insert_nodesthen inserts the root node with the same forced ID. The same-instance FK onroot_nodeisDEFERRABLE INITIALLY DEFERRED, so it is checked only at commit, by which point the referenced root node row exists — no post-insertUPDATEis needed (anddf.grant_usage()deliberately grantsUPDATE (status, updated_at)but notUPDATE (root_node)ondf.instances, so an update path would fail for ordinary df roles). Theupdate-node-statusactivity anddf.result()now scope theirdf.nodeslookups byinstance_idin addition toid, and the activity asserts the scopedUPDATEaffects exactly one row.instance_idis a required field of the activity input — node IDs are unique only per instance, so updating by node ID alone could silently write to a different instance's node. There is deliberately no node-ID-only fallback. - Design note — collision handling for both ID spaces (#129): Both IDs stay 8-hex
VARCHAR(8)(the requested minimal change) and re-roll on conflict viaINSERT ... ON CONFLICT DO NOTHING RETURNING id; the mechanism is symmetric and only the conflict target differs.df.instances.idis a global identifier with no natural scoping column, so its reserve arbitrates on the single-column primary key (id).df.nodes.idis always used together with its owninginstance_id, so promoting the pre-existing(instance_id, id)UNIQUE to the primary key lets node inserts arbitrate per instance — the random node ID never has to be globally unique. UsingON CONFLICT DO NOTHINGrather than aSELECT EXISTSpre-check closes a TOCTOU window and, for instances, an RLS blind spot: the pre-check only saw the caller's own rows, whereasON CONFLICTdetects a clash with any role's row at the index level. The retry bound (MAX_ID_ATTEMPTS) surfaces a hard error on exhaustion rather than returning an unverified ID. - In-flight orchestration compatibility (#129 — breaking for in-flight work): Adding
instance_idto theupdate-node-statusactivity input changes the input string that duroxide records in orchestration history. duroxide validates activity inputs by exact equality during replay, so any orchestration that was in flight across the binary upgrade (it recorded the old{node_id, status}input under 0.2.3) fails deterministic replay under the new.soand cannot complete. This is an intentional break of the general "in-flight work completes after the swap" expectation (the Scenario B1 and B2 "In-flight work" rows above) for this release, and follows the same drain-or-recreate precedent as the v0.1.0 → v0.1.1 execution-model change (Scenario B2, below): operators must drain in-flight instances to a terminal state before deploying 0.2.4, or cancel and recreate any that cannot drain. Instances that completed before the upgrade are terminal and unaffected; instances started after the upgrade carryinstance_idfrom their first node update and replay normally. - Scenario A considerations: Fresh-install and upgraded schemas must both end with exactly one identity constraint on
df.nodes:nodes_pkey PRIMARY KEY (instance_id, id)(constraint key orderinstance_id, id), its matching unique indexnodes_pkey ON df.nodes USING btree (instance_id, id), and no survivingnodes_instance_node_keyconstraint or index. The recreated foreign keys keep identical names and referencing columns, so the constraint/index snapshot diff is empty. - Scenario B1 considerations: The schema change is to table constraints only; the new
.soissues the same column lists againstdf.nodes/df.instances, now withON CONFLICT ... DO NOTHING RETURNING id. The instance reserve arbitrates onid(the primary key in both old and new schemas) and the node insert arbitrates on(instance_id, id)— an index that exists in both the pre-0.2.4 schema (thenodes_instance_node_keycomposite UNIQUE) and the new schema (the composite primary key) — so both statements stay valid against a schema that has not runALTER EXTENSION UPDATE. The pre-generated-root_idreserve is also old-schema-safe:instances_root_node_same_instance_fkeyisDEFERRABLE INITIALLY DEFERREDin every shipped schema, soroot_nodeis not checked until commit, by which point the forced-ID root node row has been inserted within the same transaction. NoUPDATE df.instancesis issued, so the change relies only on theINSERT (..., root_node, ...)privilege every shippeddf.grant_usage()already grants, not on anyUPDATE (root_node)grant. One benign residual exists against the old schema only: a node ID that is globally duplicated but per-instance-unique would clash with the surviving single-columnnodes_pkey (id), whichON CONFLICT (instance_id, id)does not arbitrate, so it raises just as it did before this change — astronomically rare, strictly no worse than prior behavior, and eliminated onceALTER EXTENSION UPDATEswaps in the composite primary key. This covers schema compatibility only — the SQL stays valid against the old table shape. The separate in-flight replay break introduced by the changed activity-input shape is documented under "In-flight orchestration compatibility" above and requires draining before upgrade. - Scenario B2 considerations:
ADD PRIMARY KEY (instance_id, id)setsNOT NULLon both columns and builds a unique index over existing rows.idwas already the old primary key (implicitlyNOT NULL).instance_idcarries anodes_instance_id_present_chk CHECK (instance_id IS NOT NULL)constraint, but it was addedNOT VALID, so it only guarantees rows written on 0.2.2+; in the unlikely event a database still holds pre-0.2.2 node rows with a NULLinstance_id, theADD PRIMARY KEY(and the explicitALTER COLUMN instance_id SET NOT NULLthat precedes it) will abort and the operator must backfill or remove those rows before retrying the upgrade. On an empty database the restructure is metadata-only; on a populated one PostgreSQL rebuilds thedf.nodesprimary-key index in place. BecauseADD PRIMARY KEY/ALTER COLUMN ... SET NOT NULLtake anACCESS EXCLUSIVElock ondf.nodesand rebuild the index, on a largedf.nodesthe upgrade blocks concurrent access for a period that scales with the table's size; runALTER EXTENSION UPDATEinside a maintenance window and considerSET lock_timeoutfor the session so the migration fails fast instead of queuing behind (or stalling in front of) long-running transactions. Combined with the in-flight replay break noted above, the recommended upgrade sequence is: stop newdf.start()calls, drain or cancel in-flight instances, then run the upgrade.
- DDL change (df schema):
df.list_instances()lists rows newest-first (ORDER BY created_at DESC), optionally filtered by status. The pre-0.2.4idx_instances_status(status)covered only the status equality, so a status-filtered listing still required a sort and an unfiltered listing had no supporting index. Fresh installs (src/lib.rs) now createidx_instances_status(status, created_at DESC, id)and a newidx_instances_created_at(created_at DESC, id). The upgrade scriptsql/pg_durable--0.2.3--0.2.4.sqldrops any existing copies (DROP INDEX IF EXISTS) then recreates both indexes with the same definitions. The trailingidprepares the access path for the keyset pagination planned fordf.list_instances(ORDER BY created_at DESC, id ASC);df.list_instances()does not yet order byid, so PR2 does not change the current result ordering — it only positions the index to serve that future deterministic order as an index-only scan. - Design note (RLS):
df.instanceshas a row-level-security policy (instances_user_isolation) filteringsubmitted_by = current_user::regrole, so a per-user index leading withsubmitted_bywould be more selective for an individual session. Thecreated_at-leading design is intentional: it is optimal for the admin / external-client global-listing path (#146) that reads across submitters, and it still removes the per-query sort for the common case. Asubmitted_by-leading refinement can be revisited if profiling shows the per-user path dominates. - Scenario A considerations: The upgrade script recreates the indexes with column lists and
DESC/tiebreaker ordering identical to the fresh-install DDL, sopg_get_indexdef()foridx_instances_statusandidx_instances_created_atis byte-identical on both paths and the Scenario A snapshot matches. - Scenario B1 considerations: The new
.soworks against all previous schemas. Thedf.list_instances()queries (ORDER BY created_at DESC LIMIT, optionallyWHERE status = $1) reference only thecreated_at/statuscolumns, which exist in every shippeddf.instancesschema; against a schema that has not runALTER EXTENSION UPDATEthe queries stay valid and correct — they simply fall back to a sort without the new index until the upgrade is applied. This is a performance-only change with no correctness impact. - Scenario B2 considerations: No data migration.
DROP INDEX/CREATE INDEXrebuild access-path metadata only; row data is untouched. TheCREATE INDEXstatements take aSHARElock ondf.instanceswhile they build, so on a largedf.instancesrunALTER EXTENSION UPDATEin a maintenance window for the same reasons noted above.
- DDL change (df schema): Adds
df.duroxide_schema(), anIMMUTABLE/PARALLEL SAFESQL function that returns the name of the schema holding the duroxide provider objects. Fresh 0.2.3 installs create the function (insrc/lib.rs) returning'_duroxide'; the upgrade scriptsql/pg_durable--0.2.2--0.2.3.sqlcreates the same function returning'duroxide'so pre-existing installs keep using the legacy schema. Both bodies setsearch_path = pg_catalog, pg_tempto satisfy the pgspot gate. - DDL change (provider schema): Fresh installs now run
CREATE SCHEMA _duroxide(wasCREATE SCHEMA duroxide). The upgrade script does not rename, drop, or move the existingduroxideschema — renaming an in-use provider schema would orphan the BGW's durable state. Upgraded installs therefore continue to useduroxide. - Runtime selection: Backend sessions resolve the provider schema once per session via
backend_duroxide_schema()(cached in aOnceLock); the BGW resolves it once per epoch viaresolve_duroxide_schema_pool()(re-resolved after every CREATE EXTENSION so drop+recreate with a different schema version is handled). Both calldf.duroxide_schema()and fall back to'duroxide'(LEGACY_DUROXIDE_SCHEMA) when the helper is absent — i.e. a new.sodeployed against a ≤0.2.2 schema that has not runALTER EXTENSION pg_durable UPDATE. Presence is detected via apg_proccatalog lookup rather than catching42883, so the surrounding (sub)transaction is never aborted. - Scenario A considerations: The Scenario A equivalence contract covers the
dfschema only and compares function signatures, not bodies.df.duroxide_schema()has an identical signature on the fresh-install and upgrade paths (only the returned literal differs), so Scenario A passes. The provider schema name (_duroxidevsduroxide) is intentionally excluded from the snapshot diff, as it was for v0.2.0. - Scenario B1 considerations: The new
.soworks against all previous schemas: whendf.duroxide_schema()does not exist (≤0.2.2 schemas without the upgrade applied) the runtime falls back to'duroxide', which is exactly the schema those installs use. - Scenario B2 considerations: No data migration. The existing
duroxideschema and its tables are untouched; the upgrade only adds onedffunction. - Test fixture: A new
sql/pg_durable--0.2.2.sqlinstall fixture is checked in at the provider-compat-start boundary. The upgrade harness now reconstructs 0.2.2 directly from it (an emptyduroxideschema that the BGW populates via the duroxide-pgApplyAllmigration) instead of chaining from the pre-providerpg_durable--0.1.1.sqlfixture, whose embedded hand-writtenduroxideschema lacks the_duroxide_migrationstracking table and is therefore incompatible with the duroxide-pg provider.
- DDL change: The three RLS policies (
instances_user_isolation,nodes_user_isolation,vars_user_isolation) are dropped and recreated to compare againstquote_ident(current_user)::regroleinstead ofcurrent_user::regrole. Thedf.vars.ownercolumn default is changed the same way viaALTER TABLE ... ALTER COLUMN ... SET DEFAULT. Castingname → regroledirectly reparses the value as an unquoted SQL identifier and case-folds it, so a role likelabUserresolved tolabuser, raisingrole "labuser" does not existat INSERT time on theWITH CHECKclause (and at variable read/write time on the policy).quote_ident()wraps the name in double quotes soregrole_inpreserves casing and other reserved characters. - Scenario A considerations: Schema comparison must verify the three policy expressions and the
df.vars.ownerdefault text match the newquote_ident(...)form on both fresh installs and upgraded databases. - Scenario B1 considerations: The new
.socontinues to work against pre-0.2.2 schemas. The runtime SPI queries insrc/dsl.rsthat read/writedf.varsnow usequote_ident(current_user)::regrole; the comparison still resolves correctly against the olderowner REGROLEcolumn regardless of which expression the policy uses, becauseregrole = regroleis OID equality once both sides resolve. The pre-existing bug (policy lookup on the older schema for non-owner mixed-case roles) is not reintroduced by the new.so— it was always present in those schemas, and is only fixed by runningALTER EXTENSION pg_durable UPDATE. - Scenario B2 considerations: No data migration needed. The change is purely DDL on policies + a column default. Existing rows in
df.instances,df.nodes, anddf.varsare untouched.
- DDL change: Upgrade SQL now redefines helper SQL/PLpgSQL functions (
df.as_op(),df.if_then_op(),df.if_else_op(),df.ensure_durofut(),df.loop_prefix_op()) withSET search_path = pg_catalog, df, pg_tempfor defense-in-depth. - Scenario A considerations: Schema comparison should verify helper function definitions match fresh-install SQL, including the
SET search_pathclause inproconfig/function definition text. - Scenario B1 considerations: Runtime code moved key internal lookups to parameterized SPI/sqlx queries. This is backward compatible with prior schemas because query parameterization changed execution style, not table/column contracts.
- Scenario B2 considerations: Existing instances/graphs created pre-upgrade should remain readable and executable after
ALTER EXTENSION UPDATE; tests should include status/result and graph loading paths to cover updated internal query call sites.
- DDL change:
df.varsaddsowner REGROLE NOT NULL DEFAULT current_user::regrole, changes the primary key from(name)to(owner, name), enables RLS, and adds thevars_user_isolationpolicy. - Scenario A considerations: The schema comparison must verify the new column, its default, the new primary key definition, RLS enabled state, the
vars_user_isolationpolicy, and table grants. Because the upgrade script addsownerwithALTER TABLE ... ADD COLUMN, upgraded schemas placeownerafter the existing columns. Fresh-install DDL for v0.2.0 has been aligned to that order so Scenario A continues to compareordinal_position. - Scenario B1 considerations: This change touches the highest-risk upgrade surface because the Rust code now queries
df.vars.ownerand usesON CONFLICT (owner, name). The new.sostill has to work against the v0.1.1 schema, which has neither theownercolumn nor the(owner, name)primary key. The implementation therefore uses the installed extension version as the compatibility boundary: v0.1.x stays in legacy global-vars mode, while v0.2.0+ uses owner-scoped queries. - Scenario B2 considerations: The upgrade script assigns all pre-existing
df.varsrows to the role runningALTER EXTENSIONvia theDEFAULT current_user::regrolebackfill. Upgrade tests verify that existing vars remain readable after upgrade for the role that performed the upgrade, that the migrated row is re-homed to that upgrade-running role, and that new post-upgrade writes/executions use owner-scoped semantics. This migration does not preserve per-user ownership for legacy rows; it intentionally re-homes them to the upgrade runner. - Current status on this branch:
scripts/test-upgrade.shnow passes all Scenario A, B1, and B2 checks for this change.
- DDL change:
df.instancesanddf.nodesadd structuralCHECKconstraints, same-instance foreign keys, and supporting unique constraints. Table-wideINSERTgrants are narrowed to column-levelINSERTgrants that match the columnsdf.start()writes, while preserving direct userINSERTunder RLS. - Scenario A considerations: Schema comparison must include the new constraints, foreign keys, and narrowed grants on
df.instancesanddf.nodes. - Scenario B1 considerations: The
.soremains backward compatible with older schemas because the hardening is schema-only and does not introduce new columns or change Rust query shapes. - Scenario B2 considerations: The upgrade script adds the new
CHECKand foreign-key constraints asNOT VALIDso malformed legacy metadata rows do not blockALTER EXTENSION UPDATE, while all new writes are still enforced immediately after the upgrade.
- DDL change (df schema): No new SQL functions added for readiness. The internal Rust
is_worker_ready()check (not SQL-callable) gatesdf.*functions until the BGW writes a row toduroxide._worker_ready. - DDL change (duroxide schema): None required in the upgrade script. Fresh v0.2.0 installs create
CREATE SCHEMA duroxidevia extension SQL (extension-owned). The BGW'sApplyAllpolicy applies duroxide table DDL at runtime. For customers upgrading from v0.1.1, the duroxide schema and its objects already exist (extension-owned from the earlier SQL-hand-over approach); the BGW will verify they are up-to-date and continue normally. - Scenario A considerations: The Scenario A equivalence contract covers the
dfschema only. The duroxide schema diverges intentionally between the fresh-install and upgrade paths in v0.2.0: fresh installs have an emptyduroxideschema (tables added later by BGW), while upgrades have a fully populatedduroxideschema (carried forward from v0.1.1 extension SQL). This divergence is expected and acceptable —scripts/test-upgrade.shexcludes theduroxideschema from the snapshot diff. - Scenario B1 considerations: Readiness checking is internal to the Rust binary — no SQL function needs to be registered. The new
.socontinues to work against v0.1.1 schemas that have not runALTER EXTENSION UPDATE. - Scenario B2 considerations: The BGW readiness check (
wait_for_ready()) is called in both B1 and B2 scenarios to ensure the BGW has applied all pending duroxide migrations before exercising the extension. - Current status on this branch: Implemented. BGW now uses
MigrationPolicy::ApplyAll, verifies duroxide schema extension ownership before applying, and writesduroxide._worker_readyafter initialization completes.
- DDL change (df schema): None. All new schema objects are in the
duroxideschema and are applied at runtime by the BGW. - DDL change (duroxide schema): Five new migrations applied by BGW at startup:
- 0006:
worker_queue.tag TEXTcolumn + index; updatedenqueue_worker_workandfetch_work_itemSPs - 0007:
fetch_orchestration_itemSP body change only (no schema change) - 0008: new
kv_storetable; updatedfetch_orchestration_item,ack_orchestration_item, deletion/pruning SPs - 0009:
kv_store.last_updated_at_ms BIGINTcolumn; updated KV materialization SPs - 0010: new
kv_deltatable; two-table KV write model; delta→store merge on terminal transition
- 0006:
- Scenario A considerations: The
dfschema equivalence contract is unchanged. Theduroxideschema is excluded from snapshot diffs — fresh installs start with an emptyduroxideschema (BGW fills it in at runtime) while upgrades carry forward the fully-populated schema from v0.1.1. This is expected and acceptable. - Scenario B1 considerations: The BGW uses
MigrationPolicy::ApplyAll. A database that has only migrations 0001–0005 is handled gracefully: the BGW detects the gap and applies 0006–0010 at startup. No manual intervention is needed. - Scenario B2 considerations: All five new migrations are additive (new tables and columns with defaults or nullable). Existing
df.vars,df.nodes,df.instances, anddf.graphsdata is untouched. - Historical status: Implemented with the provider source at
4a6bf6bandCargo.tomlpinned toduroxide = "=0.1.26".
- DDL change (df schema): None. This is a provider source and version update only.
- DDL change (duroxide schema): No extension upgrade script DDL is required. The BGW continues to own provider migrations through
MigrationPolicy::ApplyAll. - Provider compatibility boundary: v0.2.2 is the first version in the open-source
duroxide-pgprovider line. Earlier pg_durable versions usedduroxide-pg-opt, whose SQL migrations and runtime state are not an upgrade source for this line. GitHub CI therefore setsPROVIDER_COMPAT_START_VERSION=0.2.2by default and skips A/B1/B2 coverage that would cross fromduroxide-pg-opttoduroxide-pg. Azure's fork owns upgrade testing for theduroxide-pg-optline. - Scenario A considerations: Skipped for the v0.2.1 → v0.2.2 boundary in GitHub CI because v0.2.1 is before the provider compatibility start. Future
duroxide-pg-line releases resume the normal fresh-vs-upgradeddfschema comparison against the immediately previous compatible version. - Scenario B1 considerations: The new
.sois not required to execute against pre-v0.2.2duroxide-pg-optprovider state. A failure pattern where basicdf.*functions work but provider-backed execution remains pending is expected across that boundary and should not be treated as a GitHub CI regression. Futureduroxide-pg-line releases must remain binary-compatible with v0.2.2+ schemas unless a later provider-line or major-version boundary explicitly changes that contract. - Scenario B2 considerations: Data compatibility is not tested across the
duroxide-pg-opt→duroxide-pgsplit. Futureduroxide-pg-line releases must preserve data created under the immediately previous compatible version. - Current status: Implemented —
Cargo.tomlexactly pinsduroxide = "=0.1.29"andduroxide-pg = "=0.1.34";scripts/test-upgrade.shdefaultsPROVIDER_COMPAT_START_VERSIONto0.2.2while allowing forks/CI to override it.
- DDL change: Upgrade script adds
CREATE FUNCTION df.if_rows(result_name text, then_branch text, else_branch text)— a new C-language function backed by the pgrx#[pg_extern]if_rows_fn_wrappersymbol. - Scenario A considerations: Fresh install picks up
df.if_rowsautomatically from pgrx-generated SQL. The upgrade path required an explicitCREATE FUNCTIONin the upgrade script to match. - Scenario B1 considerations: No backward compatibility concern.
df.if_rowsis a new function that doesn't exist in v0.1.1 schemas — it simply won't be callable until the customer runsALTER EXTENSION UPDATE. The.sosymbol exists but is never invoked from old schemas. All other changes (substitution engine rewrite,Resultreturn type) are internal to orchestration code and don't touch any SQL queries or table schemas. - Scenario B2 considerations: No data migration needed. The change is purely additive (new function) with no table or column changes.
- DDL change: None. All changes are runtime-only (pool consolidation, semaphore backpressure, new GUCs).
- Scenario A considerations: No schema changes — the
dfschema equivalence contract is unchanged. - Scenario B1 considerations: The new
.sodefaults match previous hard-coded values (management=6 covers the former polling=1 + activity=5, duroxide=10, backend=10→1 is internal). The new.soworks against all previous schemas without any GUC configuration. - Scenario B2 considerations: No data migration needed. Existing instances, nodes, and graphs are unaffected. The four new GUCs (
max_management_connections,max_duroxide_connections,max_user_connections,execution_acquire_timeout) are Postmaster-context and default to values preserving previous behavior.
- DDL change: v0.1.1 shipped with both
submitted_by REGROLEandlogin_role REGROLEondf.nodesanddf.instances. The v0.2.0 schema removeslogin_rolefrom both tables and keepssubmitted_byas the sole identity column. The composite unique constraint on instances isUNIQUE (id, submitted_by), and the composite FK from nodes references(id, submitted_by). This change is unrelated to the separate v0.2.0df.vars.owneraddition. - Scenario A considerations: Schema comparison must verify the absence of
login_roleon both tables, the narrower unique constraint, and the updated FK definition. - Scenario B1 considerations: The v0.1.1 schema does have
login_role, so B1 must still verify that the new.soworks with the old table shape and can insert into the legacy schema by populatinglogin_roleas needed. For execution compatibility, the supported contract is narrower: old instances continue to work whensubmitted_byitself hasLOGIN, because the worker now authenticates directly assubmitted_by. Instances that relied on the old split-identity path (login_role != submitted_by, especiallysubmitted_byon a NOLOGIN role) are an intentional breaking change, not a compatibility target. - Scenario B2 considerations: No data migration is needed for completed rows, but in-flight v0.1.1 work created under
SET ROLEto a NOLOGIN role is expected to break under the new execution model. Upgrade planning and tests should call out that customers must drain or recreate those instances rather than expecting them to survive the change.
- DDL change (fresh install): The
extension_sql!block insrc/lib.rsno longer contains GRANT statements to PUBLIC for thedfschema, tables, or functions. Fresh installs require the admin to explicitly grant privileges to application roles. - DDL change (upgrade): No REVOKE statements added to the upgrade script. Existing installs that upgraded from v0.1.1 retain their PUBLIC grants.
- Scenario A considerations: Grants intentionally differ between fresh install and upgrade. Fresh installs have no PUBLIC grants; upgraded installs retain them. The
scripts/test-upgrade.shScenario A comparison excludes grant-related rows (grant_table,grant_routine,grant_schema) from the diff. This is an accepted divergence — grant changes are not part of the upgrade contract. - Scenario B1 considerations: No impact. The
.socode does not depend on grant state — it uses SPI (which inherits the calling user's privileges) and sqlx connections (which authenticate as the worker role). Whether grants are present or not does not affect the.so's operation. - Scenario B2 considerations: No impact. Existing data and grants are preserved after upgrade. The upgrade script does not modify permissions.
-
DDL change: None. This is a runtime-only change implemented entirely in the
.so. -
Scenario A considerations: No schema changes — the
dfschema equivalence contract is unchanged. -
Scenario B1 considerations: The new GUC and the superuser checks in
df.start(),load_function_graph, andexecute_sqlare all runtime behavior enforced by the new.so. The checks querypg_catalog.pg_roles(always present) anddf.instances/df.nodes(present in all versions). The new.soworks against all previous schemas without modification. The GUC defaults tooff; the test runner sets it tooninpostgresql.confso that existing E2E tests that run aspostgresare not broken. -
Scenario B2 considerations: No data migration needed. The GUC has no effect on schema shape or existing data.
-
DDL change (fresh install):
REVOKE EXECUTE ON FUNCTION df.http(...) FROM PUBLICadded to therls_and_grantsextension SQL block insrc/lib.rs, executed immediately afterdf.http()is created.df.grant_usage()gains a second parameterinclude_http boolean DEFAULT false; whenfalse(the default), the function revokesdf.httpfrom the role after the blanketGRANT EXECUTE ON ALL FUNCTIONSand emits aWARNINGif the role still has effective HTTP access via a PUBLIC or inherited grant. -
DDL change (upgrade): No DDL executed. Section 7 of
pg_durable--0.1.1--0.2.0.sqlis a documentation comment only. The v0.1.1 PUBLIC grant ondf.http()is intentionally preserved.df.grant_usage()is redefined with the new signature. Admins who want opt-in HTTP permissions after upgrade must run:REVOKE EXECUTE ON FUNCTION df.http(text,text,text,jsonb,integer) FROM PUBLIC; -
Scenario A considerations: Grant differences between fresh install and upgrade are already excluded from snapshot diffs. The
df.grant_usagesignature change (new optional parameter with a default) must match between the fresh-install SQL generated by pgrx and theCREATE OR REPLACE FUNCTIONin the upgrade script. -
Scenario B1 considerations: No impact. The execution-time privilege check in
execute_httpqueries the live catalog viahas_function_privilege; it is not schema-version sensitive. On v0.1.1 schemas where PUBLIC still holdsEXECUTEondf.http(),has_function_privilegereturnstruefor all roles — consistent with the decision not to revoke the PUBLIC grant on upgrade. -
Scenario B2 considerations: No data migration needed. Existing nodes and instances are unaffected. Roles that had HTTP access via the v0.1.1 PUBLIC grant retain it after
ALTER EXTENSION UPDATE(intentional).
- DDL change (fresh install and upgrade):
df.grant_usage()gains a third parameterwith_grant boolean DEFAULT false. Whentrue, all privileges are granted WITH GRANT OPTION, including on the admin helpers (df.grant_usage,df.revoke_usage). The superuser check is removed fromdf.grant_usage()— authorization is now enforced by PostgreSQL-native mechanisms: EXECUTE privilege (revoked from PUBLIC) and WITH GRANT OPTION on underlying objects.df.revoke_usage()gains a self-revoke safety check usingpg_has_role(current_user, p_role, 'MEMBER')to prevent a role from accidentally revoking its own (or a parent role's) access. - Scenario A considerations: The
df.grant_usagesignature change (three parameters instead of two) must match between fresh-install SQL and theCREATE OR REPLACE FUNCTIONin the upgrade script. Grant-related rows are already excluded from snapshot diffs. - Scenario B1 considerations: No impact on backward compatibility. The
.socode does not calldf.grant_usageordf.revoke_usageinternally — they are user-facing SQL functions. On v0.1.1 schemas, these functions don't exist at all; they are added by the upgrade script. - Scenario B2 considerations: The upgrade script redefines both functions with
CREATE OR REPLACE FUNCTION, replacing the two-parameter version with the three-parameter version. The new default (with_grant => false) preserves existing behavior for callers usingdf.grant_usage(role)ordf.grant_usage(role, include_http => true). No data migration needed.