Skip to content

Commit 74d81bd

Browse files
authored
Add label_filter, keyset pagination, and timestamps to df.list_instances (#278)
Add a new arity-disjoint overload of df.list_instances exposing label_filter, opaque keyset cursors (after_cursor/next_cursor), and created_at/completed_at, while leaving the existing 6-column function frozen to preserve binary backward compatibility (Scenario B1). Closes #87, addresses #146. Add a partial idx_instances_label(label, created_at DESC, id) WHERE label IS NOT NULL so label-filtered pages stay seek-based instead of scanning unrelated instances, and a redundant created_at <= $ts leading conjunct on the keyset predicate for a sargable btree bound (result set unchanged). Both index definitions stay byte-identical between the fresh-install DDL and the upgrade script for Scenario A.
1 parent e17b7e4 commit 74d81bd

7 files changed

Lines changed: 825 additions & 101 deletions

File tree

USER_GUIDE.md

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1420,20 +1420,51 @@ LOOP
14201420

14211421
### List All Instances
14221422

1423+
`df.list_instances` has two overloads, selected by argument count: a **basic** form (0–2 args) returning 6 columns, and a **paginated** form (3–4 args) returning 9 columns (adding `created_at`, `completed_at`, `next_cursor`). To reach the paginated form, pass at least three arguments, using `NULL` for filters you want to skip.
1424+
14231425
```sql
1424-
-- All instances
1426+
-- Basic overload (6 columns: instance_id, label, function_name, status, execution_count, output)
1427+
1428+
-- All instances (most recent 100)
14251429
SELECT * FROM df.list_instances();
14261430

14271431
-- Filter by status (lowercase)
14281432
SELECT * FROM df.list_instances('running');
14291433
SELECT * FROM df.list_instances('completed');
14301434
SELECT * FROM df.list_instances('failed');
14311435

1432-
-- With limit
1436+
-- With a page size
14331437
SELECT * FROM df.list_instances(NULL, 10);
1438+
1439+
-- Paginated overload (9 columns: the six above plus created_at, completed_at, next_cursor)
1440+
1441+
-- Filter by label (issue #87) — three args selects the paginated overload
1442+
SELECT * FROM df.list_instances(NULL, 100, 'nightly-report');
1443+
```
1444+
1445+
**Basic overload columns:** `instance_id`, `label`, `function_name`, `status`, `execution_count`, `output`
1446+
1447+
**Paginated overload columns:** the six above plus `created_at`, `completed_at`, `next_cursor`
1448+
1449+
`created_at` and `completed_at` are the submit/completion timestamps from `df.instances`. `completed_at` is `NULL` until the run reaches `completed` (it stays `NULL` for `failed`/`cancelled`). Rows are returned newest-first (`created_at DESC`, then `id` as a stable tiebreaker).
1450+
1451+
#### Paginating large result sets (issue #146)
1452+
1453+
The **paginated overload** uses keyset (cursor) pagination. Each page carries a `next_cursor` value (identical on every row of the page); pass it back as the `after_cursor` argument to fetch the next page. `next_cursor` is `NULL` on the final page. (The basic 0–2 argument form does not return `next_cursor` — pass at least three arguments to paginate.)
1454+
1455+
```sql
1456+
-- Page 1: three args (NULL status, limit 50, NULL label) selects the paginated overload
1457+
SELECT instance_id, status, next_cursor
1458+
FROM df.list_instances(NULL, 50, NULL);
1459+
1460+
-- Page 2: pass page 1's next_cursor as the 4th argument
1461+
SELECT instance_id, status, next_cursor
1462+
FROM df.list_instances(NULL, 50, NULL, '323032362d...');
14341463
```
14351464

1436-
**Columns:** `instance_id`, `label`, `function_name`, `status`, `execution_count`, `output`
1465+
Filters (`status_filter`, `label_filter`) are sticky across pages — keep passing the same filter values along with the cursor. The cursor is opaque; pass it back verbatim. A malformed cursor raises an error rather than silently restarting from page 1.
1466+
1467+
> **Note:** `next_cursor` advances over `df.instances` independently of the per-row execution-metadata lookup. In a brief start-up window a freshly-submitted instance can appear in `df.instances` before its execution metadata is queryable and is omitted from that page; in the rare case where *every* row of a non-final page is omitted, the page returns zero rows (so `next_cursor` can't be read) — retry shortly.
14371468
14381469
### Instance Details
14391470

docs/api-reference.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,50 @@ If you reuse a label across runs, multiple instances can match — pass the spec
337337

338338
---
339339

340+
### df.list_instances(...)
341+
342+
Lists your durable function instances, newest-first. Results are RLS-scoped to your own instances (superusers see all). The function comes in **two overloads**, distinguished by argument count:
343+
344+
| Overload | Call shape | Returned columns |
345+
|----------|-----------|------------------|
346+
| **Basic** (0–2 args) | `df.list_instances([status_filter[, limit_count]])` | 6 columns (no timestamps or cursor) |
347+
| **Paginated** (3–4 args) | `df.list_instances(status_filter, limit_count, label_filter[, after_cursor])` | 9 columns (adds `created_at`, `completed_at`, `next_cursor`) |
348+
349+
The two overloads have non-overlapping arities (basic matches 0–2 arguments, paginated matches 3–4), so a call is never ambiguous. To reach the paginated overload you must pass at least the first three arguments — use `NULL` for any you don't want to filter on (e.g. `df.list_instances(NULL, 100, NULL)`).
350+
351+
| Parameter | Type | Default | Description |
352+
|-----------|------|---------|-------------|
353+
| `status_filter` | TEXT | `NULL` (basic only) | Only instances with this status (lowercase: `pending`, `running`, `completed`, `failed`, `cancelled`). `NULL` = any. |
354+
| `limit_count` | INTEGER | `100` (basic only) | Max rows per page (must be ≥ 1; capped at 10000). |
355+
| `label_filter` | TEXT | — (required to select the paginated overload) | Only instances whose label equals this value (issue #87). `NULL` = any. |
356+
| `after_cursor` | TEXT | `NULL` | Opaque keyset cursor from a prior page's `next_cursor`; returns the page that sorts strictly after it (issue #146). `NULL` = first page. |
357+
358+
> `status_filter` and `limit_count` default only in the basic overload. The paginated overload requires all three of `status_filter`, `limit_count`, and `label_filter` to be supplied positionally (pass `NULL` to skip a filter); only `after_cursor` is optional.
359+
360+
**Basic overload columns:** `instance_id`, `label`, `function_name`, `status`, `execution_count`, `output`.
361+
362+
**Paginated overload columns:** the six above plus `created_at`, `completed_at`, `next_cursor`.
363+
364+
- `created_at` / `completed_at` are the submit and completion timestamps from `df.instances`. `completed_at` is `NULL` until the instance reaches `completed` (it stays `NULL` for `failed`/`cancelled`).
365+
- Rows are ordered `created_at DESC, id ASC` (deterministic, served by the `(created_at DESC, id)` indexes on `df.instances`).
366+
- `next_cursor` is the token to fetch the page *after* this one. It is the same value on every row of a page and `NULL` on the final page.
367+
368+
```sql
369+
-- Basic overload: most recent 50 completed runs (6 columns, no timestamps/cursor)
370+
SELECT instance_id, status FROM df.list_instances('completed', 50);
371+
372+
-- Paginated overload: all instances carrying a given label (9 columns)
373+
SELECT instance_id, status, created_at, completed_at, next_cursor
374+
FROM df.list_instances(NULL, 100, 'nightly-report');
375+
376+
-- Keyset pagination: pass the previous page's next_cursor back in as after_cursor
377+
SELECT * FROM df.list_instances(NULL, 50, NULL, '323032362d...');
378+
```
379+
380+
> **Pagination note:** `next_cursor` is computed over `df.instances` (the authoritative, RLS-filtered set) independently of the per-row execution-metadata lookup, so it normally advances correctly. In a brief start-up window an instance can exist in `df.instances` before its execution metadata is queryable; such a row is omitted from the current page. Edge case: if *every* row of a non-final page is omitted this way, that page returns zero rows and you cannot read `next_cursor` (it is carried on each row) — retry shortly. A malformed `after_cursor` raises an error; always pass a `next_cursor` value back verbatim.
381+
382+
---
383+
340384
### df.result(instance_id)
341385

342386
Gets instance result (for completed instances).

docs/upgrade-testing.md

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -239,12 +239,21 @@ what the upgrade script handles, and any backward compatibility considerations.
239239
- **Scenario B2 considerations:** `ADD PRIMARY KEY (instance_id, id)` sets `NOT NULL` on both columns and builds a unique index over existing rows. `id` was already the old primary key (implicitly `NOT NULL`). `instance_id` carries a `nodes_instance_id_present_chk CHECK (instance_id IS NOT NULL)` constraint, but it was added `NOT 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 NULL `instance_id`, the `ADD PRIMARY KEY` (and the explicit `ALTER COLUMN instance_id SET NOT NULL` that 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 the `df.nodes` primary-key index in place. Because `ADD PRIMARY KEY` / `ALTER COLUMN ... SET NOT NULL` take an `ACCESS EXCLUSIVE` lock on `df.nodes` and rebuild the index, on a large `df.nodes` the upgrade blocks concurrent access for a period that scales with the table's size; run `ALTER EXTENSION UPDATE` inside a maintenance window and consider `SET lock_timeout` for 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 new `df.start()` calls, drain or cancel in-flight instances, then run the upgrade.
240240

241241
#### Indexes on df.instances for ordered/paginated listing (issues #167/#87/#146)
242-
- **DDL change (df schema):** `df.list_instances()` lists rows newest-first (`ORDER BY created_at DESC`), optionally filtered by status. The pre-0.2.4 `idx_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 create `idx_instances_status(status, created_at DESC, id)` and a new `idx_instances_created_at(created_at DESC, id)`. The upgrade script `sql/pg_durable--0.2.3--0.2.4.sql` drops any existing copies (`DROP INDEX IF EXISTS`) then recreates both indexes with the same definitions. The trailing `id` prepares the access path for the keyset pagination planned for `df.list_instances` (`ORDER BY created_at DESC, id ASC`); `df.list_instances()` does not yet order by `id`, 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.
242+
- **DDL change (df schema):** `df.list_instances()` lists rows newest-first (`ORDER BY created_at DESC`), optionally filtered by status. The pre-0.2.4 `idx_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 create `idx_instances_status(status, created_at DESC, id)`, a new `idx_instances_created_at(created_at DESC, id)`, and a partial `idx_instances_label(label, created_at DESC, id) WHERE label IS NOT NULL` for the label-filtered path (issue #87). The upgrade script `sql/pg_durable--0.2.3--0.2.4.sql` drops any existing copies (`DROP INDEX IF EXISTS`) then recreates all three indexes with the same definitions. The trailing `id` is the keyset tiebreaker for `df.list_instances` (`ORDER BY created_at DESC, id ASC`). At the time these indexes were added `df.list_instances()` did not yet order by `id`; the **label filter, keyset pagination, timestamps** change below realizes that order, and these indexes then serve both the sort and the `after_cursor` range predicate as an index scan.
243243
- **Design note (RLS):** `df.instances` has a row-level-security policy (`instances_user_isolation`) filtering `submitted_by = current_user::regrole`, so a per-user index leading with `submitted_by` would be more selective for an individual session. The `created_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. A `submitted_by`-leading refinement can be revisited if profiling shows the per-user path dominates.
244-
- **Scenario A considerations:** The upgrade script recreates the indexes with column lists and `DESC`/tiebreaker ordering identical to the fresh-install DDL, so `pg_get_indexdef()` for `idx_instances_status` and `idx_instances_created_at` is byte-identical on both paths and the Scenario A snapshot matches.
244+
- **Scenario A considerations:** The upgrade script recreates the indexes with column lists, partial predicate, and `DESC`/tiebreaker ordering identical to the fresh-install DDL, so `pg_get_indexdef()` for `idx_instances_status`, `idx_instances_created_at`, and `idx_instances_label` is byte-identical on both paths and the Scenario A snapshot matches.
245245
- **Scenario B1 considerations:** The new `.so` works against all previous schemas. The `df.list_instances()` queries (`ORDER BY created_at DESC LIMIT`, optionally `WHERE status = $1`) reference only the `created_at`/`status` columns, which exist in every shipped `df.instances` schema; against a schema that has not run `ALTER EXTENSION UPDATE` the 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.
246246
- **Scenario B2 considerations:** No data migration. `DROP INDEX` / `CREATE INDEX` rebuild access-path metadata only; row data is untouched. The `CREATE INDEX` statements take a `SHARE` lock on `df.instances` while they build, so on a large `df.instances` run `ALTER EXTENSION UPDATE` in a maintenance window for the same reasons noted above.
247247

248+
#### `df.list_instances()` — label filter, keyset pagination, timestamps (issues #87/#146)
249+
- **DDL change (df schema):** This adds a **new overload** of `df.list_instances` rather than changing the existing one. The prior two-argument function (`df.list_instances(status_filter text, limit_count integer)` → 6 columns) is left in place **unchanged**. A new four-argument overload `df.list_instances(status_filter text, limit_count int, label_filter text, after_cursor text DEFAULT NULL)` is added, returning three extra trailing columns (`created_at`, `completed_at`, `next_cursor`) and backed by a distinct symbol (`list_instances_paged_wrapper`). Only `after_cursor` defaults, giving the overload a minimum arity of 3; the basic function matches calls of arity 0–2 and the paginated one arity 3–4, so the two never overlap and PostgreSQL never reports "function is not unique". The paginated overload orders rows `created_at DESC, id ASC`, served as an index scan by the `(created_at DESC, id)` indexes added in the previous subsection. The upgrade script `sql/pg_durable--0.2.3--0.2.4.sql` adds only the new overload (no `DROP FUNCTION`).
250+
- **Why an overload instead of changing the function (Scenario B1):** Changing the existing two-argument/6-column function in place would break Scenario B1. A customer running 0.2.2/0.2.3 who loads the new `.so` but never runs `ALTER EXTENSION UPDATE` still has the old 6-column SQL declaration bound to the `list_instances_wrapper` symbol. If the new `.so` implemented that symbol with a 9-column shape, the returned tuple would not match the catalog declaration and calls would error. Keeping the old function frozen (same 6-column shape, same `list_instances_wrapper` symbol) preserves that contract; the new capability ships as a separate function/symbol. This mirrors the repo's `wait_for_completion``await_instance` precedent of keeping both functions rather than mutating one.
251+
- **Design note (cursor):** `after_cursor` is an opaque keyset token. Each page carries `next_cursor` (identical on every row of the page, `NULL` on the final page); the client passes it back as `after_cursor` to fetch the next page. The cursor encodes `(created_at, id)` of the last row, so pagination is deterministic and seek-based (no `OFFSET`). `next_cursor` is computed over `df.instances` (RLS-filtered) independently of the per-row execution-metadata lookup, so it advances correctly even when a row is transiently skipped; a malformed cursor raises an error rather than silently restarting.
252+
- **Scenario A considerations:** The `CREATE FUNCTION df."list_instances"(...)` block in the upgrade script is the pgrx-generated fresh-install DDL for the new overload (`src/monitoring.rs`) copied verbatim — same argument list, defaults, and `RETURNS TABLE` column list/types. The old two-argument function is unchanged from the 0.2.3 base install. So on both paths the catalog ends with exactly the same two `df.list_instances` overloads, and the Scenario A snapshot matches a fresh 0.2.4 install.
253+
- **Scenario B1 considerations:** Both overloads of the new `.so`'s `list_instances` read only columns that exist in every shipped `df.instances` schema (`id`, `label`, `status`, `created_at`, `completed_at`), so they run correctly against a pre-0.2.4 schema that has not run `ALTER EXTENSION UPDATE` — the paginated path simply falls back to a sort without the `(created_at DESC, id)` index. Crucially, the old catalog still binds existing 0/1/2-argument callers to `list_instances_wrapper`, which the new `.so` still exports with the original 6-column shape, so those calls keep working unchanged.
254+
- **Scenario B2 considerations:** No data migration. The `CREATE FUNCTION` adds catalog metadata only; `df.instances` rows are untouched. The new `created_at`/`completed_at` result columns are read from columns that already exist and are already populated on every prior install.
255+
- **Dependent-object note:** Because the upgrade only adds a function (no `DROP FUNCTION`), no customer-owned object that depends on the existing two-argument `df.list_instances` is affected — there is nothing to drop or repoint.
256+
248257
### v0.2.2 → v0.2.3
249258

250259
#### Rename duroxide provider schema to `_duroxide` for fresh installs

0 commit comments

Comments
 (0)