Skip to content

Commit 27a8b8c

Browse files
authored
Derive node statuses from execution lineage (#263)
Add status_details metadata to df.nodes and stamp each node transition with deterministic execution lineage. Use the lineage to fence stale loop-generation writes while preserving older-schema write compatibility. Move df.instance_nodes() and df.explain() to a shared read-time inference model. It reports skipped branches and superseded loop-body nodes without adding new physical node statuses. Keep df.instance_nodes(text, integer) callable for old schemas and upgraded callers as a shape-compatible adapter. It no longer queries execution history and returns one row per node with execution_id = 1. Update the 0.2.3 to 0.2.4 migration, user/API docs, SQL skill guidance, and E2E coverage for the inferred-status model.
1 parent fbd902a commit 27a8b8c

11 files changed

Lines changed: 1295 additions & 183 deletions

File tree

.agents/skills/pg-durable-sql/SKILL.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -153,8 +153,9 @@ df.list_instances(status_filter TEXT DEFAULT NULL, limit_count INT DEFAULT 100)
153153
df.instance_info(instance_id TEXT)
154154
-- Columns: instance_id, label, function_name, function_version, current_execution_id, status, output
155155

156-
df.instance_nodes(instance_id TEXT, last_n_executions INT DEFAULT 5)
157-
-- Columns: execution_id, node_id, node_type, query, result_name, left_node, right_node, status, result, updated_at
156+
df.instance_nodes(instance_id TEXT)
157+
-- Columns: node_id, node_type, query, result_name, left_node, right_node, status, result, status_details, inferred_status, inferred_status_from_ancestor_id, updated_at
158+
-- inferred_status reinterprets status with a derived 'skipped' (untaken if/then/race branch) and loop re-entry as 'pending'
158159

159160
df.instance_executions(instance_id TEXT, limit_count INT DEFAULT 5)
160161
-- Columns: execution_id, status, event_count, duration_ms, output

USER_GUIDE.md

Lines changed: 65 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1459,17 +1459,70 @@ SELECT * FROM df.instance_executions('a1b2c3d4', 20);
14591459

14601460
### Function Nodes
14611461

1462-
See the function graph structure:
1462+
See the function graph structure, one row per node, with both the stored status
1463+
and a derived status that interprets the durable-execution state model:
14631464

14641465
```sql
1465-
-- Last 5 executions (default)
14661466
SELECT * FROM df.instance_nodes('a1b2c3d4');
1467-
1468-
-- Last 10 executions
1469-
SELECT * FROM df.instance_nodes('a1b2c3d4', 10);
14701467
```
14711468

1472-
**Columns:** `execution_id`, `node_id`, `node_type`, `query`, `result_name`, `left_node`, `right_node`, `status`, `result`
1469+
**Columns:** `node_id`, `node_type`, `query`, `result_name`, `left_node`, `right_node`, `status`, `result`, `status_details`, `inferred_status`, `inferred_status_from_ancestor_id`, `updated_at`
1470+
1471+
- **`status`** — the status physically stored on the node: `pending`, `running`,
1472+
`completed`, or `failed`.
1473+
- **`status_details`** — JSON execution metadata written by the worker (the
1474+
`execution_id` generation stamp). You normally do not read this directly; it is
1475+
what `inferred_status` is derived from.
1476+
- **`inferred_status`** — the stored status reinterpreted top-down from the root
1477+
node. It adds one derived value, **`skipped`**, and reconciles loop re-entry:
1478+
- `skipped` — a node on a branch that was decided against and will not (further)
1479+
run: the untaken arm of a completed `df.if()`, the right side of a failed
1480+
`df.then()`, or the abandoned arm of a resolved `df.race()`.
1481+
- a node from a previous loop iteration reads back as `pending` (it will re-run),
1482+
rather than showing the old iteration's terminal status.
1483+
- a node that physically ran keeps its stored `completed`/`failed`/`running`.
1484+
- **`inferred_status_from_ancestor_id`** — when `inferred_status` was *derived*
1485+
from an ancestor (a `skipped` branch, or a superseded loop node), this names the
1486+
ancestor node that drove the inference; otherwise `NULL`.
1487+
1488+
> This is read-time interpretation only — it does not change how the graph
1489+
> executes. `df.race()` still abandons the losing branch and `df.join()` still
1490+
> waits for every branch; `inferred_status` only changes how those outcomes are
1491+
> *reported*.
1492+
1493+
#### Node state transitions
1494+
1495+
```mermaid
1496+
stateDiagram-v2
1497+
[*] --> pending
1498+
pending --> running: scheduled (written)
1499+
running --> completed: success (written)
1500+
running --> failed: error (written)
1501+
1502+
pending --> skipped: ancestor failed / not taken / race lost (derived)
1503+
running --> skipped: ancestor failed / not taken / race lost (derived)
1504+
completed --> pending: superseded by newer execution (derived)
1505+
failed --> pending: superseded by newer execution (derived)
1506+
1507+
classDef physical fill:#dfd,stroke:#080;
1508+
classDef derived stroke-dasharray: 5 5,fill:#eee;
1509+
classDef hybrid fill:#dfd,stroke:#080,stroke-dasharray: 5 5;
1510+
class running,completed,failed physical
1511+
class skipped derived
1512+
class pending hybrid
1513+
```
1514+
1515+
Solid edges are **physical**: the worker writes them and they persist in
1516+
`df.nodes.status` (the `status` column). Dashed edges are **derived**: nothing
1517+
writes them — they are how `df.instance_nodes()` *reinterprets* a stored status
1518+
(via `inferred_status`) when a node's `execution_id` no longer matches its live
1519+
lineage. `pending` (drawn with a dashed border) is **both**: it is written once by
1520+
`df.start()`, and it is *also* what the read path reports for a node superseded by a
1521+
newer loop iteration. That terminal → `pending` edge is therefore not a write — it
1522+
is what a previous iteration's stored `completed`/`failed` (or an in-flight
1523+
`running`) *looks like* once a newer iteration supersedes it and has not yet
1524+
re-reached the node.
1525+
14731526

