Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ Provider-line note: v0.2.3 stays in the `duroxide-pg` provider compatibility lin

### Changed

- **Full UUID instance/node IDs:** `df.start()` now generates full 36-character canonical UUIDs (122 bits of entropy) for instance and node IDs instead of the legacy 8-character form (32 bits, ~50% birthday-paradox collision chance near 65k instances on the `df.instances.id` primary key). The six id columns widen from `VARCHAR(8)` to `TEXT` and their `*_format_chk` CHECK constraints relax to accept a legacy 8-character id or a full UUID, so rows created before an in-place upgrade stay valid. A version gate keeps a new `.so` emitting 8-character ids against not-yet-upgraded schemas (Scenario B1). Upgrade DDL is in `sql/pg_durable--0.2.2--0.2.3.sql` (#129).
- **duroxide provider schema:** fresh installs now use `_duroxide` as the duroxide-pg provider schema, while installations upgraded from earlier versions keep the legacy `duroxide` schema. The active schema is resolved at runtime via `df.duroxide_schema()`, so the change is transparent to existing deployments (#201).
- **Default worker role:** the background worker's default role is now `postgres` instead of `azuresu` (#206).
- **`df.break()` internals:** `df.break()` now carries its value as a typed `NodeError` instead of a JSON sentinel, with a compatibility fallback for envelopes written before #148 (#229).
Expand Down
17 changes: 10 additions & 7 deletions USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ After `CREATE EXTENSION`, the background worker initializes the engine schema as
```sql
-- Execute a simple SQL query as a durable function
SELECT df.start('SELECT ''Hello, durable world!''');
-- Returns: a1b2c3d4 (8-character instance ID)
-- Returns: 2f1c4d8e-9a3b-4c7d-8e1f-0a1b2c3d4e5f (UUID instance ID)
```

### Check the Result
Expand All @@ -97,8 +97,8 @@ SELECT df.start('SELECT ''Hello, durable world!''');
-- List all functions
SELECT * FROM df.list_instances();

-- Get result of a specific instance
SELECT df.result('a1b2c3d4');
-- Get result of a specific instance (use the instance_id returned above)
SELECT df.result('2f1c4d8e-9a3b-4c7d-8e1f-0a1b2c3d4e5f');
```

---
Expand Down Expand Up @@ -127,10 +127,13 @@ SELECT df.result('a1b2c3d4');

### Instance IDs

Every durable function gets a unique 8-character hex ID (e.g., `a1b2c3d4`). Use this ID to:
- Check status: `SELECT df.status('a1b2c3d4')`
- Get result: `SELECT df.result('a1b2c3d4')`
- Cancel: `SELECT df.cancel('a1b2c3d4')`
Every durable function gets a unique UUID (e.g., `2f1c4d8e-9a3b-4c7d-8e1f-0a1b2c3d4e5f`). Use this ID to:
- Check status: `SELECT df.status('2f1c4d8e-9a3b-4c7d-8e1f-0a1b2c3d4e5f')`
- Get result: `SELECT df.result('2f1c4d8e-9a3b-4c7d-8e1f-0a1b2c3d4e5f')`
- Cancel: `SELECT df.cancel('2f1c4d8e-9a3b-4c7d-8e1f-0a1b2c3d4e5f')`

> **Note:** Instances created before upgrading to 0.2.3 keep their original
> 8-character IDs (e.g., `a1b2c3d4`); both formats remain valid.

### Durability

Expand Down
8 changes: 4 additions & 4 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,13 +220,13 @@ When serialized to JSON:

```sql
CREATE TABLE df.nodes (
id VARCHAR(8) PRIMARY KEY,
instance_id VARCHAR(8), -- Set by df.start()
id TEXT PRIMARY KEY, -- Full UUID (0.2.3+); legacy 8-char rows accepted after upgrade
instance_id TEXT, -- Set by df.start()
node_type TEXT NOT NULL, -- SQL, THEN, IF, JOIN, LOOP, etc.
query TEXT, -- SQL query or config JSON
result_name TEXT, -- Named result for $variable substitution
left_node VARCHAR(8), -- Left child ID
right_node VARCHAR(8), -- Right child ID
left_node TEXT, -- Left child ID
right_node TEXT, -- Right child ID
status TEXT DEFAULT 'pending',
result JSONB,
created_at TIMESTAMPTZ DEFAULT now()
Expand Down
22 changes: 22 additions & 0 deletions docs/upgrade-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,28 @@ what the upgrade script handles, and any backward compatibility considerations.

### v0.2.2 → v0.2.3

#### #129 Full UUID instance/node IDs
- **DDL change:** The six id columns — `df.instances.id`, `df.instances.root_node`, `df.nodes.id`, `df.nodes.instance_id`, `df.nodes.left_node`, `df.nodes.right_node` — widen from `VARCHAR(8)` to `TEXT`, and their six `*_format_chk` CHECK constraints relax from `^[0-9a-f]{8}$` to `^[0-9a-f]{8}(-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})?$`, which accepts a legacy 8-character id **or** a full canonical UUID. The legacy short id carried only 32 bits of entropy (~50% PK-collision chance near 65k instances); 0.2.3 generates full UUIDs (122 bits). The fresh-install DDL lives in `src/lib.rs`; the upgrade script is `sql/pg_durable--0.2.2--0.2.3.sql`.
- **Upgrade mechanics:** The id columns participate in four composite foreign keys (`nodes_instance_identity_fkey`, `nodes_left_node_same_instance_fkey`, `nodes_right_node_same_instance_fkey`, `instances_root_node_same_instance_fkey`), so the upgrade drops those FKs and the six format checks, runs `ALTER COLUMN ... TYPE text` (binary-coercible — the PRIMARY KEY / UNIQUE indexes are rebuilt in place, not dropped), then re-adds the format checks (new regex) and the FKs. All re-added constraints use the exact names, `NOT VALID`, and `DEFERRABLE INITIALLY DEFERRED` of a fresh install.
- **Scenario A considerations:** The upgrade reproduces the fresh-install constraints byte-for-byte (same names, same `NOT VALID` state, same FK definitions) and `varchar(8) → text` leaves `atttypmod = -1` just like a fresh `TEXT` column, so the `df` schema snapshot matches on both paths.
- **Scenario B1 considerations:** A 0.2.3 `.so` must keep emitting 8-character ids against pre-0.2.3 schemas (whose `VARCHAR(8)` columns and `^[0-9a-f]{8}$` checks would reject a UUID). `df.start()` gates id generation on `full_uuid_ids_enabled()` (`src/dsl.rs`), which returns true only when the installed extension version is `>= 0.2.3`. On older schemas it returns false and `short_id()` is used; on fresh/upgraded 0.2.3 schemas `full_uuid()` is used. This mirrors the existing `owner_scoped_vars_enabled()` / `legacy_login_role_schema()` version gates.
- **Scenario B2 considerations:** No data migration. Rows created before the upgrade keep their 8-character ids, which still satisfy the relaxed CHECK regex, so the new and old id formats coexist and in-flight BGW state stays valid. The `NOT VALID` re-add means existing rows are not rescanned.
- **Operational note — validating constraints later:** Because the relaxed CHECK regex also matches every legacy 8-character id, all pre-existing rows already conform. An operator who wants to drop the `NOT VALID` marker (so the constraints show as validated in the catalog) can run `ALTER TABLE df.instances VALIDATE CONSTRAINT instances_id_format_chk` (and the analogous statements for the other five `*_format_chk` constraints) at any later time; the validating scan cannot fail because every existing id already matches. This is optional — `NOT VALID` constraints are still enforced for new rows.
- **Operational note — lock window:** `ALTER COLUMN ... TYPE text` takes an `ACCESS EXCLUSIVE` lock on `df.nodes` and `df.instances`. The `varchar(8) → text` conversion is binary-coercible so there is no full table rewrite, but the `PRIMARY KEY`/`UNIQUE` indexes are rebuilt in place and the dropped/re-added foreign keys briefly lock both tables. The work scales with retained history (`df.nodes`/`df.instances` row counts), so run `ALTER EXTENSION pg_durable UPDATE` in a maintenance window and consider pausing `df.start()` traffic on installations with very large `df.nodes` tables.
- **Operational note — dependent objects:** Re-typing the six id columns will fail if a user-created view, rule, generated column, or SQL function depends on those columns. Check for dependencies before upgrading and drop/recreate them around the update:
```sql
SELECT DISTINCT dependent_ns.nspname AS schema, dependent.relname AS object
FROM pg_depend d
JOIN pg_rewrite r ON r.oid = d.objid
JOIN pg_class dependent ON dependent.oid = r.ev_class
JOIN pg_namespace dependent_ns ON dependent_ns.oid = dependent.relnamespace
JOIN pg_class src ON src.oid = d.refobjid
JOIN pg_namespace src_ns ON src_ns.oid = src.relnamespace
WHERE src_ns.nspname = 'df' AND src.relname IN ('nodes', 'instances')
AND dependent.relname NOT IN ('nodes', 'instances');
```
- **Constraint-drift note:** The id-format CHECK regex is maintained byte-identically in two places — the fresh-install DDL (`src/lib.rs`) and the upgrade script — and is validated by `pgspot` plus the functional B1/B2 tests, which insert real 8-character and UUID ids and assert accept/reject behavior. The `df` schema snapshot in `scripts/test-upgrade.sh` compares column `data_type` and constraint names but not CHECK bodies or FK deferrability flags; pinning those via `pg_get_constraintdef()`/`convalidated` is a possible future hardening of the schema comparison.

#### Rename duroxide provider schema to `_duroxide` for fresh installs
- **DDL change (df schema):** Adds `df.duroxide_schema()`, an `IMMUTABLE`/`PARALLEL SAFE` SQL function that returns the name of the schema holding the duroxide provider objects. Fresh 0.2.3 installs create the function (in `src/lib.rs`) returning `'_duroxide'`; the upgrade script `sql/pg_durable--0.2.2--0.2.3.sql` creates the same function returning `'duroxide'` so pre-existing installs keep using the legacy schema. Both bodies set `search_path = pg_catalog, pg_temp` to satisfy the pgspot gate.
- **DDL change (provider schema):** Fresh installs now run `CREATE SCHEMA _duroxide` (was `CREATE SCHEMA duroxide`). The upgrade script does **not** rename, drop, or move the existing `duroxide` schema — renaming an in-use provider schema would orphan the BGW's durable state. Upgraded installs therefore continue to use `duroxide`.
Expand Down
127 changes: 127 additions & 0 deletions sql/pg_durable--0.2.2--0.2.3.sql
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,18 @@

-- pg_durable upgrade: 0.2.2 → 0.2.3
--
-- This upgrade carries two independent schema changes:
-- 1. df.duroxide_schema() — reports which schema holds the duroxide provider
-- objects for this install (provider schema rename for fresh installs).
-- 2. Full UUID instance/node ids (#129) — widens the id columns to TEXT and
-- relaxes their format CHECKs to accept a full canonical UUID.
-- The two changes touch disjoint objects (a df function vs. the df.nodes /
-- df.instances columns), so the order below does not matter.

-- ============================================================================
-- Change 1: df.duroxide_schema()
-- ============================================================================
--
-- Introduces df.duroxide_schema(), a helper that reports which schema holds the
-- duroxide provider objects for this install. Fresh 0.2.3 installs create the
-- provider objects in the '_duroxide' schema (see lib.rs). Installs upgrading
Expand All @@ -19,3 +31,118 @@ CREATE FUNCTION df.duroxide_schema() RETURNS text
LANGUAGE sql IMMUTABLE PARALLEL SAFE
SET search_path = pg_catalog, pg_temp
AS $$ SELECT 'duroxide'::text $$;

-- ============================================================================
-- Change 2: Full UUID instance/node ids (#129)
-- ============================================================================
--
-- Widens the instance/node id columns from VARCHAR(8) to TEXT and relaxes the
-- id-format CHECK constraints so they accept a full canonical UUID in addition
-- to the legacy 8-character form. See issue #129: the old 8-hex ids carry only
-- 32 bits of entropy, giving a ~50% birthday-paradox collision chance around
-- 65k instances on the df.instances.id primary key. From 0.2.3 the extension
-- generates full UUIDs (122 bits) for new instances and nodes.
--
-- Backward compatibility: rows created before this upgrade keep their 8-char
-- ids, which still satisfy the new CHECK regex, so no data is rewritten and the
-- background worker's in-flight state stays valid. The new .so only emits full
-- UUIDs once the installed extension version reports >= 0.2.3 (see
-- full_uuid_ids_enabled() in src/dsl.rs), so a .so upgraded ahead of this
-- script keeps producing 8-char ids that the old VARCHAR(8) columns accept.
--
-- The id columns participate in composite foreign keys, so the constraints are
-- dropped before the type change and re-added afterwards. varchar(8) -> text is
-- binary-coercible, so the supporting PRIMARY KEY / UNIQUE indexes are rebuilt
-- in place by ALTER COLUMN TYPE and do not need to be dropped. The format CHECK
-- and FOREIGN KEY constraints are re-created exactly as a fresh 0.2.3 install
-- defines them (same names, NOT VALID, DEFERRABLE INITIALLY DEFERRED) so an
-- in-place upgrade yields a schema identical to a fresh install (Scenario A).
--
-- Operators are schema-qualified (OPERATOR(pg_catalog.~)) so name resolution
-- never depends on the session search_path, closing the CVE-2018-1058 vector.
-- Enforced by the pgspot CI gate (scripts/pgspot-gate.sh).

-- ----------------------------------------------------------------------------
-- 1. Drop the composite foreign keys that span the id columns being widened.
-- ----------------------------------------------------------------------------
ALTER TABLE df.nodes
DROP CONSTRAINT IF EXISTS nodes_instance_identity_fkey,
DROP CONSTRAINT IF EXISTS nodes_left_node_same_instance_fkey,
DROP CONSTRAINT IF EXISTS nodes_right_node_same_instance_fkey;

ALTER TABLE df.instances
DROP CONSTRAINT IF EXISTS instances_root_node_same_instance_fkey;

-- ----------------------------------------------------------------------------
-- 2. Drop the old 8-hex-only id-format CHECK constraints.
-- ----------------------------------------------------------------------------
ALTER TABLE df.instances
DROP CONSTRAINT IF EXISTS instances_id_format_chk,
DROP CONSTRAINT IF EXISTS instances_root_node_format_chk;

ALTER TABLE df.nodes
DROP CONSTRAINT IF EXISTS nodes_id_format_chk,
DROP CONSTRAINT IF EXISTS nodes_instance_id_format_chk,
DROP CONSTRAINT IF EXISTS nodes_left_node_format_chk,
DROP CONSTRAINT IF EXISTS nodes_right_node_format_chk;

-- ----------------------------------------------------------------------------
-- 3. Widen the id columns to TEXT (binary-coercible; indexes rebuilt in place).
-- ----------------------------------------------------------------------------
ALTER TABLE df.instances
ALTER COLUMN id TYPE text,
ALTER COLUMN root_node TYPE text;

ALTER TABLE df.nodes
ALTER COLUMN id TYPE text,
ALTER COLUMN instance_id TYPE text,
ALTER COLUMN left_node TYPE text,
ALTER COLUMN right_node TYPE text;

-- ----------------------------------------------------------------------------
-- 4. Re-add the id-format CHECK constraints, now accepting a legacy 8-char id
-- OR a full canonical UUID. NOT VALID so existing rows are not rescanned;
-- new rows are still validated. Existing rows already conform (their 8-char
-- ids match the relaxed regex), so an operator may later run
-- `ALTER TABLE df.instances VALIDATE CONSTRAINT instances_id_format_chk` (and
-- the other five constraints) in a maintenance window to drop the NOT VALID
-- marker; the scan cannot fail because every existing id already matches.
-- ----------------------------------------------------------------------------
ALTER TABLE df.instances
ADD CONSTRAINT instances_id_format_chk
CHECK (id OPERATOR(pg_catalog.~) '^[0-9a-f]{8}(-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})?$') NOT VALID,
ADD CONSTRAINT instances_root_node_format_chk
CHECK (root_node OPERATOR(pg_catalog.~) '^[0-9a-f]{8}(-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})?$') NOT VALID;

ALTER TABLE df.nodes
ADD CONSTRAINT nodes_id_format_chk
CHECK (id OPERATOR(pg_catalog.~) '^[0-9a-f]{8}(-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})?$') NOT VALID,
ADD CONSTRAINT nodes_instance_id_format_chk
CHECK (instance_id OPERATOR(pg_catalog.~) '^[0-9a-f]{8}(-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})?$') NOT VALID,
ADD CONSTRAINT nodes_left_node_format_chk
CHECK (left_node IS NULL OR left_node OPERATOR(pg_catalog.~) '^[0-9a-f]{8}(-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})?$') NOT VALID,
ADD CONSTRAINT nodes_right_node_format_chk
CHECK (right_node IS NULL OR right_node OPERATOR(pg_catalog.~) '^[0-9a-f]{8}(-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})?$') NOT VALID;

-- ----------------------------------------------------------------------------
-- 5. Re-add the composite foreign keys, identical to a fresh 0.2.3 install.
-- ----------------------------------------------------------------------------
ALTER TABLE df.nodes
ADD CONSTRAINT nodes_instance_identity_fkey
FOREIGN KEY (instance_id, submitted_by)
REFERENCES df.instances (id, submitted_by)
DEFERRABLE INITIALLY DEFERRED NOT VALID,
ADD CONSTRAINT nodes_left_node_same_instance_fkey
FOREIGN KEY (instance_id, left_node)
REFERENCES df.nodes (instance_id, id)
DEFERRABLE INITIALLY DEFERRED NOT VALID,
ADD CONSTRAINT nodes_right_node_same_instance_fkey
FOREIGN KEY (instance_id, right_node)
REFERENCES df.nodes (instance_id, id)
DEFERRABLE INITIALLY DEFERRED NOT VALID;

ALTER TABLE df.instances
ADD CONSTRAINT instances_root_node_same_instance_fkey
FOREIGN KEY (id, root_node)
REFERENCES df.nodes (instance_id, id)
DEFERRABLE INITIALLY DEFERRED NOT VALID;
16 changes: 13 additions & 3 deletions src/activities/load_function_graph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,15 @@ pub async fn execute(
"Loading function graph for instance: {instance_id}"
));

let instance_query = "SELECT root_node, r.rolname AS submitted_by
// Cast the id-typed columns (root_node here; id/left_node/right_node in the
// nodes query below) to text so this prepared statement's result descriptor
// is identical whether the columns are still VARCHAR(8) (a pre-0.2.3 schema
// or a not-yet-upgraded instance) or TEXT (0.2.3+). The BGW holds long-lived
// pooled connections with cached prepared statements; without the cast, an
// ALTER EXTENSION UPDATE that widens these columns to TEXT under a live
// connection makes the next execute fail with "cached plan must not change
// result type". See sql/pg_durable--0.2.2--0.2.3.sql.
let instance_query = "SELECT root_node::text AS root_node, r.rolname AS submitted_by
FROM df.instances i
LEFT JOIN pg_catalog.pg_roles r ON r.oid = i.submitted_by::oid
WHERE i.id = $1";
Expand Down Expand Up @@ -102,8 +110,10 @@ pub async fn execute(
}
}

let nodes_query = r#"SELECT n.id, n.node_type, n.query, n.result_name,
n.left_node, n.right_node,
// id/left_node/right_node cast to text for the same cached-plan-stability
// reason documented on instance_query above.
let nodes_query = r#"SELECT n.id::text AS id, n.node_type, n.query, n.result_name,
n.left_node::text AS left_node, n.right_node::text AS right_node,
r.rolname AS submitted_by,
n.database
FROM df.nodes n
Expand Down
Loading
Loading