Repository navigation
[Spanner Change Stream] Fix Change Streams PostgreSQL metadata queries - #40755
Conversation
There was a problem hiding this comment.
📋 Review Summary
This PR resolves three important bugs in Spanner Change Streams (SpannerIO.readChangeStream()): fixing PartitionMetadataDao.tableExists() for PostgreSQL-dialect databases by checking table_schema = 'public', parameterizing getPartitionsMatchingState() and short-circuiting empty token updates in updateToScheduled(), and adding support for schema-qualified change stream and TVF names across SpannerIO and ChangeStreamDao. The changes are well-structured, adhere to existing SpannerIO patterns, and include comprehensive unit tests for both GoogleSQL and PostgreSQL dialects.
🔍 General Feedback
- Switching
PartitionMetadataDao.getPartitionsMatchingState()in the PostgreSQL branch from string concatenation to bound query parameters ($1andANY ($2)) is a great improvement for both query plan caching and safety. - The schema-qualification handling across
fetchPartitionMode,checkTvfExistence, andChangeStreamDao.changeStreamQuerycleanly accounts for the differences between GoogleSQL (<schema>.READ_<name>) and PostgreSQL ("spanner"."read_json_<name>"/"spanner"."read_proto_bytes_<name>").
💡 Tip: Comment /gemini review on this pull request to trigger a new Gemini code review.
72bf8ca to
77dbaa6
Compare
|
Checks are failing. Will not request review until checks are succeeding. If you'd like to override that behavior, comment |
77dbaa6 to
4ece3c1
Compare
|
Assigning reviewers: R: @nielm for label spanner. Note: If you would like to opt out of this review, comment Available commands:
The PR bot will only process comments in the main thread (not review comments). |
| + COLUMN_STATE | ||
| + "\" = $1 AND \"" | ||
| + COLUMN_PARTITION_TOKEN | ||
| + "\" = ANY ($2)") |
There was a problem hiding this comment.
This seems to change the behavior for empty composedPartitionTokens array.
Previously it wouldn't check COLUMN_PARTITION_TOKEN but now I think that it will do ANY() which evaluates to false.
Maybe have a branch on if composedPartitionTokens is empty and have two statements you bind to?
There was a problem hiding this comment.
Yeah, it seems change the behavior. However, this change may be safe
Here is why:
- Method semantics & all callers in PartitionMetadataDao.java: getPartitionsMatchingState(List composedPartitionTokens, State state) is private and only has two callers:
- updateToScheduled(List composedPartitionTokens) (lines 535–557): queries which tokens among composedPartitionTokens are currently in State.CREATED, then loops for (String composedPartitionToken : composedPartitionTokens) to update only those tokens to SCHEDULED. When composedPartitionTokens is empty, that for loop executes 0 iterations and updates nothing. Under the old PostgreSQL code, omitting AND "PartitionToken" = ANY(...) ran SELECT * FROM "" WHERE "State" = 'CREATED' — performing an unnecessary full scan (and taking read locks on all CREATED rows in the table inside a read-write transaction) only to discard every row returned.
- updateToRunning(String composedPartitionToken) (lines 564–582): always passes Collections.singletonList(composedPartitionToken) (never empty).
- Matches the GoogleSQL branch (lines 707–722):
java
"SELECT * FROM " + metadataTableName
+ " WHERE " + COLUMN_PARTITION_TOKEN + " IN UNNEST(@composedPartitionTokens) AND "
+ COLUMN_STATE + " = @State"
In GoogleSQL, passing an empty list binds an empty ARRAY to @composedPartitionTokens, and IN UNNEST([]) evaluates to false (0 rows).
The only reason the original PostgreSQL code had if (!composedPartitionTokens.isEmpty()) was because it used raw SQL string concatenation (= ANY (Array[...])), where an untyped empty literal Array[] (without ::text[]) is a PostgreSQL syntax error (cannot determine type of empty array).
With a typed bound Spanner parameter $2 (Value.stringArray(...)), Spanner PostgreSQL resolves "PartitionToken" = ANY ($2) to the exact same $in_array(STRING, ARRAY) AST as GoogleSQL's IN UNNEST(@composedPartitionTokens) (verified in parameterized_queries.test), cleanly returning 0 rows when $2 is empty.
| ArgumentCaptor.forClass(ImmutableList.class); | ||
| doNothing().when(transaction).buffer(mutations.capture()); | ||
|
|
||
| assertNull(pgTransactionContext.updateToScheduled(Collections.singletonList(PARTITION_TOKEN))); |
There was a problem hiding this comment.
add test coverage of 0 and multiple tokens as well
There was a problem hiding this comment.
Done — added testInTransactionContextUpdateToScheduledPostgresEmptyTokens (0 tokens) and testInTransactionContextUpdateToScheduledPostgresMultipleTokens (multiple tokens) in 49ac029.
|
Thanks for clarifying! |
Summary
This PR fixes two PostgreSQL metadata query issues in the Spanner Change Streams connector (
PartitionMetadataDao):PartitionMetadataDao.tableExists()on PostgreSQL-dialect databases:tableExists()hardcodedWHERE t.table_catalog = '' AND t.table_schema = ''. In PostgreSQL-dialect Spanner databases,table_catalogis the database name and user tables reside intable_schema = 'public', causingtableExists()to always returnfalse.InitializeDoFnruns more than once (e.g., on retry or pipeline restart with an existing metadata table),createPartitionMetadataTable()(CREATE TABLE IF NOT EXISTS) succeeds as a no-op, andcreateFakeParentPartition()subsequently fails withALREADY_EXISTS: Row [Parent0] in table Metadata_... already exists.tableExists()to checkt.table_schema = 'public'whenthis.isPostgres()is true (consistent withfindAllTableIndexes()).PartitionMetadataDao.getPartitionsMatchingState()on PostgreSQL-dialect databases:getPartitionsMatchingState()to use bound query parameters (WHERE "State" = $1 AND "PartitionToken" = ANY ($2)) instead of string concatenation.composedPartitionTokenswas empty, the PostgreSQL branch omitted the"PartitionToken"filter (becauseArray[]without an explicit type cast is invalid syntax in string-concatenated PostgreSQL SQL), causing an unintended unfiltered scan and read lock on all rows matchingStateinside the read-write transaction even thoughupdateToScheduled()updates 0 rows. Always applying"PartitionToken" = ANY ($2)with a typedValue.stringArray(...)parameter evaluates an empty list tofalse(0 rows), aligning PostgreSQL with the GoogleSQL branch (WHERE PartitionToken IN UNNEST(@composedPartitionTokens) AND State = @state).Testing
PartitionMetadataDaoTestcovering both GoogleSQL and PostgreSQL dialects (including 0, 1, and multiple partition tokens forupdateToScheduled).