[SPARK-59336][SQL] Fix syntax error classifier in Postgres connector - #58621
[SPARK-59336][SQL] Fix syntax error classifier in Postgres connector#58621urosstan-db wants to merge 7 commits into
Conversation
6697d71 to
3639109
Compare
| verify(conn).setAutoCommit(false) | ||
| } | ||
|
|
||
| test("SPARK-59336: classify only syntax error SQLSTATEs as syntax errors") { |
There was a problem hiding this comment.
uros-b
left a comment
There was a problem hiding this comment.
cc @vladanvasi-db regarding #56899
Discussed offline and decided to go with current approach in this PR. |
cloud-fan
left a comment
There was a problem hiding this comment.
Review summary
The patch correctly stops treating every PostgreSQL class-42 error as syntax and adds useful unit and integration coverage for the permission path. One non-blocking issue remains: 42000 is a combined syntax-or-access-rule condition, so accepting it unconditionally can still turn an authorization failure into Spark's syntax-error wrapper. Restricting the positive match to 42601 and reversing the new 42000 assertion would align the implementation with the public classifier contract.
Findings
1 total: 0 P0, 0 P1, 1 P2, 0 P3.
Non-blocking (P2)
- Do not treat PostgreSQL SQLSTATE 42000 as syntax-only —
sql/core/src/main/scala/org/apache/spark/sql/jdbc/PostgresDialect.scala:264— see inline.
PR metadata suggestions
- Narrow the user-facing-change statement from generic '42x errors' to the specific PostgreSQL SQLSTATE behavior, and note that only unambiguous syntax conditions are classified as syntax errors.
| override def isSyntaxErrorBestEffort(exception: SQLException): Boolean = { | ||
| Option(exception.getSQLState).exists(_.startsWith("42")) | ||
| exception.getSQLState match { | ||
| case "42000" | "42601" => true |
There was a problem hiding this comment.
Non-blocking (P2): PostgreSQL defines 42000 as the combined syntax_error_or_access_rule_violation condition, while 42601 is the syntax-specific condition. Since isSyntaxErrorBestEffort guarantees that every true result is a syntax error and JDBCRDD uses it to replace the original exception with JDBC_EXTERNAL_ENGINE_SYNTAX_ERROR, an access-rule failure reported as 42000 is still mislabeled here. Could we match only 42601 and change the unit coverage so an access-rule-flavored 42000 is asserted false?
There was a problem hiding this comment.
Thanks a lot for comment, I was thinking whether we would need to exclude 42000 and be more strict, or we want to be a little bit broader. And decided to go with a little bit broader catch, since most 42x common errors have subtype and it is hard to test every syntax issue in integration tests, while there is no big issue to classify some new uknown 42000 error as syntax error. We can iterate by the time, for now, I wanted to exclude 42x errors that are clearly false positives.
| .load() | ||
| } | ||
| assertResult("42501")(postgresError.getSQLState) | ||
| assertResult("ERROR: permission denied for table bar")(postgresError.getMessage) |
There was a problem hiding this comment.
The exact message match is a bit brittle in the Docker test.
assertResult("ERROR: permission denied for table bar")(postgresError.getMessage)
PSQLException.getMessage often appends Position: / Detail:, and pre-15 servers say relation instead of table. The suite already allows POSTGRES_DOCKER_IMAGE_NAME to vary. Assert SQLState == "42501" and getMessage.contains("permission denied"), same style as the existing SPARK-47886 checks in this suite.
Also use DROP USER IF EXISTS in cleanup. If CREATE USER fails, the finally DROP USER can hide the original error; if a connection is still held, DROP USER can fail after a green assertion.
There was a problem hiding this comment.
Good proposal, I will make error less restrictive and use IF EXISTS syntax (it is supported from pg server 8.2, released in 20 years ago), so even if someone provide older docker image version for testing, syntax would work.
I also, used some special spark's Util method (I forgot the exact name) to preserve stack trace if finally fails (#58621 (comment)).
|
|
||
| // See https://www.postgresql.org/docs/current/errcodes-appendix.html | ||
| override def isSyntaxErrorBestEffort(exception: SQLException): Boolean = { | ||
| Option(exception.getSQLState).exists(_.startsWith("42")) |
There was a problem hiding this comment.
Same startsWith("42") pattern is still in H2, Derby, DB2, Teradata, and Databricks dialects. This might be out of scope in this particular PR, but perhaps we can file follow-up tickets?
There was a problem hiding this comment.
Totally agreed, I planned to make a folow-up with additional tests (e.g. querying non existing table should not be clasified as syntax error). And in that additional testing, we may catch issues on other dialects.
https://issues.apache.org/jira/browse/SPARK-59369
|
Left a few more comments, thank you @urosstan-db and @cloud-fan! |
|
Also, please consider updating the user-facing section of the PR description. The impact is a bit broader than the description currently says. The write-up reads as “don’t treat 42501 as syntax.” The allowlist also stops wrapping other class-42 states (42P01 undefined table, 42703 undefined column, grouping/type errors, …). That is the intended contract, and it matters on a real path: JDBCRDD.resolveTable only consults isObjectNotFoundException when ident is defined. A normal spark.read.format("jdbc").option("dbtable", …) probe of a missing table currently becomes JDBC_EXTERNAL_ENGINE_SYNTAX_ERROR because 42P01 starts with 42. After this PR it surfaces as a SQLException. Please state that explicitly under “user-facing change,” including that the error type/class changes for those cases. |
| val restrictedJdbcUrl = s"jdbc:postgresql://$dockerIp:$externalPort/postgres" | ||
|
|
||
| Using.resource(getConnection()) { conn => | ||
| conn.prepareStatement(s"CREATE USER $restrictedUser PASSWORD '$restrictedPassword'") |
There was a problem hiding this comment.
CREATE USER sits outside tryWithSafeFinally. If that statement fails (role already exists from a previous keepContainer run), cleanup never runs.
Put both CREATE and DROP inside the same tryWithSafeFinally, and DROP USER IF EXISTS before CREATE.
There was a problem hiding this comment.
Good comment, but if create user fails, then there is nothing to clean, while after this executeUpdate, we will for sure execute drop user. I would not add drop user before create, since I like to have clean lifecycle, and we use docker container for each new run, even if we decide to reuse docker containers in CI in the future (highly unlike), we should be fine with execution of this test
| assert(!dialect.isSyntaxErrorBestEffort(new SQLException("permission denied", "42501"))) | ||
| assert(!dialect.isSyntaxErrorBestEffort(new SQLException("undefined table", "42P01"))) | ||
| assert(!dialect.isSyntaxErrorBestEffort(new SQLException("error without SQLSTATE"))) | ||
| } |
There was a problem hiding this comment.
Unit coverage is thin for the new contract. The suite checks 42501 and 42P01. Adding 42703 (undefined column) and 42883 (undefined function) would lock the other common class-42 false positives the PR description calls out. Cheap and they always run, unlike Docker tests.
There was a problem hiding this comment.
I would not go that deep with unit testing for one-liner method, since it does not protect us more much more than current change, while it adds burden for future change of logic
What changes were proposed in this pull request?
https://www.postgresql.org/docs/17/errcodes-appendix.html

Why are the changes needed?
Does this PR introduce any user-facing change?
42000and42601as syntax errors. Other class-42 errors, such as42501(insufficient privilege) and42P01(undefined table), are propagated as their original PostgreSQL JDBC errors.How was this patch tested?
Was this patch authored or co-authored using generative AI tooling?
Generated-by: Codex CLI