Skip to content

Commit 7e8a035

Browse files
authored
feat: add multi-database support via df.start() database parameter (#41)
* feat: add multi-database support via df.start() database parameter Allow durable functions to target any database on the cluster by adding an optional 'database' parameter to df.start(). A single function invocation targets exactly one database; cross-database workflows can use dblink/postgres_fdw or separate durable functions. Changes: - Add 'database TEXT' column to df.nodes and df.instances - Add 'database' parameter to df.start() with pg_database validation - Thread database through FunctionNode, load_function_graph, execute_sql activity, orchestration, and connect_as_user() - Add E2E test (34_multi_database.sql) covering cross-database execution, invalid database rejection, and backward compatibility * fix: address PR review feedback for multi-database support - Handle SPI errors explicitly in database existence check (dsl.rs) instead of swallowing via .ok().flatten() - Move SET/RESET SESSION AUTHORIZATION outside DO block in test 34 where PL/pgSQL does not allow these statements * fix: include database in connection error message and add tests - Include database name in connect_as_user error message for easier debugging of multi-database connection failures - Add test 4: multi-node sequence graph targeting another database - Add test 5: deferred connection failure when database is dropped after df.start() (uses df.loop to verify clean failure)
1 parent 8c435e6 commit 7e8a035

9 files changed

Lines changed: 696 additions & 42 deletions

File tree

docs/multi-database.md

Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
# Multi-Database Support
2+
3+
**Status:** Draft
4+
**Date:** 2026-03-06
5+
6+
## Summary
7+
8+
Allow durable functions to execute SQL in any database on the same PostgreSQL cluster, not just the database where the extension is installed. A single function always runs against one database; cross-database workflows are deferred to a future enhancement.
9+
10+
## Motivation
11+
12+
Today, pg_durable can only execute SQL in the database configured by `pg_durable.database` (the same database the background worker connects to). Users with multiple databases in the same cluster—e.g., multi-tenant setups, or separate `analytics` / `app` databases—cannot use pg_durable to run durable functions against those databases.
13+
14+
pg_cron solved the same problem: it stores all metadata in one database but can schedule jobs against any database via `cron.schedule_in_database()`. We adopt a similar approach.
15+
16+
## Design Principles
17+
18+
1. **Extension lives in one database.** The `df` and `duroxide` schemas, background worker connection, and all metadata tables (`df.instances`, `df.nodes`) remain in the database specified by `pg_durable.database`. The extension is created (`CREATE EXTENSION pg_durable`) in only that one database.
19+
20+
2. **One database per function invocation.** A single `df.start()` call targets exactly one database. All SQL nodes in that invocation execute against that database. We explicitly do not support functions that span multiple databases in this iteration—it would complicate the DSL and orchestration for limited benefit. Users needing cross-database work can use `dblink` or `postgres_fdw` inside their SQL queries, or start separate durable functions per database.
21+
22+
3. **DSL is database-agnostic.** The DSL (`df.sql()`, `~>`, `&`, etc.) has no concept of "database." Database is purely a property of the *instance*, set at `df.start()` time. This keeps the DSL simple and avoids a combinatorial explosion of database-aware operators.
23+
24+
4. **Backwards compatible.** Omitting the database parameter defaults to `pg_durable.database` (today's behavior). No existing queries break.
25+
26+
## API Design
27+
28+
### Option Considered: New Function `df.start_in_database()`
29+
30+
pg_cron uses a separate function (`cron.schedule_in_database()`). This has the advantage of zero risk of breaking changes, but adds a parallel function that must be maintained in lockstep with `df.start()`.
31+
32+
### Chosen Approach: Optional Parameter on `df.start()`
33+
34+
Add an optional `database` parameter to `df.start()`:
35+
36+
```sql
37+
-- Existing signature (unchanged behavior):
38+
SELECT df.start(df.sql('SELECT 1'));
39+
SELECT df.start(df.sql('SELECT 1'), 'my-label');
40+
41+
-- New: specify target database
42+
SELECT df.start(df.sql('SELECT 1'), database => 'analytics');
43+
SELECT df.start(df.sql('SELECT 1'), 'my-label', 'analytics');
44+
```
45+
46+
The current signature is:
47+
48+
```sql
49+
df.start(fut text, label text DEFAULT NULL) → text
50+
```
51+
52+
The new signature becomes:
53+
54+
```sql
55+
df.start(fut text, label text DEFAULT NULL, database text DEFAULT NULL) → text
56+
```
57+
58+
**Why this is not a breaking change:**
59+
- The new parameter has a `DEFAULT NULL` value, so all existing calls continue to work unchanged.
60+
- PostgreSQL supports named parameter syntax (`database => 'analytics'`), so users can skip `label` and specify only `database`.
61+
- pgrx supports `default!()` for optional parameters, which maps to SQL `DEFAULT`.
62+
63+
**Why we prefer this over a separate function:**
64+
- One function to learn and document.
65+
- No risk of the two functions drifting apart.
66+
- Matches PostgreSQL's general convention of optional parameters over function proliferation.
67+
- `database => NULL` means "use the default" — clean and intuitive.
68+
69+
### Querying from Other Databases
70+
71+
Users calling `df.start()` from a *different* database than where the extension is installed need to use `dblink` or `postgres_fdw` to call into the extension database. The extension functions (`df.start`, `df.status`, `df.result`, etc.) only exist in the extension database.
72+
73+
**Alternative considered:** Installing stub functions in other databases that proxy via `dblink`. Rejected as over-engineering for now; advanced users can set this up themselves.
74+
75+
## Schema Changes
76+
77+
### `df.instances` Table
78+
79+
Add a `database` column:
80+
81+
```sql
82+
ALTER TABLE df.instances ADD COLUMN database TEXT;
83+
```
84+
85+
- `NULL` means "the extension database" (i.e., the database where `df.instances` itself lives). This is always unambiguous: the table only exists in the extension database, so NULL can only refer to that database. Even if `pg_durable.database` were later changed, the old tables would be gone (extension dropped) or still in the original database.
86+
- Non-NULL values name a different database on the same cluster.
87+
- Populated by `df.start()` from the `database` parameter.
88+
89+
### `df.nodes` Table
90+
91+
Add a `database` column:
92+
93+
```sql
94+
ALTER TABLE df.nodes ADD COLUMN database TEXT;
95+
```
96+
97+
Like `submitted_by` and `login_role`, this is denormalized from the instance for convenience—the `execute_sql` activity reads from `df.nodes` and should not need to join with `df.instances` to determine the target database. NULL means "the extension database," same as on `df.instances`.
98+
99+
### No Changes to DSL / `Durofut`
100+
101+
The `Durofut` struct (and by extension `df.sql()`, operators, etc.) does not need a database field. The database is an *instance-level* property, set once at `df.start()` and stamped onto all nodes at insertion time—exactly like `submitted_by` and `login_role` today.
102+
103+
## Implementation Changes
104+
105+
### 1. `df.start()`[src/dsl.rs](../src/dsl.rs)
106+
107+
- Add `database: default!(Option<&str>, "NULL")` parameter.
108+
- When `database` is `Some(db)`, validate it exists (see [Validation](#validation) below).
109+
- Pass `database` value (or NULL) to `insert_nodes()` and include it in the `INSERT INTO df.nodes` statement.
110+
- Include `database` in the `INSERT INTO df.instances` statement.
111+
112+
### 2. `FunctionNode`[src/types.rs](../src/types.rs)
113+
114+
- Add `pub database: Option<String>` field.
115+
- Serialized/deserialized naturally with serde.
116+
117+
### 3. `load_function_graph` Activity — [src/activities/load_function_graph.rs](../src/activities/load_function_graph.rs)
118+
119+
- Include `database` in the SELECT from `df.nodes`.
120+
- Populate `FunctionNode.database`.
121+
122+
### 4. `execute_sql` Activity — [src/activities/execute_sql.rs](../src/activities/execute_sql.rs)
123+
124+
- Add `database: Option<String>` to `ExecuteSqlInput`.
125+
- Pass it to `connect_as_user()`.
126+
127+
### 5. `connect_as_user()`[src/types.rs](../src/types.rs)
128+
129+
- Add `database: Option<&str>` parameter.
130+
- Use `database.unwrap_or_else(|| &target_database())` for connection options instead of hard-coding `target_database()`.
131+
132+
### 6. Orchestration — [src/orchestrations/execute_function_graph.rs](../src/orchestrations/execute_function_graph.rs)
133+
134+
- When building the `ExecuteSqlInput` JSON, include `node.database`.
135+
- No other changes needed—the orchestration itself doesn't care about the database.
136+
137+
### 7. Schema DDL — [src/lib.rs](../src/lib.rs)
138+
139+
- Add `database TEXT` column to both `CREATE TABLE` statements.
140+
141+
### 8. `execute_http` Activity
142+
143+
- No changes needed. HTTP requests don't target a database.
144+
145+
## Validation
146+
147+
When `df.start()` receives a non-NULL `database` parameter, we should validate that the database exists. This can be done via:
148+
149+
```sql
150+
SELECT 1 FROM pg_database WHERE datname = $1
151+
```
152+
153+
If the database doesn't exist, raise an error immediately rather than letting the background worker fail later with a confusing connection error.
154+
155+
**Role validation:** We do *not* need to validate that `login_role` can connect to the target database at `df.start()` time. The existing behavior already defers connection errors to activity execution time, which is appropriate for durable functions (the role/database might be created between `df.start()` and actual execution).
156+
157+
## Security Considerations
158+
159+
- **Role isolation is preserved.** The existing `login_role` / `submitted_by` / `SET ROLE` mechanism works identically regardless of target database. The user who calls `df.start()` determines the execution role, not the target database.
160+
- **`pg_hba.conf` applies.** The background worker's `login_role` connection to a different database is subject to the same `pg_hba.conf` rules as any other connection. If the role can't connect to that database, the activity fails with a clear error.
161+
- **No privilege escalation.** Targeting a different database doesn't grant additional privileges. The `SET ROLE` still constrains execution to the `submitted_by` role's permissions *in that database*.
162+
163+
## Observability
164+
165+
- `df.instances` and `df.nodes` gain a `database` column visible in `SELECT * FROM df.instances`.
166+
- Background worker logs already include the SQL being executed; adding the database name to log messages in `execute_sql` would be helpful.
167+
- `df.status()` and `df.result()` work unchanged—they query `df.instances`/`df.nodes` which are always in the extension database.
168+
169+
## Migration
170+
171+
- Existing rows in `df.instances` and `df.nodes` will have `database = NULL`, which correctly means "the extension database." No data migration needed.
172+
- The schema change is additive (`ADD COLUMN ... DEFAULT NULL`), safe for rolling upgrades.
173+
174+
## Testing
175+
176+
### Unit Tests
177+
178+
- Verify `df.start()` accepts the new parameter.
179+
- Verify NULL database defaults to `pg_durable.database`.
180+
181+
### E2E Tests
182+
183+
- **Same-database (regression):** Existing tests continue to pass without changes.
184+
185+
- **Cross-database test** (`NN_multi_database.sql`):
186+
187+
Must run as **superuser** (add to the superuser list in `test-e2e-local.sh`) because it creates/drops a database. However, the durable function itself should be submitted by `df_e2e_user` (non-privileged) to validate that role isolation works across databases.
188+
189+
```sql
190+
-- 1. Setup: create test database and grant access to df_e2e_user
191+
CREATE DATABASE test_multi_db;
192+
GRANT CONNECT ON DATABASE test_multi_db TO df_e2e_user;
193+
194+
-- 2. Create a table in the target database for df_e2e_user
195+
-- (use dblink since we can't switch databases mid-session)
196+
SELECT dblink_exec(
197+
'dbname=test_multi_db',
198+
'CREATE TABLE test_tbl (id INT, value TEXT)'
199+
);
200+
SELECT dblink_exec(
201+
'dbname=test_multi_db',
202+
'GRANT ALL ON test_tbl TO df_e2e_user'
203+
);
204+
205+
-- 3. Submit durable function as df_e2e_user targeting test_multi_db
206+
SET SESSION AUTHORIZATION df_e2e_user;
207+
CREATE TEMP TABLE _test_state (instance_id TEXT);
208+
INSERT INTO _test_state SELECT df.start(
209+
df.sql('INSERT INTO test_tbl VALUES (1, ''hello'')'),
210+
database => 'test_multi_db'
211+
);
212+
RESET SESSION AUTHORIZATION;
213+
214+
-- 4. Poll until complete (standard pattern)
215+
-- ...
216+
217+
-- 5. Verify the row exists in test_multi_db
218+
SELECT * FROM dblink(
219+
'dbname=test_multi_db',
220+
'SELECT value FROM test_tbl WHERE id = 1'
221+
) AS t(value TEXT);
222+
-- Assert value = 'hello'
223+
224+
-- 6. Cleanup
225+
DROP TABLE _test_state;
226+
DROP DATABASE test_multi_db;
227+
```
228+
229+
Key aspects this test validates:
230+
- `df.start()` with `database =>` parameter works
231+
- SQL executes in the target database, not the extension database
232+
- Role isolation: function runs as `df_e2e_user`, not the background worker's superuser
233+
- `login_role` can connect to the target database (requires `GRANT CONNECT`)
234+
235+
- **Invalid database:** Verify `df.start(..., database => 'nonexistent')` raises an immediate error (not a deferred background worker failure).
236+
237+
## Scope Exclusions
238+
239+
- **Cross-database functions:** A single function graph spanning multiple databases (e.g., read from `db1`, write to `db2`) is not supported. This would require per-node database targeting, which adds significant DSL and orchestration complexity. Users can achieve this via `dblink`/`postgres_fdw` within SQL queries, or by starting separate durable functions per database.
240+
- **Extension installation in multiple databases:** The extension continues to be installed in exactly one database. Supporting multiple installations would require distributed coordination between background workers.
241+
- **Connection pooling per database:** Each SQL activity creates a fresh connection (existing behavior). Per-database connection pooling could improve performance but is orthogonal to this feature.
242+
243+
## Summary of Changes
244+
245+
| File | Change |
246+
|------|--------|
247+
| `src/lib.rs` | Add `database TEXT` column to `df.instances` and `df.nodes` DDL |
248+
| `src/dsl.rs` | Add `database` param to `df.start()`, validate, pass to `insert_nodes()` |
249+
| `src/types.rs` | Add `database` to `FunctionNode`; add `database` param to `connect_as_user()` |
250+
| `src/activities/execute_sql.rs` | Add `database` to `ExecuteSqlInput`, pass to `connect_as_user()` |
251+
| `src/activities/load_function_graph.rs` | Include `database` in node SELECT |
252+
| `src/orchestrations/execute_function_graph.rs` | Include `node.database` in `ExecuteSqlInput` JSON |
253+
| `tests/e2e/sql/` | Add multi-database E2E test |

scripts/test-e2e-local.sh

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,7 @@ for run in $(seq 1 $REPEAT_COUNT); do
312312
# 27 creates users and tests permissions
313313
# 28 drops/creates the extension
314314
# 29 uses dblink and creates pg_durable in a different database
315+
# 34 creates/drops a database for multi-database testing
315316
PSQL_USER="$E2E_USER"
316317
if [[ "$test_name" == "00_requires_shared_preload" \
317318
|| "$test_name" == "22_cross_connection" \
@@ -320,7 +321,8 @@ for run in $(seq 1 $REPEAT_COUNT); do
320321
|| "$test_name" == 26_superuser_* \
321322
|| "$test_name" == "27_user_isolation" \
322323
|| "$test_name" == "28_bgw_lifecycle" \
323-
|| "$test_name" == "29_database_validation" ]]; then
324+
|| "$test_name" == "29_database_validation" \
325+
|| "$test_name" == "34_multi_database" ]]; then
324326
PSQL_USER="$PG_USER"
325327
fi
326328

src/activities/execute_sql.rs

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ pub struct ExecuteSqlInput {
1919
pub query: String,
2020
pub submitted_by: String,
2121
pub login_role: String,
22+
/// Target database (None = extension database)
23+
#[serde(skip_serializing_if = "Option::is_none")]
24+
pub database: Option<String>,
2225
}
2326

2427
/// Execute a SQL query as the submitting user and return results as JSON
@@ -31,12 +34,24 @@ pub async fn execute(
3134
serde_json::from_str(&input_json).map_err(|e| format!("Invalid execute_sql input: {e}"))?;
3235

3336
ctx.trace_info(format!(
34-
"Executing SQL as '{}' (connected as '{}'): {}",
35-
input.submitted_by, input.login_role, input.query
37+
"Executing SQL as '{}' (connected as '{}'){}: {}",
38+
input.submitted_by,
39+
input.login_role,
40+
input
41+
.database
42+
.as_ref()
43+
.map(|db| format!(" in database '{db}'"))
44+
.unwrap_or_default(),
45+
input.query
3646
));
3747

3848
// Create a single connection as login_role, SET ROLE to submitted_by
39-
let mut conn = connect_as_user(&input.login_role, &input.submitted_by).await?;
49+
let mut conn = connect_as_user(
50+
&input.login_role,
51+
&input.submitted_by,
52+
input.database.as_deref(),
53+
)
54+
.await?;
4055

4156
match sqlx::query(&input.query).fetch_all(&mut conn).await {
4257
Ok(rows) => {

src/activities/load_function_graph.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,8 @@ pub async fn execute(
5858
r#"SELECT id, node_type, query, result_name,
5959
left_node, right_node,
6060
submitted_by::text AS submitted_by,
61-
login_role::text AS login_role
61+
login_role::text AS login_role,
62+
database
6263
FROM df.nodes WHERE instance_id = '{instance_id}'"#
6364
);
6465

@@ -79,6 +80,7 @@ pub async fn execute(
7980
right_node: row.get("right_node"),
8081
submitted_by: row.get::<String, _>("submitted_by"),
8182
login_role: row.get::<String, _>("login_role"),
83+
database: row.get("database"),
8284
};
8385
nodes.insert(id, node);
8486
}

0 commit comments

Comments
 (0)