fix: reliability hardening — pool isolation, client recovery, SSRF, loop safety - #221
fix: reliability hardening — pool isolation, client recovery, SSRF, loop safety#221Ismaël Mejía (iemejia) wants to merge 5 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
This PR hardens pg_durable against several reliability and safety failure modes in both the background worker (duroxide runtime) and the user-session client path, plus adds guardrails around loop execution, SSRF protections, and result expansion limits.
Changes:
- Isolated extension/epoch polling onto a dedicated 1-connection
poll_poolin the BGW to avoid starvation under load. - Added safety limits/guardrails:
$name.*row-set expansion cap, CGNAT SSRF blocklist, loop iteration maximum, and fail-fast behavior for malformed loop condition JSON. - Made the per-backend Duroxide client recoverable after connection-level failures (reset-on-error so later calls can reinitialize).
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| src/worker.rs | Adds dedicated polling pool and wraps env var mutation in unsafe with a SAFETY note. |
| src/client.rs | Replaces permanent OnceLock<Client> with resettable per-backend thread_local client and adds connection-error detection. |
| src/types.rs | Adds $name.* expansion row limit and serialization support for loop iteration counter; adds unit tests. |
| src/ssrf.rs | Blocks RFC6598 CGNAT range and adds tests. |
| src/orchestrations/execute_function_graph.rs | Adds loop iteration persistence/limit and fails malformed loop config JSON instead of silently looping. |
| src/lib.rs | Adds pg_tests for the new regression scenarios (client error heuristic, CGNAT blocking, row-set expansion, loop_iteration serde, malformed loop config validate). |
| src/dsl.rs | Initializes new FunctionInput.loop_iteration field at df.start() time. |
Comments suppressed due to low confidence (2)
src/worker.rs:223
- run_duroxide_runtime() closes mgmt_pool but never closes the newly added poll_pool. This can leave the dedicated polling connection open longer than necessary and makes shutdown less orderly. Consider closing both pools when run_duroxide_runtime() exits.
run_until_extension_dropped_or_shutdown(
&poll_pool,
duroxide_runtime,
EXTENSION_DROP_POLL_INTERVAL,
SHUTDOWN_CHECK_INTERVAL,
epoch_id.as_deref(),
)
.await;
}
src/orchestrations/execute_function_graph.rs:590
- execute_loop_node() always uses FunctionInput as the continue_as_new payload. That matches ExecuteFunctionGraph::execute(), but ExecuteSubtree expects a different JSON shape (graph/node_id/results/vars/label). If a LOOP node ever runs inside a JOIN/RACE branch (i.e., inside ExecuteSubtree), continue_as_new will restart the subtree orchestration with an incompatible input and fail with "Missing graph". Either explicitly reject LOOP nodes in ExecuteSubtree with a clear error, or change the subtree orchestration input/continue_as_new strategy so loops are supported in parallel branches.
// duroxide 0.1.1: continue_as_new returns an awaitable future - return it directly
return ctx
.continue_as_new(serde_json::to_string(&new_input).unwrap_or(graph.instance_id.clone()))
.await
| // concurrent readers of environment at this point. | ||
| // | ||
| // SAFETY: This is called during initialization before any pool or async | ||
| // task has started, and the BGW tokio runtime uses new_current_thread() | ||
| // (no worker threads). No other thread can be reading env concurrently. | ||
| unsafe { |
There was a problem hiding this comment.
Good catch. The "before any pool or async task has started" claim was inaccurate since mgmt_pool is already created at that point. Rewrote the SAFETY comment to focus on the actual invariant: the BGW uses new_current_thread() (no additional OS threads spawned) and PostgreSQL's fork model guarantees no other threads exist in this process that could be reading the environment concurrently.
| /// Test-accessible wrapper for is_connection_error. | ||
| pub fn is_connection_error_for_test(err: &str) -> bool { | ||
| is_connection_error(err) | ||
| } |
There was a problem hiding this comment.
Agreed. Changed to pub(crate) and gated with #[cfg(any(test, feature = "pg_test"))] so it's only compiled for test builds and doesn't leak into the public API surface.
…oop safety C4 (Critical): Isolate epoch/extension polling onto dedicated pool Added a separate 1-connection poll_pool used exclusively for extension- existence checks and epoch sentinel heartbeats. This prevents activity work (graph loading, status updates) on the management pool from starving the health-check loop and causing spurious runtime shutdowns under load. C5 (Critical): Recoverable client on connection failure Replaced OnceLock<Client> with thread_local RefCell<Option<Client>> that auto-resets on connection errors (broken pipe, pool timeout, connection refused, etc.). Subsequent calls transparently re-create the client pool instead of failing permanently for the session's lifetime. H5 (High): Wrap std::env::set_var in unsafe block with safety comment Added SAFETY documentation explaining why set_var is acceptable in these specific contexts (single-threaded tokio runtime, no concurrent readers). Prepares for Rust 2024 edition where set_var becomes unsafe. H6 (High): Block CGNAT range 100.64.0.0/10 in SSRF protection Added RFC 6598 Carrier-Grade NAT addresses to the IPv4 blocklist. These are used by cloud providers for internal routing and could expose metadata endpoints via DNS rebinding attacks. M1 (Medium): Row-count limit on $name.* expansion (10,000 rows) expand_row_set() now rejects result sets exceeding 10,000 rows to prevent unbounded SQL string allocation from large query results. M7 (Medium): Maximum loop iteration safeguard (100,000 iterations) Loops now track iteration count across continue_as_new generations via FunctionInput.loop_iteration. After 100,000 iterations (~27 hours at minimum 1s rate), the loop fails with a clear error. M8 (Medium): Malformed loop condition config now fails instead of looping Previously, if a LOOP node's condition config was unparseable JSON, the code silently fell through to infinite looping. Now it returns an error immediately, surfacing the parse failure to the user.
Add unit tests and pg_tests that validate the following fixes don't regress:
C5 - Client connection error detection:
- Unit tests in client::tests verifying is_connection_error() heuristic
correctly identifies connection refused, broken pipe, pool timeout,
reset by peer, and closed errors while ignoring normal SQL errors
- pg_test test_is_connection_error_detects_failures for integration layer
H6 - CGNAT SSRF blocklist:
- Unit test blocks_cgnat_rfc6598 verifies 100.64.0.0/10 range is blocked
with edge cases at 100.63.x (allowed) and 100.128.x (allowed)
- pg_test test_ssrf_blocks_cgnat_range for integration layer
M1 - Row-set expansion limit:
- test_row_set_expansion_rejects_oversized_result: verifies >10,000 rows
returns an error mentioning the limit
- test_row_set_expansion_accepts_within_limit: verifies 100 rows works
- pg_test test_row_set_expansion_limit_via_dsl: end-to-end via substitute_all
M7 - Loop iteration counter:
- test_function_input_loop_iteration_serialization: round-trip preserves count
- test_function_input_loop_iteration_defaults_to_zero: backward compat with
old FunctionInput JSON missing the field
M8 - Malformed loop condition detection:
- test_malformed_loop_condition_detected_at_validate: verifies DSL-time
behavior (structural validation passes, runtime will catch the error)
…emantics
The test expected validate_recursive() to pass when condition_node is a
plain string ("nonexist"), but for_each_config_child now correctly
rejects condition_node values that don't deserialize as a valid Durofut.
Update the test to assert the expected error and clarify comments.
aecc629 to
4ef8747
Compare
e35f5ae to
7ec9c0d
Compare
|
This PR has been split into 6 focused PRs to ease review and reduce rebase conflicts. Each PR is independent and can be reviewed/merged in any order.
Closing this PR in favor of the above. |
Summary
Addresses 7 reliability and safety issues from the audit (2 Critical, 2 High, 3 Medium). All fixes compile cleanly and pass unit tests.
C4 (Critical): Isolate epoch/extension polling onto dedicated pool
Problem: The management pool (default 6 connections) was shared between epoch heartbeats and activity work. Under high concurrency (>6 simultaneous orchestrations), all connections could be consumed by status updates, starving the heartbeat loop — causing the worker to interpret a pool-timeout as "extension dropped" and spuriously shut down.
Fix: Created a separate 1-connection
poll_poolused exclusively for extension-existence checks and epoch sentinel heartbeats. Activity work remains on the management pool but can no longer starve health checks.C5 (Critical): Recoverable client on connection failure
Problem:
OnceLock<Client>is permanent — once set, it can never be replaced. If connections die (PG restart,pg_terminate_backend, network partition), alldf.start()/df.signal()/df.cancel()from existing sessions fail permanently.Fix: Replaced with
thread_local! { RefCell<Option<Client>> }that detects connection-level errors (broken pipe, pool timeout, connection refused) and resets the client so the next call transparently re-creates the pool.H5 (High): Wrap
std::env::set_varin unsafe blockProblem:
set_varbecomesunsafein Rust 2024 edition due to potential data races.Fix: Wrapped both call sites with
unsafe {}blocks and SAFETY documentation explaining why they're sound (single-threaded tokio runtime, no concurrent env readers, called before pool creation).H6 (High): Block CGNAT range in SSRF protection
Problem:
100.64.0.0/10(RFC 6598 Carrier-Grade NAT) was missing from the IPv4 blocklist. In cloud environments, CGNAT addresses are sometimes used for internal metadata/service endpoints exploitable via DNS rebinding.Fix: Added
[100, b, ..] if (64..=127).contains(&b)tocheck_blocked_ipv4()with corresponding test (edge cases at 100.63.x and 100.128.x).M1 (Medium): Row-count limit on
$name.*expansionProblem:
expand_row_set()had no limit on rows, allowing unbounded SQL string allocation from large result sets.Fix: Added
MAX_ROWSET_EXPANSION = 10,000— expansions exceeding this fail with a clear error suggesting pagination.M7 (Medium): Maximum loop iteration safeguard
Problem: Infinite loops (
df.loop()without break or condition) ran indefinitely with no upper bound.Fix: Added
loop_iterationcounter toFunctionInput(persisted acrosscontinue_as_newgenerations). After 100,000 iterations (~27 hours at 1s minimum), the loop fails with a clear error.M8 (Medium): Malformed loop condition fails instead of infinite loop
Problem: If a LOOP node's condition JSON was unparseable,
if let Ok(config)silently fell through, creating an infinite loop with no exit condition.Fix: Changed to explicit
matchthat returns an error on parse failure, surfacing the problem immediately.Verification
cargo check— no errors, only pre-existingextract_hostwarningcargo test --lib types::— 29/29 passcargo test --lib ssrf::— 37/37 pass (including new CGNAT test)cargo fmt— clean