14741527
### System Metrics (Explicit Grant Required)
14751528

@@ -2118,14 +2171,17 @@ This shows the graph structure with status markers on each node:
21182171
- `✓ Completed` — node finished successfully
21192172
- `✗ Failed` — node encountered an error
21202173
- `⏳ Running` — node was in progress when the instance failed or was inspected
2174+
- `⊘ Skipped` — branch was decided away (untaken `if` arm, right side of a failed `then`, or race loser) so the node will never run
21212175
- `○ Pending` — node never started
21222176

2177+
The markers reflect the same derived status as the `inferred_status` column of `df.instance_nodes()`, so the tree view and the node table always agree.
2178+
21232179
`df.explain()` tells you **where** in the graph execution stopped, but not **why**. For that, inspect individual nodes.
21242180

21252181
#### Step 4: Inspect Individual Nodes
21262182

21272183
```sql
2128-
SELECT node_id, node_type, result_name, status,
2184+
SELECT node_id, node_type, result_name, status, inferred_status,
21292185
left(query, 80) AS query,
21302186
left(result, 120) AS result
21312187
FROM df.instance_nodes('a1b2c3d4');
@@ -2137,6 +2193,8 @@ This shows every node in the graph with its status and result. Key things to loo
21372193
|---------------|---------------|
21382194
| A node with `status = 'failed'` | This is the node that caused the failure |
21392195
| A node with `result = NULL` and `status = 'completed'` | The SQL returned no rows |
2196+
| A node with `inferred_status = 'skipped'` | The node was on a branch that was decided against (untaken `df.if()` arm, right side of a failed `df.then()`, or losing `df.race()` arm) and never (further) ran |
2197+
| `inferred_status = 'pending'` on a node that previously completed | The node belongs to an earlier loop iteration and will re-run |
21402198
| Result contains `{"jsonb": null}` | Possible type extraction issue — see "Known Limitations" below |
21412199
| A `running` node with no result | Execution was interrupted at this node |
21422200

docs/api-reference.md

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,68 @@ SELECT df.result('a1b2c3d4');
351351

352352
---
353353

354+
### df.instance_nodes(instance_id)
355+
356+
Returns one row per node in an instance's graph, with each node's stored physical
357+
status alongside a read-time **derived** status. This is the primary tool for
358+
inspecting *where* an instance is and *why* a branch did or did not run.
359+
360+
| Parameter | Type | Auto-wrap | Description |
361+
|-----------|------|-----------|-------------|
362+
| `instance_id` | TEXT | ❌ Literal | Target instance ID |
363+
364+
Return columns:
365+
366+
| Column | Type | Description |
367+
|--------|------|-------------|
368+
| `node_id` | TEXT | Node id (unique within the instance) |
369+
| `node_type` | TEXT | `SQL`, `THEN`, `IF`, `JOIN`, `RACE`, `LOOP`, `SLEEP`, `SIGNAL`, `HTTP`, … |
370+
| `query` | TEXT | SQL text for `SQL` nodes; a JSON config for compound/leaf nodes |
371+
| `result_name` | TEXT | Capture name (`\|=>`), or `NULL` |
372+
| `left_node` | TEXT | First child node id, or `NULL` |
373+
| `right_node` | TEXT | Second child node id, or `NULL` |
374+
| `status` | TEXT | **Physical** stored status: `pending`, `running`, `completed`, `failed` |
375+
| `result` | JSONB | Result/error payload for `completed`/`failed` nodes, else `NULL` |
376+
| `status_details` | JSONB | Worker-written node metadata (see below), or `NULL` if never transitioned |
377+
| `inferred_status` | TEXT | **Derived** status: physical status plus `skipped`, and loop re-entry surfaced as `pending` |
378+
| `inferred_status_from_ancestor_id` | TEXT | Ancestor node id that drove a derived `skipped`/`pending`, or `NULL` |
379+
| `updated_at` | TIMESTAMPTZ | Last physical status change |
380+
381+
**`status_details` JSON contract.** Written by the worker through the
382+
`update-node-status` activity and stored verbatim in `df.nodes.status_details`:
383+
384+
- `execution_id` — the node's full segmented execution path, e.g.
385+
`a1b2c3d4::1::7f9a0012::1`. Parse it positionally: the second `::`-token is the
386+
root loop generation (used to detect superseded loop iterations), and the
387+
trailing segments encode `JOIN`/`RACE` sub-orchestration lineage.
388+
389+
`inferred_status` and `inferred_status_from_ancestor_id` are **computed at read
390+
time** and are not stored in `df.nodes.status_details`.
391+
392+
**Derived statuses.** `skipped` is never written to `df.nodes.status` (it is not a
393+
member of the `nodes_status_chk` constraint) — it exists only in `inferred_status`:
394+
395+
- `skipped` — a non-terminal node whose nearest terminal ancestor already decided
396+
the branch will not run: the untaken arm of a completed `df.if()`, the right side
397+
of a failed `df.then()`/`~>`, or the abandoned (still-running) loser of a resolved
398+
`df.race()`. A loser that already reached `completed`/`failed` keeps its physical
399+
status.
400+
- `pending` (derived) — a node from an older loop generation that a newer ancestor
401+
generation has superseded; it will re-run, so it reads back as `pending` rather
402+
than showing the previous iteration's terminal status.
403+
404+
`df.explain()` renders the same derived status for each node, so the two views
405+
always agree.
406+
407+
```sql
408+
SELECT node_id, node_type, status AS physical, inferred_status,
409+
status_details->>'execution_id' AS execution_id
410+
FROM df.instance_nodes('a1b2c3d4')
411+
ORDER BY node_id;
412+
```
413+
414+
---
415+
354416
## Variable Functions
355417

356418
### df.setvar(name, value)

sql/pg_durable--0.2.3--0.2.4.sql

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,3 +256,87 @@ DROP INDEX IF EXISTS df.idx_instances_status;
256256
CREATE INDEX idx_instances_status ON df.instances(status, created_at DESC, id);
257257
DROP INDEX IF EXISTS df.idx_instances_created_at;
258258
CREATE INDEX idx_instances_created_at ON df.instances(created_at DESC, id);
259+
260+
-- ============================================================================
261+
-- Node state-transition model: add df.nodes.status_details (PR #263).
262+
--
263+
-- The background worker stamps every node transition with the orchestration
264+
-- generation "{instance_id}::{execution_id}" in status_details->>'execution_id'.
265+
-- df.instance_nodes() parses that stamp to derive the pending/skipped statuses
266+
-- and to reconcile loop re-entry, and update_node_status() uses it to fence stale
267+
-- writes. The column is nullable and is deliberately NOT added to any user INSERT
268+
-- grant on df.nodes -- only the background worker writes it.
269+
--
270+
-- Backward compatibility (Scenario B1): the new .so probes for this column at
271+
-- runtime (update_node_status) and degrades to the plain status/result write when
272+
-- it is absent, so a pre-0.2.4 schema keeps running until this upgrade applies.
273+
--
274+
-- Upgrade ordering (in-flight instances): the worker's orchestration history
275+
-- changed shape in this release -- update_node_status activity inputs gained an
276+
-- execution_id field, and JOIN/RACE branch sub-orchestrations now use
277+
-- deterministic composed instance ids instead of auto-generated ones. duroxide
278+
-- replays by exact equality on recorded inputs/ids, so instances in flight across
279+
-- the upgrade cannot resume; drain or recreate them before upgrading (the same
280+
-- constraint documented for issue #129).
281+
-- ============================================================================
282+
ALTER TABLE df.nodes ADD COLUMN status_details JSONB;
283+
284+
COMMENT ON COLUMN df.nodes.status_details IS
285+
'Execution metadata written by the worker (never inserted by users). JSON object with key '
286+
'"execution_id": the orchestration instance_id::execution_id stamp recorded when the node last '
287+
'transitioned. df.instance_nodes() parses it to derive pending/skipped statuses; see USER_GUIDE.md.';
288+
289+
-- ============================================================================
290+
-- df.instance_nodes(): one row per node with derived status (PR #263).
291+
--
292+
-- The return shape changed: the per-execution fan-out is gone, replaced by a
293+
-- single row per node carrying the stored status plus the derived status_details,
294+
-- inferred_status and inferred_status_from_ancestor_id columns. Keep the old
295+
-- two-argument overload callable for binary/schema compatibility, but implement
296+
-- it as a one-row-per-node adapter that ignores last_n_executions and returns a
297+
-- dummy execution_id of 1. Remove its default argument so one-argument calls
298+
-- resolve to the new API after the schema upgrade. The definitions below are the
299+
-- pgrx-generated fresh-install DDL (src/monitoring.rs) verbatim, so the Scenario
300+
-- A schema snapshot (function arguments + result type) matches a fresh 0.2.4
301+
-- install.
302+
-- ============================================================================
303+
DROP FUNCTION IF EXISTS df.instance_nodes(text, integer);
304+
305+
CREATE FUNCTION df."instance_nodes"(
306+
"instance_id_param" TEXT, /* &str */
307+
"_last_n_executions" INT /* i32 */
308+
) RETURNS TABLE (
309+
"execution_id" bigint, /* i64 */
310+
"node_id" TEXT, /* alloc::string::String */
311+
"node_type" TEXT, /* alloc::string::String */
312+
"query" TEXT, /* core::option::Option<alloc::string::String> */
313+
"result_name" TEXT, /* core::option::Option<alloc::string::String> */
314+
"left_node" TEXT, /* core::option::Option<alloc::string::String> */
315+
"right_node" TEXT, /* core::option::Option<alloc::string::String> */
316+
"status" TEXT, /* core::option::Option<alloc::string::String> */
317+
"result" TEXT, /* core::option::Option<alloc::string::String> */
318+
"updated_at" timestamp with time zone /* core::option::Option<pgrx::datum::time_stamp_with_timezone::TimestampWithTimeZone> */
319+
)
320+
STRICT
321+
LANGUAGE c /* Rust */
322+
AS 'MODULE_PATHNAME', 'instance_nodes_wrapper';
323+
324+
CREATE FUNCTION df."instance_nodes"(
325+
"instance_id_param" TEXT /* &str */
326+
) RETURNS TABLE (
327+
"node_id" TEXT, /* alloc::string::String */
328+
"node_type" TEXT, /* alloc::string::String */
329+
"query" TEXT, /* core::option::Option<alloc::string::String> */
330+
"result_name" TEXT, /* core::option::Option<alloc::string::String> */
331+
"left_node" TEXT, /* core::option::Option<alloc::string::String> */
332+
"right_node" TEXT, /* core::option::Option<alloc::string::String> */
333+
"status" TEXT, /* core::option::Option<alloc::string::String> */
334+
"result" TEXT, /* core::option::Option<alloc::string::String> */
335+
"status_details" TEXT, /* core::option::Option<alloc::string::String> */
336+
"inferred_status" TEXT, /* alloc::string::String */
337+
"inferred_status_from_ancestor_id" TEXT, /* core::option::Option<alloc::string::String> */
338+
"updated_at" timestamp with time zone /* core::option::Option<pgrx::datum::time_stamp_with_timezone::TimestampWithTimeZone> */
339+
)
340+
STRICT
341+
LANGUAGE c /* Rust */
342+
AS 'MODULE_PATHNAME', 'instance_nodes_v2_wrapper';

0 commit comments

Comments
 (0)