Skip to content

Commit a9f86bc

Browse files
authored
Surface execution-history lookup failures in df.instance_executions (#168) (#225)
df.instance_executions() previously swallowed every failure (runtime build, provider connect, list_executions, get_execution_info) into an empty rowset. Because a completed instance always has at least one execution row, an empty result was indistinguishable from a silently failed lookup, masking real errors (issue #168). These failures now raise an explicit error, while the genuinely-empty case (a non-existent or non-owned instance, gated by the RLS existence check) still returns an empty rowset. Also validate limit_count >= 1 and cap it at 10000, matching df.list_instances. Add e2e regression test tests/e2e/sql/05_instance_executions.sql.
1 parent aa1b1fe commit a9f86bc

2 files changed

Lines changed: 134 additions & 21 deletions

File tree

src/monitoring.rs

Lines changed: 40 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,14 @@ pub fn instance_info(
192192
}
193193

194194
/// Get the last N executions for an eternal durable function (loop).
195+
///
196+
/// Distinguishes "this instance genuinely has no execution history yet" (empty
197+
/// rowset) from "the execution-history lookup failed" (explicit error). The
198+
/// latter — failing to build the runtime, connect to the duroxide store, list
199+
/// executions, or fetch a specific execution's info — now raises an error
200+
/// instead of being silently swallowed into an empty rowset. A completed
201+
/// instance always has at least one execution row, so an empty result for one
202+
/// previously masked a real lookup failure. See issue #168.
195203
#[pg_extern(schema = "df")]
196204
pub fn instance_executions(
197205
instance_id: &str,
@@ -206,11 +214,18 @@ pub fn instance_executions(
206214
name!(output, Option<String>),
207215
),
208216
> {
217+
if limit_count < 1 {
218+
pgrx::error!("limit_count must be at least 1");
219+
}
220+
let limit_count = limit_count.min(10000);
221+
209222
let pg_conn_str = postgres_connection_string();
210223
let provider_schema = backend_duroxide_schema();
211224
let instance_id_owned = instance_id.to_string();
212225

213226
// Ownership check: SPI goes through RLS, so non-owned instances are invisible.
227+
// A non-existent or non-owned instance legitimately has no history to show,
228+
// so an empty rowset (not an error) is the correct response here.
214229
let exists: bool = Spi::get_one_with_args(
215230
"SELECT EXISTS(SELECT 1 FROM df.instances WHERE id = $1)",
216231
&[instance_id.into()],
@@ -228,29 +243,31 @@ pub fn instance_executions(
228243
.build()
229244
{
230245
Ok(rt) => rt,
231-
Err(_) => return TableIterator::new(vec![]),
246+
Err(e) => pgrx::error!("failed to create async runtime for instance_executions: {e}"),
232247
};
233248

234-
let results = rt.block_on(async {
235-
let store = match new_backend_provider(&pg_conn_str, provider_schema).await {
236-
Ok(s) => s,
237-
Err(_) => return vec![],
238-
};
249+
let results: Result<Vec<(i64, String, i64, i64, Option<String>)>, String> =
250+
rt.block_on(async {
251+
let store = new_backend_provider(&pg_conn_str, provider_schema).await?;
239252

240-
let client = Client::new(store);
253+
let client = Client::new(store);
241254

242-
let execution_ids = match client.list_executions(&instance_id_owned).await {
243-
Ok(ids) => ids,
244-
Err(_) => return vec![],
245-
};
255+
let execution_ids = client
256+
.list_executions(&instance_id_owned)
257+
.await
258+
.map_err(|e| format!("failed to list executions: {e:?}"))?;
246259

247-
let mut sorted_ids: Vec<_> = execution_ids.into_iter().collect();
248-
sorted_ids.sort_by(|a, b| b.cmp(a));
249-
let limited: Vec<_> = sorted_ids.into_iter().take(limit_count as usize).collect();
260+
let mut sorted_ids: Vec<_> = execution_ids.into_iter().collect();
261+
sorted_ids.sort_by(|a, b| b.cmp(a));
262+
let limited: Vec<_> = sorted_ids.into_iter().take(limit_count as usize).collect();
263+
264+
let mut rows = Vec::new();
265+
for exec_id in limited {
266+
let info = client
267+
.get_execution_info(&instance_id_owned, exec_id)
268+
.await
269+
.map_err(|e| format!("failed to fetch info for execution {exec_id}: {e:?}"))?;
250270

251-
let mut rows = Vec::new();
252-
for exec_id in limited {
253-
if let Ok(info) = client.get_execution_info(&instance_id_owned, exec_id).await {
254271
let duration_ms = info
255272
.completed_at
256273
.map(|end| end.saturating_sub(info.started_at))
@@ -264,11 +281,13 @@ pub fn instance_executions(
264281
info.output,
265282
));
266283
}
267-
}
268-
rows
269-
});
284+
Ok(rows)
285+
});
270286

271-
TableIterator::new(results)
287+
match results {
288+
Ok(rows) => TableIterator::new(rows),
289+
Err(e) => pgrx::error!("df.instance_executions: execution history lookup failed: {e}"),
290+
}
272291
}
273292

274293
/// Get system-wide durable function metrics.
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
-- Copyright (c) Microsoft Corporation.
2+
-- Licensed under the PostgreSQL License.
3+
4+
-- Regression test for issue #168:
5+
-- df.instance_executions() can return no rows for completed instances.
6+
--
7+
-- A completed instance always has at least one execution row in the duroxide
8+
-- store (the instance and its first execution are created in the same
9+
-- transaction). Previously, instance_executions() swallowed any
10+
-- runtime/provider/lookup error into an empty rowset, making "no execution
11+
-- history yet" indistinguishable from "the lookup failed".
12+
--
13+
-- This test guards the contract:
14+
-- * a completed instance exposes >= 1 execution row, and
15+
-- * an invalid limit_count is rejected with an explicit error rather than
16+
-- being silently masked by an empty rowset.
17+
SET SESSION AUTHORIZATION df_e2e_user;
18+
19+
CREATE TEMP TABLE _test_state (instance_id TEXT);
20+
21+
INSERT INTO _test_state SELECT df.start('SELECT 42', 'test-instance-executions-168');
22+
23+
DO $$
24+
DECLARE
25+
inst_id TEXT;
26+
status TEXT;
27+
exec_count INT;
28+
top_exec_id BIGINT;
29+
top_status TEXT;
30+
BEGIN
31+
SELECT instance_id INTO inst_id FROM _test_state;
32+
RAISE NOTICE 'Testing instance: %', inst_id;
33+
34+
SELECT df.wait_for_completion(inst_id) INTO status;
35+
IF lower(status) != 'completed' THEN
36+
RAISE EXCEPTION 'TEST FAILED: expected completed, got %', status;
37+
END IF;
38+
39+
-- Core assertion (issue #168): a completed instance must expose >= 1 execution.
40+
SELECT count(*) INTO exec_count FROM df.instance_executions(inst_id, 1);
41+
IF exec_count < 1 THEN
42+
RAISE EXCEPTION 'TEST FAILED: instance_executions(inst_id, 1) returned % rows for a completed instance, expected >= 1', exec_count;
43+
END IF;
44+
45+
-- The returned execution should have a valid id and a non-empty status.
46+
-- We deliberately do NOT assert status = 'completed': df.status() /
47+
-- wait_for_completion track the pg_durable df.instances table, while
48+
-- instance_executions reports duroxide's per-execution status. The two are
49+
-- updated independently and can briefly diverge under concurrent
50+
-- background-worker load. Issue #168 is about empty rows, not the exact
51+
-- status string, so asserting row presence + a well-formed status is the
52+
-- correct, race-free check.
53+
SELECT e.execution_id, e.status INTO top_exec_id, top_status
54+
FROM df.instance_executions(inst_id, 1) e;
55+
IF top_exec_id < 1 THEN
56+
RAISE EXCEPTION 'TEST FAILED: execution_id should be >= 1, got %', top_exec_id;
57+
END IF;
58+
IF top_status IS NULL OR length(trim(top_status)) = 0 THEN
59+
RAISE EXCEPTION 'TEST FAILED: execution status should be non-empty, got %', coalesce(top_status, '<null>');
60+
END IF;
61+
62+
-- The default limit should also return the execution history.
63+
SELECT count(*) INTO exec_count FROM df.instance_executions(inst_id);
64+
IF exec_count < 1 THEN
65+
RAISE EXCEPTION 'TEST FAILED: instance_executions(inst_id) (default limit) returned % rows, expected >= 1', exec_count;
66+
END IF;
67+
68+
RAISE NOTICE 'TEST PASSED: instance_executions returns execution history for a completed instance';
69+
END $$;
70+
71+
-- limit_count < 1 must raise an explicit error, not silently return empty rows.
72+
DO $$
73+
DECLARE
74+
inst_id TEXT;
75+
got_error BOOLEAN := false;
76+
BEGIN
77+
SELECT instance_id INTO inst_id FROM _test_state;
78+
BEGIN
79+
PERFORM * FROM df.instance_executions(inst_id, 0);
80+
EXCEPTION WHEN OTHERS THEN
81+
got_error := true;
82+
END;
83+
84+
IF NOT got_error THEN
85+
RAISE EXCEPTION 'TEST FAILED: instance_executions(inst_id, 0) should raise an error for limit_count < 1';
86+
END IF;
87+
88+
RAISE NOTICE 'TEST PASSED: instance_executions rejects limit_count < 1';
89+
END $$;
90+
91+
DROP TABLE _test_state;
92+
93+
RESET SESSION AUTHORIZATION;
94+
SELECT 'TEST PASSED' AS result;

0 commit comments

Comments
 (0)