fix(postgres): recover applied create table after disconnect

This commit is contained in:
zipg
2026-09-15 23:22:23 +08:00
committed by GitHub
parent 3c8ae159fb
commit e498fc3731
2 changed files with 202 additions and 1 deletions
+1 -1
View File
@@ -7168,7 +7168,7 @@ pub async fn get_columns(pool: &Pool, schema: &str, table: &str) -> Result<Vec<C
.await
}
fn pg_quote_literal(value: &str) -> String {
pub(crate) fn pg_quote_literal(value: &str) -> String {
format!("'{}'", value.replace('\'', "''"))
}
+201
View File
@@ -1508,6 +1508,54 @@ fn query_pool_error_action(db_type: Option<DatabaseType>, sql: &str, err: &str)
}
}
fn native_postgres_compatibility_type(db_type: Option<DatabaseType>) -> bool {
matches!(
db_type,
Some(
DatabaseType::Postgres
| DatabaseType::Gaussdb
| DatabaseType::OpenGauss
| DatabaseType::Kwdb
| DatabaseType::Questdb
)
)
}
fn postgres_create_table_relation(sql: &str) -> Option<(Option<String>, String)> {
let statements = Parser::parse_sql(&PostgreSqlDialect {}, sql).ok()?;
let [Statement::CreateTable(table)] = statements.as_slice() else {
return None;
};
if table.temporary {
return None;
}
// PostgreSQL stores unquoted identifiers lower-cased, so the existence
// probe must compare the folded spelling; quoted names keep their case.
let mut parts = table.name.0.iter().filter_map(|part| {
part.as_ident().map(|ident| match ident.quote_style {
Some(_) => ident.value.clone(),
None => ident.value.to_lowercase(),
})
});
let table_name = parts.next_back()?;
Some((parts.next_back(), table_name))
}
fn should_verify_postgres_create_table_after_connection_error(
db_type: Option<DatabaseType>,
sql: &str,
error: &str,
) -> bool {
native_postgres_compatibility_type(db_type)
&& is_connection_error(error)
&& postgres_create_table_relation(sql).is_some()
}
fn is_postgres_duplicate_relation_error(error: &str) -> bool {
let lower = error.to_ascii_lowercase();
lower.contains("already exists") && (lower.contains("relation") || lower.contains("table"))
}
fn query_execution_error_action(
db_type: Option<DatabaseType>,
sql: &str,
@@ -2839,6 +2887,85 @@ pub async fn execute_sql_statement_with_options_typed(
result
}
async fn recover_postgres_create_table_after_connection_error(
state: &AppState,
connection_id: &str,
database: &str,
schema: Option<&str>,
sql: &str,
cancel_token: Option<CancellationToken>,
options: &QueryExecutionOptions,
db_type: Option<DatabaseType>,
initial_error: &QueryExecutionError,
) -> Option<Result<db::QueryResult, QueryExecutionError>> {
if is_canceled(&cancel_token)
|| !should_verify_postgres_create_table_after_connection_error(db_type, sql, &initial_error.to_string())
{
return None;
}
let pool_database = query_pool_database(database, options.catalog.as_deref());
let new_key = state
.reconnect_pool_for_session(connection_id, pool_database, options.client_session_id.as_deref())
.await
.ok()?;
let mysql_dialect = connection_mysql_query_dialect(state, connection_id).await;
// A compatible PostgreSQL server may close the session after committing a
// CREATE TABLE. Retry once on a fresh session: this covers a disconnect
// before execution, while a duplicate relation below confirms that the
// original request already took effect.
let retry_error = match do_execute_typed(
state,
&new_key,
mysql_dialect,
Some(database),
sql,
schema,
cancel_token.clone(),
options.clone(),
)
.await
{
Ok(result) => return Some(Ok(result)),
Err(error) => error,
};
if !is_postgres_duplicate_relation_error(&retry_error.to_string()) {
return None;
}
let (qualified_schema, table_name) = postgres_create_table_relation(sql)?;
let schema_predicate = qualified_schema
.or_else(|| schema.map(str::to_owned))
.map(|schema_name| format!("n.nspname = {}", db::postgres::pg_quote_literal(&schema_name)))
.unwrap_or_else(|| "n.nspname = current_schema()".to_string());
let verify_sql = format!(
"SELECT EXISTS (SELECT 1 FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace WHERE {} AND c.relname = {}) AS dbx_create_table_applied",
schema_predicate,
db::postgres::pg_quote_literal(&table_name),
);
let verify_options = QueryExecutionOptions {
max_rows: Some(1),
client_session_id: options.client_session_id.clone(),
..Default::default()
};
let verified = do_execute_typed(
state,
&new_key,
mysql_dialect,
Some(database),
&verify_sql,
None,
cancel_token,
verify_options,
)
.await
.ok()
.and_then(|result| result.rows.first().and_then(|row| row.first()).and_then(|value| value.as_bool()))
.unwrap_or(false);
verified.then(|| Ok(empty_query_result(0)))
}
async fn execute_sql_statement_with_options_typed_inner(
state: &AppState,
connection_id: &str,
@@ -2903,6 +3030,23 @@ async fn execute_sql_statement_with_options_typed_inner(
};
let action = result.as_ref().err().map(|error| query_execution_error_action(db_type, sql, error));
if let Some(initial_error) = result.as_ref().err() {
if let Some(recovered) = recover_postgres_create_table_after_connection_error(
state,
connection_id,
database,
schema,
sql,
cancel_token.clone(),
&options,
db_type,
initial_error,
)
.await
{
return with_sql_context(recovered);
}
}
match action {
Some(PoolErrorAction::ReconnectAndRetry) if !is_canceled(&cancel_token) => {
let pool_database = query_pool_database(database, options.catalog.as_deref());
@@ -9620,6 +9764,63 @@ for line in sys.stdin:
assert!(is_connection_error("Error occurred while creating a new object: error communicating with the server"));
}
#[test]
fn postgres_create_table_recovery_only_targets_persistent_tables() {
assert_eq!(
postgres_create_table_relation("CREATE TABLE \"app\".\"events\" (id bigint)"),
Some((Some("app".to_string()), "events".to_string()))
);
assert_eq!(
postgres_create_table_relation("-- ddl\nCREATE TABLE events (id bigint)"),
Some((None, "events".to_string()))
);
assert_eq!(postgres_create_table_relation("CREATE TEMP TABLE events (id bigint)"), None);
assert_eq!(postgres_create_table_relation("ALTER TABLE events ADD COLUMN note text"), None);
}
#[test]
fn postgres_create_table_recovery_folds_unquoted_identifiers() {
assert_eq!(
postgres_create_table_relation("CREATE TABLE MyTable (id bigint)"),
Some((None, "mytable".to_string()))
);
assert_eq!(
postgres_create_table_relation("CREATE TABLE Core.Orders (id bigint)"),
Some((Some("core".to_string()), "orders".to_string()))
);
assert_eq!(
postgres_create_table_relation("CREATE TABLE \"Core\".\"Orders\" (id bigint)"),
Some((Some("Core".to_string()), "Orders".to_string()))
);
}
#[test]
fn postgres_create_table_recovery_requires_native_connection_loss() {
let sql = "CREATE TABLE events (id bigint)";
assert!(should_verify_postgres_create_table_after_connection_error(
Some(DatabaseType::Postgres),
sql,
"connection closed; PostgreSQL schema.reset cleanup failed: connection closed"
));
assert!(should_verify_postgres_create_table_after_connection_error(
Some(DatabaseType::Gaussdb),
sql,
"connection reset by peer"
));
assert!(!should_verify_postgres_create_table_after_connection_error(
Some(DatabaseType::Postgres),
sql,
"ERROR: permission denied"
));
assert!(!should_verify_postgres_create_table_after_connection_error(
Some(DatabaseType::Vastbase),
sql,
"connection closed"
));
assert!(is_postgres_duplicate_relation_error("ERROR: relation \"events\" already exists"));
assert!(!is_postgres_duplicate_relation_error("ERROR: permission denied for schema public"));
}
#[test]
fn query_error_context_omits_raw_sql_and_is_not_duplicated() {
let sql = "select 'secret-123' as token";