Skip to content

Commit d4f7a3f

Browse files
Haider ZahidCopilot
andcommitted
Fix caller transaction graph handoff
Track the originating PostgreSQL transaction while the worker waits for a newly started graph. Use durable, bounded probes so long commits resume, rollbacks fail cleanly, transient database errors retry, and wait history remains bounded. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 0551e71 commit d4f7a3f

12 files changed

Lines changed: 909 additions & 83 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ Pre-1.0 note: while `pg_durable` is in major version `0`, minor releases may inc
1212

1313
### Fixed
1414

15+
- **Caller-transaction handoff:** `df.start()` now tracks the originating transaction until it commits or aborts, so legal caller transactions lasting more than five seconds no longer leave a `pending` `df.instances` row paired with a failed engine execution. Graph admission uses durable backoff and bounded-history compaction rather than holding a worker connection while it waits.
1516
- **Worker connection role names:** catalog role names are now passed verbatim when opening workflow connections, preventing quote-wrapped names from being reinterpreted as a different role.
1617
- **Restricted HTTP transport (#342):** restricted allow-list builds now require HTTPS so credentials and request bodies cannot be sent over plaintext HTTP; development-only `http-allow-all` builds continue to permit HTTP.
1718

USER_GUIDE.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,13 @@ runs in. It changes nothing about the durable function that gets started:
169169
| `'caller'` (default) | Joins the caller's transaction; a `ROLLBACK` discards the durable function. |
170170
| `'new'` | Runs in its own transaction on a separate session; **survives a rollback of the caller's transaction**. |
171171

172+
The caller transaction may remain open for an arbitrary amount of time after
173+
`df.start()` returns. The worker follows that transaction's outcome without
174+
holding an execution connection: it begins the workflow after commit and
175+
terminates the engine record without executing SQL after rollback. Rolling back
176+
only the savepoint that contains `df.start()` is also treated as a rollback even
177+
if the enclosing transaction later commits.
178+
172179
`'new'` provides the same rollback-survival outcome as Oracle autonomous
173180
transactions and `REQUIRES_NEW` propagation for **asynchronously started work**.
174181
It is not a synchronous autonomous routine: only the durable launch has

docs/ARCHITECTURE.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -452,6 +452,19 @@ pub async fn execute(
452452
}
453453
```
454454

455+
`df.start()` commits the duroxide start independently while its `df.instances`
456+
and `df.nodes` writes remain in the caller's transaction. New orchestration
457+
inputs therefore carry the originating top-level transaction ID. Graph
458+
admission uses a single-shot probe: load immediately when the graph is visible,
459+
otherwise inspect `pg_xact_status()`, then wait with deterministic durable
460+
timers while the transaction is in progress. An abort terminates the engine
461+
record without executing SQL; a committed transaction whose graph is still
462+
absent identifies a savepoint rollback. The wait periodically uses
463+
`continue_as_new` to bound replay history and never holds a management
464+
connection between probes. Historical orchestration inputs omit the transaction
465+
ID and continue scheduling the original load activity with its original input,
466+
preserving in-flight replay compatibility.
467+
455468
### Node Execution
456469

457470
Internal node handlers return `NodeResult`, a `Result` whose error arm is a typed
@@ -809,4 +822,3 @@ SELECT df.start(
809822
2. **Phase 2 (Execution)**: Background worker's duroxide runtime picks up the orchestration. `LoadFunctionGraph` activity loads the graph. Orchestration walks the graph, scheduling activities for each step. Results flow between nodes via `$variable` substitution. Loops use `continue_as_new` for durability.
810823

811824
The key insight is that **graph construction is synchronous** (in user transaction) while **execution is asynchronous and durable** (in background worker via duroxide replay).
812-

docs/E2E_TESTING.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ The test suite is organized into 23 files. Files `01`–`09` open with `SET SESS
7272
| `15_rls.sql` | RLS on `df.instances` / `df.nodes` / `df.vars` — per-user visibility, cross-user cancel/signal denied, column-level UPDATE, superuser bypass, per-user variable isolation |
7373
| `16_heartbeat.sql` | Worker heartbeat liveness — `df._worker_epoch.last_seen_at` advances over time |
7474
| `52_node_id_collision_across_instances.sql` | Cross-instance node-ID collision — two instances own the same 8-hex node id; asserts composite-PK coexistence, that `(instance_id, id)` addresses exactly one row, `df.result()` is instance-scoped, and a scoped `update_node_status`-style UPDATE affects exactly one row (issue #129) |
75+
| `68_long_caller_transaction.sql` | Caller-transaction handoff beyond five seconds, transient graph-probe failure, whole rollback, and savepoint rollback |
7576

7677
### Build-Phase Specific
7778

docs/upgrade-testing.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,15 @@ gate, so they never need to be added to the exclude list.
203203
Each schema-changing PR should add a section here documenting what changed,
204204
what the upgrade script handles, and any backward compatibility considerations.
205205

206+
### v0.2.6 → v0.2.7
207+
208+
#### Transaction-aware graph admission
209+
- **Runtime change (no DDL):** New caller-mode starts include the top-level PostgreSQL transaction ID in the root orchestration input. A versioned single-shot activity probes graph visibility and `pg_xact_status()`; the deterministic orchestration waits with capped backoff and periodically `continue_as_new`s to bound replay history.
210+
- **Rollback behavior:** A whole-transaction abort fails the df-less engine record without executing SQL. A committed origin transaction with no visible graph is reported distinctly as a likely savepoint rollback. Transient graph/pool errors return a retry state rather than terminally failing the orchestration.
211+
- **Replay compatibility:** Historical `FunctionInput` payloads deserialize with no origin transaction ID and schedule the original `pg_durable::activity::load-function-graph` activity with the same raw instance-ID input. Existing in-flight history therefore retains its operation name, order, and input bytes. The new activity name and input shape are used only for starts created by the new binary.
212+
- **Scenario A/B2 considerations:** No extension schema or persisted `df` data changes; no upgrade DDL is required.
213+
- **Scenario B1 considerations:** The new binary uses PostgreSQL's built-in `pg_current_xact_id()` / `pg_xact_status()` functions and existing `df.instances` / `df.nodes` columns, all available across the supported v0.2.2+ provider line. New starts work against old extension schemas without runtime schema detection.
214+
206215
### v0.2.5 → v0.2.6
207216

208217
#### Remove `df.ensure_durofut()`

0 commit comments

Comments
 (0)