Skip to content

Commit 572a95b

Browse files
authored
Error when encountering an unsupported aggregate function (#1020)
When handling aggregate functions in cross-shard queries, we can only return a correct result for functions that we have explicit support for. No matter what the aggregate function is doing, we need to know how to combine the results from the separate shards. Prior to this change, we would just silently do the wrong thing, treating this as any other non-aggregate expression and just returning a union of the rows from each query. We now explicitly check if an aggregate function with that name exists and error if we don't recognize it. This is future proofed against new functions in later postgres versions, as well as user defined aggregates (which we can likely never support). This will produce a false positive if a function exists and is defined as aggregate for some argument types but not others. But frankly anyone doing that is asking for trouble. This logic is objectively in the wrong place. What we are doing here is validation, not parsing. However as I'm familiarizing myself with these code paths and preparing a larger rearchitecture, I'm slowly hammering things into a shape that's easier to move around. I do not intend on leaving this logic here long term.
1 parent cfecdad commit 572a95b

15 files changed

Lines changed: 180 additions & 20 deletions

File tree

integration/ci/cache-key.sh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ REPO_ROOT="$( cd "${SCRIPT_DIR}/../.." && pwd )"
1313
cd "$REPO_ROOT"
1414

1515
files=(
16+
rust-toolchain.toml
1617
Cargo.lock
1718
Cargo.toml
1819
.cargo/config.toml

integration/go/go_pgx/sharded_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -152,11 +152,11 @@ func TestShardedTwoPc(t *testing.T) {
152152
assert.NoError(t, err)
153153
}
154154

155-
// +4 is for schema sync
155+
// +5 is for schema sync
156156
assertShowField(t, "SHOW STATS", "total_xact_2pc_count", 200, "pgdog_2pc", "pgdog_sharded", 0, "primary")
157157
assertShowField(t, "SHOW STATS", "total_xact_2pc_count", 200, "pgdog_2pc", "pgdog_sharded", 1, "primary")
158-
assertShowField(t, "SHOW STATS", "total_xact_count", 401+4, "pgdog_2pc", "pgdog_sharded", 0, "primary") // PREPARE, COMMIT for each transaction + TRUNCATE
159-
assertShowField(t, "SHOW STATS", "total_xact_count", 401+4, "pgdog_2pc", "pgdog_sharded", 1, "primary")
158+
assertShowField(t, "SHOW STATS", "total_xact_count", 401+5, "pgdog_2pc", "pgdog_sharded", 0, "primary") // PREPARE, COMMIT for each transaction + TRUNCATE
159+
assertShowField(t, "SHOW STATS", "total_xact_count", 401+5, "pgdog_2pc", "pgdog_sharded", 1, "primary")
160160

161161
for i := range 200 {
162162
rows, err := conn.Query(

integration/python/test_session_mode.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,3 +356,14 @@ async def test_no_search_path_session_mode():
356356

357357
async with conn.transaction():
358358
await conn.execute("SELECT * FROM shard_0.py_test_no_search_path_session_mode LIMIT 1")
359+
360+
@pytest.mark.asyncio
361+
async def test_unrecognized_aggregate_function_works_on_schema_based_sharding():
362+
conn = await async_session_conn("shard_0")
363+
await conn.execute("DROP AGGREGATE IF EXISTS pgdog_sum_python(int4)")
364+
await conn.execute("CREATE AGGREGATE pgdog_sum_python (int4) (sfunc = int4_sum, stype = bigint)")
365+
await conn.execute("DROP TABLE IF EXISTS unrecognized_agg_test_python")
366+
await conn.execute("CREATE TABLE unrecognized_agg_test_python (lol int4)")
367+
368+
async with conn.transaction():
369+
await conn.execute("SELECT pgdog_sum_python(lol) FROM unrecognized_agg_test_python")

integration/rust/tests/integration/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,3 +36,4 @@ pub mod timestamp_sorting;
3636
pub mod tls_enforced;
3737
pub mod tls_reload;
3838
pub mod transaction_state;
39+
pub mod unrecognized_aggregate;
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
use rust::setup::{admin_sqlx, connections_sqlx};
2+
use sqlx::Executor;
3+
use std::assert_matches;
4+
5+
async fn define_custom_aggregate_fn() {
6+
for connection in connections_sqlx().await {
7+
connection
8+
.execute("DROP AGGREGATE IF EXISTS pgdog_sum (int4)")
9+
.await
10+
.unwrap();
11+
connection
12+
.execute("CREATE AGGREGATE pgdog_sum (int4) (sfunc = int4_sum, stype = bigint)")
13+
.await
14+
.unwrap();
15+
connection
16+
.execute("DROP TABLE IF EXISTS unrecognized_agg_test")
17+
.await
18+
.unwrap();
19+
connection
20+
.execute("CREATE TABLE unrecognized_agg_test (lol int4, customer_id bigint)")
21+
.await
22+
.unwrap();
23+
}
24+
admin_sqlx().await.execute("RELOAD").await.unwrap();
25+
}
26+
27+
#[tokio::test]
28+
async fn unrecognized_aggregate_function_errors_only_on_cross_shard_queries() {
29+
define_custom_aggregate_fn().await;
30+
let mut connections = connections_sqlx().await;
31+
let sharded = connections.pop().unwrap();
32+
let unsharded = connections.pop().unwrap();
33+
34+
let unsharded_query = unsharded
35+
.fetch_one("SELECT pgdog_sum(lol) FROM unrecognized_agg_test")
36+
.await;
37+
assert_matches!(unsharded_query, Ok(_));
38+
39+
let sharded_query = sharded
40+
.fetch_one("SELECT pgdog_sum(lol) FROM unrecognized_agg_test")
41+
.await;
42+
let err = sharded_query
43+
.err()
44+
.expect("unrecognized aggregate executed successfully");
45+
assert!(err.to_string().contains("pgdog_sum is not yet supported"));
46+
}

mise.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
[tools]
2-
"rust" = { version = "1.91.0" }
2+
"rust" = { version = "1.96.0" }
33
"cargo:cargo-nextest" = "latest"
4-
"cargo:cargo-watch" = "latest"
4+
"cargo:cargo-watch" = "latest"

pgdog-stats/src/schema.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
use indexmap::IndexMap;
22
use serde::{Deserialize, Serialize};
3-
use std::{collections::HashMap, hash::Hash, ops::Deref, sync::Arc};
3+
use std::{
4+
collections::{HashMap, HashSet},
5+
hash::Hash,
6+
ops::Deref,
7+
sync::Arc,
8+
};
49

510
/// Schema name -> Table name -> Relation
611
pub type Relations = HashMap<String, HashMap<String, Relation>>;
@@ -134,6 +139,7 @@ impl Relation {
134139
pub struct SchemaInner {
135140
pub search_path: Vec<String>,
136141
pub relations: Relations,
142+
pub aggregate_functions: HashSet<String>,
137143
}
138144

139145
impl Hash for SchemaInner {

pgdog/src/backend/pool/connection/aggregate.rs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,7 @@ impl<'a> Accumulator<'a> {
176176
}
177177
}
178178
}
179+
AggregateFunction::Unrecognized(..) => return Ok(false),
179180
}
180181

181182
Ok(true)
@@ -512,6 +513,13 @@ impl<'a> Aggregates<'a> {
512513
false
513514
})
514515
}
516+
AggregateFunction::Unrecognized(fname) => {
517+
unsupported.get_or_insert(UnsupportedAggregate {
518+
function: fname.clone(),
519+
reason: format!("{fname} is not yet supported"),
520+
});
521+
false
522+
}
515523
_ => true,
516524
}
517525
});
@@ -803,7 +811,7 @@ mod test {
803811
}
804812

