Skip to content

Commit c0190b9

Browse files
authored
Merge pull request #3404 from itowlson/pg-async-streaming
Async PostgreSQL API
2 parents 65b5003 + 3f9a0d7 commit c0190b9

17 files changed

Lines changed: 690 additions & 103 deletions

File tree

Cargo.lock

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/factor-outbound-pg/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ spin-factor-outbound-networking = { path = "../factor-outbound-networking" }
2424
spin-factors = { path = "../factors" }
2525
spin-locked-app = { path = "../locked-app" }
2626
spin-resource-table = { path = "../table" }
27+
spin-wasi-async = { path = "../wasi-async" }
2728
spin-world = { path = "../world" }
2829
tokio = { workspace = true, features = ["rt-multi-thread"] }
2930
tokio-postgres = { version = "0.7", features = ["with-chrono-0_4", "with-serde_json-1", "with-uuid-1"] }
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
use std::sync::Arc;
2+
3+
use spin_factor_outbound_networking::config::allowed_hosts::OutboundAllowedHosts;
4+
use spin_world::spin::postgres4_2_0::postgres::{self as v4};
5+
6+
/// Encapsulates checking of a PostgreSQL address/connection string against
7+
/// an allow-list.
8+
///
9+
/// This is broken out as a distinct object to allow it to be synchronously retrieved
10+
/// within a P3 Accessor block and then asynchronously queried outside the block.
11+
#[derive(Clone)]
12+
pub(crate) struct AllowedHostChecker {
13+
allowed_hosts: Arc<OutboundAllowedHosts>,
14+
}
15+
16+
impl AllowedHostChecker {
17+
pub fn new(allowed_hosts: OutboundAllowedHosts) -> Self {
18+
Self {
19+
allowed_hosts: Arc::new(allowed_hosts),
20+
}
21+
}
22+
23+
#[allow(clippy::result_large_err)]
24+
pub async fn ensure_address_allowed(&self, address: &str) -> Result<(), v4::Error> {
25+
fn conn_failed(message: impl Into<String>) -> v4::Error {
26+
v4::Error::ConnectionFailed(message.into())
27+
}
28+
fn err_other(err: anyhow::Error) -> v4::Error {
29+
v4::Error::Other(err.to_string())
30+
}
31+
32+
let config = address
33+
.parse::<tokio_postgres::Config>()
34+
.map_err(|e| conn_failed(e.to_string()))?;
35+
36+
for (i, host) in config.get_hosts().iter().enumerate() {
37+
match host {
38+
tokio_postgres::config::Host::Tcp(address) => {
39+
let ports = config.get_ports();
40+
// The port we use is either:
41+
// * The port at the same index as the host
42+
// * The first port if there is only one port
43+
let port = ports.get(i).or_else(|| {
44+
if ports.len() == 1 {
45+
ports.first()
46+
} else {
47+
None
48+
}
49+
});
50+
let port_str = port.map(|p| format!(":{p}")).unwrap_or_default();
51+
let url = format!("{address}{port_str}");
52+
if !self
53+
.allowed_hosts
54+
.check_url(&url, "postgres")
55+
.await
56+
.map_err(err_other)?
57+
{
58+
return Err(conn_failed(format!(
59+
"address postgres://{url} is not permitted"
60+
)));
61+
}
62+
}
63+
#[cfg(unix)]
64+
tokio_postgres::config::Host::Unix(_) => {
65+
return Err(conn_failed("Unix sockets are not supported on WebAssembly"));
66+
}
67+
}
68+
}
69+
Ok(())
70+
}
71+
}

crates/factor-outbound-pg/src/client.rs

