forked from microsoft/pg_durable
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathclient.rs
More file actions
310 lines (268 loc) · 9.63 KB
/
Copy pathclient.rs
File metadata and controls
310 lines (268 loc) · 9.63 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
// Copyright (c) Microsoft Corporation.
// Licensed under the PostgreSQL License.
//! Cached client infrastructure for user session calls
//!
//! This module provides cached Tokio runtime and Duroxide client for efficient
//! df.start(), df.signal(), and df.cancel() calls from user sessions.
//!
//! The client is lazily initialized on first use and can automatically
//! recover from connection failures by re-creating the pool on next call.
use std::cell::RefCell;
use std::sync::OnceLock;
use duroxide::Client;
use pgrx::prelude::*;
use tokio::runtime::Runtime;
use crate::types::{backend_duroxide_schema, new_backend_provider, postgres_connection_string};
/// Cached tokio runtime for client operations.
static CLIENT_RUNTIME: OnceLock<Runtime> = OnceLock::new();
// Per-backend cached Duroxide client. Uses thread_local + RefCell because
// PostgreSQL backends are single-threaded forked processes. This allows
// the client to be reset on connection failures (unlike OnceLock which
// is permanent).
thread_local! {
static DUROXIDE_CLIENT: RefCell<Option<Client>> = const { RefCell::new(None) };
}
/// Check whether the background worker has finished initializing the duroxide
/// schema for the current binary's expected schema version.
///
/// Returns `false` if `<provider_schema>._worker_ready` does not exist, has no
/// row, or has a `schema_version` below `WORKER_SCHEMA_VERSION`. This is a fast
/// SPI read called once per session on the first call to any `df.*` function
/// that needs the duroxide client.
fn is_worker_ready() -> bool {
let schema = backend_duroxide_schema();
// First check if the readiness table exists via the catalogue. Querying
// the non-existent table directly would raise a PostgreSQL ERROR that
// aborts the current (sub)transaction — even if caught in Rust.
let table_exists = Spi::get_one_with_args::<bool>(
"SELECT EXISTS(SELECT 1 FROM pg_catalog.pg_tables \
WHERE schemaname = $1 AND tablename = '_worker_ready')",
&[schema.into()],
)
.ok()
.flatten()
.unwrap_or(false);
if !table_exists {
return false;
}
Spi::get_one_with_args::<bool>(
&format!(
"SELECT EXISTS(SELECT 1 FROM {}._worker_ready WHERE schema_version >= $1)",
schema
),
&[crate::WORKER_SCHEMA_VERSION.into()],
)
.ok()
.flatten()
.unwrap_or(false)
}
/// Get or create the cached tokio runtime.
fn get_client_runtime() -> &'static Runtime {
CLIENT_RUNTIME.get_or_init(|| {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("Failed to create tokio runtime")
})
}
/// Initialize or get the cached Duroxide client, executing `f` with it.
/// If the client doesn't exist yet, creates it. If `f` returns an error
/// that looks like a connection failure, resets the client so the next
/// call will re-initialize.
fn with_duroxide_client<T, F>(f: F) -> Result<T, String>
where
F: FnOnce(&Client, &Runtime) -> Result<T, String>,
{
let rt = get_client_runtime();
// Try to use existing client
let has_client = DUROXIDE_CLIENT.with(|cell| cell.borrow().is_some());
if !has_client {
// Need to create a new client
if !is_worker_ready() {
return Err(
"pg_durable background worker not yet initialized — try again in a moment"
.to_string(),
);
}
let pg_conn_str = postgres_connection_string();
let schema = backend_duroxide_schema();
let client = rt.block_on(async {
// SAFETY: Each PostgreSQL backend is a separate process (fork model).
// This code runs in a single-threaded tokio runtime with no worker
// threads. No concurrent thread can be reading env simultaneously.
unsafe {
std::env::set_var("DUROXIDE_PG_POOL_MAX", "1");
}
let store = new_backend_provider(&pg_conn_str, schema).await?;
Ok::<Client, String>(Client::new(store))
})?;
DUROXIDE_CLIENT.with(|cell| {
*cell.borrow_mut() = Some(client);
});
}
// Execute the operation with the client
let result = DUROXIDE_CLIENT.with(|cell| {
let borrow = cell.borrow();
let client = borrow
.as_ref()
.ok_or_else(|| "Client unexpectedly missing".to_string())?;
f(client, rt)
});
// On connection-level errors, reset the client so next call retries
if let Err(ref e) = result {
if is_connection_error(e) {
DUROXIDE_CLIENT.with(|cell| {
*cell.borrow_mut() = None;
});
}
}
result
}
/// Heuristic to detect connection-level errors that warrant client reset.
fn is_connection_error(err: &str) -> bool {
let lower = err.to_lowercase();
lower.contains("connection")
|| lower.contains("pool timed out")
|| lower.contains("broken pipe")
|| lower.contains("reset by peer")
|| lower.contains("closed")
}
/// Test-accessible wrapper for is_connection_error.
#[cfg(any(test, feature = "pg_test"))]
pub(crate) fn is_connection_error_for_test(err: &str) -> bool {
is_connection_error(err)
}
async fn list_running_descendants(client: &Client, root_instance_id: &str) -> Vec<String> {
let tree = match client.get_instance_tree(root_instance_id).await {
Ok(tree) => tree,
Err(e) => {
warning!(
"pg_durable: failed to inspect instance tree for signal fan-out (root={}): {:?}",
root_instance_id,
e
);
return vec![];
}
};
let mut descendants = Vec::new();
for child_instance_id in tree.all_ids {
if child_instance_id == root_instance_id {
continue;
}
match client.get_instance_info(&child_instance_id).await {
Ok(info) if info.status.eq_ignore_ascii_case("running") => {
descendants.push(child_instance_id);
}
Ok(_) => {}
Err(e) => {
warning!(
"pg_durable: failed to inspect child instance status for signal fan-out (child={}): {:?}",
child_instance_id, e
);
}
}
}
descendants
}
/// Start a durable function via the shared PostgreSQL store.
pub fn start_durable_function(
function_name: &str,
instance_id: &str,
input: &str,
) -> Result<(), String> {
log!(
"pg_durable: start_durable_function for instance {}",
instance_id
);
let fn_name = function_name.to_string();
let inst_id = instance_id.to_string();
let inp = input.to_string();
with_duroxide_client(|client, rt| {
rt.block_on(async {
client
.start_orchestration(&inst_id, &fn_name, &inp)
.await
.map_err(|e| format!("Failed to start durable function: {e:?}"))?;
Ok(())
})
})
}
/// Cancel a durable function.
pub fn cancel_durable_function(instance_id: &str, reason: &str) -> Result<(), String> {
let inst_id = instance_id.to_string();
let rsn = reason.to_string();
with_duroxide_client(|client, rt| {
rt.block_on(async {
client
.cancel_instance(&inst_id, &rsn)
.await
.map_err(|e| format!("Failed to cancel durable function: {e:?}"))?;
Ok(())
})
})
}
/// Raise an external event (signal) to a running orchestration.
pub fn raise_external_event(instance_id: &str, event_name: &str, data: &str) -> Result<(), String> {
let inst_id = instance_id.to_string();
let evt_name = event_name.to_string();
let evt_data = data.to_string();
with_duroxide_client(|client, rt| {
rt.block_on(async {
client
.raise_event(&inst_id, &evt_name, &evt_data)
.await
.map_err(|e| format!("Failed to raise event: {e:?}"))?;
for child_instance_id in list_running_descendants(client, &inst_id).await {
if let Err(e) = client
.raise_event(&child_instance_id, &evt_name, &evt_data)
.await
{
warning!(
"pg_durable: failed to fan out signal '{}' to child instance {}: {:?}",
evt_name,
child_instance_id,
e
);
}
}
Ok(())
})
})
}
#[cfg(test)]
mod tests {
use super::is_connection_error;
#[test]
fn detects_connection_refused() {
assert!(is_connection_error(
"Failed to start durable function: connection refused"
));
}
#[test]
fn detects_broken_pipe() {
assert!(is_connection_error("IO error: broken pipe"));
}
#[test]
fn detects_pool_timeout() {
assert!(is_connection_error(
"pool timed out while waiting for an open connection"
));
}
#[test]
fn detects_connection_reset() {
assert!(is_connection_error("reset by peer"));
}
#[test]
fn detects_connection_closed() {
assert!(is_connection_error("connection closed unexpectedly"));
}
#[test]
fn does_not_match_normal_errors() {
assert!(!is_connection_error("Instance not found"));
assert!(!is_connection_error("permission denied for table foo"));
assert!(!is_connection_error("syntax error at position 42"));
assert!(!is_connection_error(
"Orchestration already exists for instance abc123"
));
}
}