805813
fn parse(stmt: &str) -> Aggregate {
806-
Aggregate::parse(&select(stmt))
814+
Aggregate::parse(&select(stmt), &Default::default())
807815
}
808816

809817
#[test]

pgdog/src/backend/schema/mod.rs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,9 +60,18 @@ impl Schema {
6060
.map(|p| p.trim().replace("\"", ""))
6161
.collect();
6262

63+
let aggregate_functions = server
64+
.fetch_all::<String>(
65+
"SELECT DISTINCT proname FROM pg_proc INNER JOIN pg_aggregate ON oid = aggfnoid",
66+
)
67+
.await?
68+
.into_iter()
69+
.collect();
70+
6371
let inner = SchemaInner {
6472
search_path,
6573
relations,
74+
aggregate_functions,
6675
};
6776

6877
Ok(Self {
@@ -79,6 +88,15 @@ impl Schema {
7988
pub(crate) fn from_parts(
8089
search_path: Vec<String>,
8190
relations: HashMap<(String, String), Relation>,
91+
) -> Self {
92+
Self::from_parts_with_agg(search_path, relations, Vec::new())
93+
}
94+
95+
#[cfg(test)]
96+
pub(crate) fn from_parts_with_agg(
97+
search_path: Vec<String>,
98+
relations: HashMap<(String, String), Relation>,
99+
aggregate_functions: Vec<String>,
82100
) -> Self {
83101
let mut nested: StatsRelations = HashMap::new();
84102
for ((schema, name), relation) in relations {
@@ -91,6 +109,7 @@ impl Schema {
91109
inner: StatsSchema::new(SchemaInner {
92110
search_path,
93111
relations: nested,
112+
aggregate_functions: aggregate_functions.into_iter().collect(),
94113
}),
95114
}
96115
}
@@ -555,4 +574,24 @@ mod test {
555574
);
556575
}
557576
}
577+
578+
#[tokio::test]
579+
async fn test_loading_aggregate_functions() {
580+
let mut server = test_server().await;
581+
server.execute_checked("BEGIN").await.unwrap();
582+
583+
let schema = Schema::load(&mut server).await.unwrap();
584+
assert!(!schema
585+
.aggregate_functions
586+
.contains(&String::from("pgdog_sum")));
587+
588+
server
589+
.execute_checked("CREATE AGGREGATE pgdog_sum (int4) (sfunc = int4_sum, stype = bigint)")
590+
.await
591+
.unwrap();
592+
let schema = Schema::load(&mut server).await.unwrap();
593+
assert!(schema
594+
.aggregate_functions
595+
.contains(&String::from("pgdog_sum")));
596+
}
558597
}

pgdog/src/frontend/router/parser/aggregate.rs

Lines changed: 44 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ use pg_query::protobuf::Integer;
22
use pg_query::protobuf::{a_const::Val, Node, SelectStmt, String as PgQueryString};
33
use pg_query::NodeEnum;
44

5-
use crate::frontend::router::parser::{ExpressionRegistry, Function};
5+
use super::{ExpressionRegistry, Function};
6+
use crate::backend::schema::Schema;
67

78
#[derive(Debug, Clone, PartialEq)]
89
pub struct AggregateTarget {
@@ -41,10 +42,11 @@ pub enum AggregateFunction {
4142
StddevSamp,
4243
VarPop,
4344
VarSamp,
45+
Unrecognized(String),
4446
}
4547

4648
impl AggregateFunction {
47-
pub fn as_str(&self) -> &'static str {
49+
pub fn as_str(&self) -> &str {
4850
match self {
4951
AggregateFunction::Count => "count",
5052
AggregateFunction::Max => "max",
@@ -55,6 +57,7 @@ impl AggregateFunction {
5557
AggregateFunction::StddevSamp => "stddev_samp",
5658
AggregateFunction::VarPop => "var_pop",
5759
AggregateFunction::VarSamp => "var_samp",
60+
AggregateFunction::Unrecognized(s) => &*s,
5861
}
5962
}
6063
}
@@ -121,7 +124,7 @@ fn columns_match(group_by_names: &[&String], select_names: &[&String]) -> bool {
121124

122125
impl Aggregate {
123126
/// Figure out what aggregates are present and which ones PgDog supports.
124-
pub fn parse(stmt: &SelectStmt) -> Self {
127+
pub fn parse(stmt: &SelectStmt, schema: &Schema) -> Self {
125128
let mut targets = vec![];
126129
let mut registry = ExpressionRegistry::new();
127130
let group_by = stmt
@@ -168,7 +171,13 @@ impl Aggregate {
168171
"stddev_pop" => Some(AggregateFunction::StddevPop),
169172
"variance" | "var_samp" => Some(AggregateFunction::VarSamp),
170173
"var_pop" => Some(AggregateFunction::VarPop),
171-
_ => None,
174+
fname => {
175+
if schema.aggregate_functions.contains(fname) {
176+
Some(AggregateFunction::Unrecognized(fname.to_owned()))
177+
} else {
178+
None
179+
}
180+
}
172181
};
173182

174183
if let Some(function) = function {
@@ -259,7 +268,7 @@ mod test {
259268
}
260269

261270
fn parse(stmt: &str) -> Aggregate {
262-
Aggregate::parse(&select(stmt))
271+
Aggregate::parse(&select(stmt), &Default::default())
263272
}
264273

265274
#[test]
@@ -455,4 +464,34 @@ mod test {
455464
assert_eq!(aggr.group_by(), &[0]);
456465
assert_eq!(aggr.targets().len(), 1);
457466
}
467+
468+
#[test]
469+
fn test_unrecognized_aggregate_function_errors() {
470+
let schema_with_agg = Schema::from_parts_with_agg(
471+
Vec::new(),
472+
Default::default(),
473+
vec![String::from("mysum")],
474+
);
475+
let schema_without_agg = Default::default();
476+
let query = select("SELECT mysum(lol) FROM example");
477+
478+
// A random function that isn't listed as aggregate in the schema
479+
// doesn't require special support on our end, so we should be fine.
480+
let aggregate = Aggregate::parse(&query, &schema_without_agg);
481+
assert_eq!(aggregate.targets, Vec::new());
482+
483+
// If we see an aggregate function we don't recognize, we can't
484+
// process the query correctly, since we need to combine the
485+
// results from each shard.
486+
let aggregate = Aggregate::parse(&query, &schema_with_agg);
487+
let funcs = aggregate
488+
.targets
489+
.into_iter()
490+
.map(|t| t.function)
491+
.collect::<Vec<_>>();
492+
assert_eq!(
493+
funcs,
494+
vec![AggregateFunction::Unrecognized("mysum".to_owned())]
495+
);
496+
}
458497
}

0 commit comments

Comments
 (0)