Lines changed: 135 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,18 @@
1+
use std::sync::Arc;
2+
13
use anyhow::{Context, Result};
24
use futures::stream::TryStreamExt as _;
35
use native_tls::TlsConnector;
46
use postgres_native_tls::MakeTlsConnector;
57
use spin_world::async_trait;
6-
use spin_world::spin::postgres4_1_0::postgres::{
8+
use spin_world::spin::postgres4_2_0::postgres::{
79
self as v4, Column, DbValue, ParameterValue, RowSet,
810
};
9-
use std::pin::pin;
1011
use tokio_postgres::config::SslMode;
1112
use tokio_postgres::types::ToSql;
1213
use tokio_postgres::{NoTls, Row};
1314

14-
use crate::types::{convert_data_type, convert_entry, to_sql_parameter};
15+
use crate::types::{convert_data_type, convert_entry, to_sql_parameter, to_sql_parameters};
1516

1617
/// Max connections in a given address' connection pool
1718
const CONNECTION_POOL_SIZE: usize = 64;
@@ -61,9 +62,18 @@ impl Default for PooledTokioClientFactory {
6162
}
6263
}
6364

65+
#[derive(Clone)]
66+
pub struct PooledTokioClient(Arc<deadpool_postgres::Object>);
67+
68+
impl AsRef<deadpool_postgres::Object> for PooledTokioClient {
69+
fn as_ref(&self) -> &deadpool_postgres::Object {
70+
self.0.as_ref()
71+
}
72+
}
73+
6474
#[async_trait]
6575
impl ClientFactory for PooledTokioClientFactory {
66-
type Client = deadpool_postgres::Object;
76+
type Client = PooledTokioClient;
6777

6878
async fn get_client(
6979
&self,
@@ -81,7 +91,7 @@ impl ClientFactory for PooledTokioClientFactory {
8191
.map_err(ArcError)
8292
.context("establishing PostgreSQL connection pool")?;
8393

84-
Ok(pool.get().await?)
94+
Ok(PooledTokioClient(Arc::new(pool.get().await?)))
8595
}
8696
}
8797

@@ -123,7 +133,7 @@ fn create_connection_pool(
123133
}
124134

125135
#[async_trait]
126-
pub trait Client: Send + Sync + 'static {
136+
pub trait Client: Clone + Send + Sync + 'static {
127137
async fn execute(
128138
&self,
129139
statement: String,
@@ -136,6 +146,19 @@ pub trait Client: Send + Sync + 'static {
136146
params: Vec<ParameterValue>,
137147
max_result_bytes: usize,
138148
) -> Result<RowSet, v4::Error>;
149+
150+
async fn query_async(
151+
&self,
152+
statement: String,
153+
params: Vec<ParameterValue>,
154+
max_result_bytes: usize,
155+
) -> Result<QueryAsyncResult, v4::Error>;
156+
}
157+
158+
pub struct QueryAsyncResult {
159+
pub columns: Vec<v4::Column>,
160+
pub rows: tokio::sync::mpsc::Receiver<v4::Row>,
161+
pub error: tokio::sync::oneshot::Receiver<Result<(), v4::Error>>,
139162
}
140163

141164
/// Extract weak-typed error data for WIT purposes
@@ -180,8 +203,13 @@ fn query_failed(e: tokio_postgres::error::Error) -> v4::Error {
180203
v4::Error::QueryFailed(query_error)
181204
}
182205

206+
fn query_failed_anyhow(e: anyhow::Error) -> v4::Error {
207+
let text = format!("{e:?}");
208+
v4::Error::QueryFailed(v4::QueryError::Text(text))
209+
}
210+
183211
#[async_trait]
184-
impl Client for deadpool_postgres::Object {
212+
impl Client for PooledTokioClient {
185213
async fn execute(
186214
&self,
187215
statement: String,
@@ -210,34 +238,21 @@ impl Client for deadpool_postgres::Object {
210238
params: Vec<ParameterValue>,
211239
max_result_bytes: usize,
212240
) -> Result<RowSet, v4::Error> {
213-
let params = params
214-
.iter()
215-
.map(to_sql_parameter)
216-
.collect::<Result<Vec<_>>>()
217-
.map_err(|e| v4::Error::BadParameter(format!("{e:?}")))?;
218-
219-
let mut results = pin!(self
220-
.as_ref()
221-
.query_raw(&statement, params)
222-
.await
223-
.map_err(query_failed)?);
241+
let (cols_fut, mut results) = self.query_stream(statement, params).await?;
224242

225243
let mut columns = None;
226244
let mut byte_count = std::mem::size_of::<RowSet>();
227245
let mut rows = Vec::new();
228246

229247
async {
230248
while let Some(row) = results.try_next().await? {
231-
if columns.is_none() {
232-
columns = Some(infer_columns(&row));
233-
}
234-
let row = convert_row(&row)?;
235249
byte_count += row.iter().map(|v| v.memory_size()).sum::<usize>();
236250
if byte_count > max_result_bytes {
237251
anyhow::bail!("query result exceeds limit of {max_result_bytes} bytes")
238252
}
239253
rows.push(row);
240254
}
255+
columns = Some(cols_fut.await);
241256
Ok(())
242257
}
243258
.await
@@ -248,6 +263,104 @@ impl Client for deadpool_postgres::Object {
248263
rows,
249264
})
250265
}
266+
267+
async fn query_async(
268+
&self,
269+
statement: String,
270+
params: Vec<ParameterValue>,
271+
max_result_bytes: usize,
272+
) -> Result<QueryAsyncResult, v4::Error> {
273+
use futures::StreamExt;
274+
275+
let (cols_fut, mut rows) = self.query_stream(statement, params).await?;
276+
277+
let (rows_tx, rows_rx) = tokio::sync::mpsc::channel(4);
278+
let (err_tx, err_rx) = tokio::sync::oneshot::channel();
279+
280+
tokio::spawn(async move {
281+
loop {
282+
let Some(row) = rows.next().await else {
283+
_ = err_tx.send(Ok(()));
284+
return;
285+
};
286+
match row {
287+
Ok(row) => {
288+
let byte_count = row.iter().map(|v| v.memory_size()).sum::<usize>();
289+
if byte_count > max_result_bytes {
290+
_ = err_tx.send(Err(v4::Error::QueryFailed(v4::QueryError::Text(
291+
format!("query result exceeds limit of {max_result_bytes} bytes"),
292+
))));
293+
return;
294+
}
295+
296+
if let Err(e) = rows_tx.send(row).await {
297+
_ = err_tx.send(Err(v4::Error::QueryFailed(v4::QueryError::Text(
298+
format!("async error: {e}"),
299+
))));
300+
return;
301+
}
302+
}
303+
Err(e) => {
304+
_ = err_tx.send(Err(e));
305+
return;
306+
}
307+
}
308+
}
309+
});
310+
311+
let cols = cols_fut.await;
312+
313+
Ok(QueryAsyncResult {
314+
columns: cols,
315+
rows: rows_rx,
316+
error: err_rx,
317+
})
318+
}
319+
}
320+
321+
impl PooledTokioClient {
322+
async fn query_stream(
323+
&self,
324+
statement: String,
325+
params: Vec<ParameterValue>,
326+
) -> Result<
327+
(
328+
impl std::future::Future<Output = Vec<v4::Column>>,
329+
impl futures::Stream<Item = Result<Vec<DbValue>, v4::Error>>,
330+
),
331+
v4::Error,
332+
> {
333+
use futures::{FutureExt, StreamExt};
334+
335+
let params = to_sql_parameters(params)?;
336+
337+
let results = Box::pin(
338+
self.as_ref()
339+
.query_raw(&statement, params)
340+
.await
341+
.map_err(query_failed)?,
342+
);
343+
344+
let (cols_tx, cols_rx) = tokio::sync::oneshot::channel();
345+
let mut cols_tx_opt = Some(cols_tx);
346+
347+
let row_stm = results.enumerate().map(move |(index, row_res)| {
348+
let row_res = row_res.map_err(query_failed);
349+
row_res.and_then(|r| {
350+
if index == 0 {
351+
if let Some(cols_tx) = cols_tx_opt.take() {
352+
let cols = infer_columns(&r);
353+
_ = cols_tx.send(cols);
354+
}
355+
}
356+
convert_row(&r).map_err(query_failed_anyhow)
357+
})
358+
});
359+
360+
let cols_rx = cols_rx.map(|result| result.unwrap_or_default());
361+
362+
Ok((cols_rx, Box::pin(row_stm)))
363+
}
251364
}
252365

253366
fn infer_columns(row: &Row) -> Vec<Column> {

0 commit comments

Comments
 (0)