Skip to content

feat(spanner-to-sourcedb): Classify NOT_FOUND as retryable to support interleaved table replication - #4045

Open
srozsnyai wants to merge 3 commits into
GoogleCloudPlatform:mainfrom
srozsnyai:fix/spanner-to-spanner-replication-interleaving
Open

feat(spanner-to-sourcedb): Classify NOT_FOUND as retryable to support interleaved table replication#4045
srozsnyai wants to merge 3 commits into
GoogleCloudPlatform:mainfrom
srozsnyai:fix/spanner-to-spanner-replication-interleaving

Conversation

@srozsnyai

Copy link
Copy Markdown
Contributor

This PR classifies NOT_FOUND database errors as retryable in the Spanner-to-SourceDb template.

When replicating interleaved tables, child record mutations can be delivered before their parent records. Currently, this results in a non-retryable NOT_FOUND error, causing replication to fail.

By classifying NOT_FOUND as retryable, the pipeline will retry the child write until the parent record is successfully replicated.

Changes:

  • Removed ErrorCode.NOT_FOUND from the non-retryable errors list in SpannerToSourceDbExceptionClassifier.
  • Added spannerToSpannerInterleavedOutOfOrderTest in SpannerToSpannerIT to simulate out-of-order delivery (deleting a parent on destination, writing a child update on source, then writing the parent) and verified that replication succeeds after a retry.

@srozsnyai
srozsnyai requested a review from a team as a code owner July 21, 2026 07:15
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request improves the robustness of the Spanner-to-SourceDb replication pipeline by enabling retries for NOT_FOUND errors. This change specifically addresses issues encountered when replicating interleaved tables where child mutations may arrive before their corresponding parent records, ensuring the pipeline can recover gracefully once the parent data is available.

Highlights

  • Retryable Error Classification: Removed ErrorCode.NOT_FOUND from the non-retryable errors list in SpannerToSourceDbExceptionClassifier to allow the pipeline to retry operations when parent records are missing during interleaved table replication.
  • Integration Testing: Added spannerToSpannerInterleavedOutOfOrderTest to simulate out-of-order delivery scenarios and verify that replication succeeds after retries.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize the Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counterproductive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request makes NOT_FOUND errors retryable in the Spanner-to-SourceDB pipeline and adds an integration test (spannerToSpannerInterleavedOutOfOrderTest) to verify out-of-order replication of interleaved tables. Feedback on these changes suggests recursively traversing the exception chain to distinguish between row-level and schema-level NOT_FOUND errors to prevent infinite retries on permanent schema mismatches. Additionally, it is recommended to replace the hardcoded Thread.sleep(5000) in the integration test with a polling mechanism (such as checking the GCS DLQ) to prevent potential race conditions and test flakiness.

Comment on lines 37 to 38
ErrorCode.INVALID_ARGUMENT,
ErrorCode.NOT_FOUND,
ErrorCode.FAILED_PRECONDITION,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

When classifying exceptions, we should recursively traverse the exception chain (using getCause()) to inspect both the outer exception and all nested causes, rather than only checking the immediate cause, to robustly handle both wrapped and unwrapped exceptions.

Additionally, classifying all NOT_FOUND errors as retryable can lead to unnecessary retries and resource waste for permanent schema-level errors (such as "Table not found" or "Column not found"). These schema mismatches are permanent and will never succeed upon retrying.

To prevent this, we should distinguish between row-level NOT_FOUND (e.g., missing parent row in interleaved tables) and schema-level NOT_FOUND by traversing the exception chain and inspecting the messages.

References
  1. When classifying exceptions, recursively traverse the exception chain (using getCause()) to inspect both the outer exception and all nested causes, rather than only checking the immediate cause, to robustly handle both wrapped and unwrapped exceptions.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd like to push back for the following reasons:

  1. Traversing the chain: Writes occur within Spanner's runInTransaction callback, which wraps the immediate exception in a SpannerException. Checking the immediate getCause() should be sufficient; recursion is unnecessary complexity.

  2. Parsing error messages: Distinguishing schema vs. row-level NOT_FOUND errors requires string-matching error messages. This is a brittle as message strings are not a stable API contract.

  3. Operational resilience: In CDC, treating a missing table as "permanent" forces data into a DLQ. Keeping it retryable should give the pipeline the opportunity to "self-heal" once an operator applies the missing DDL.

.to("Updated Child")
.build());

Thread.sleep(5000);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using a hardcoded Thread.sleep(5000) introduces a potential race condition and can make the test flaky or slow.

If the pipeline is slow to process the child update, the 5-second sleep might not be enough, and the parent row might be written to the source before the child update is attempted at the destination. If that happens, the child update will succeed on its first attempt without ever triggering the NOT_FOUND error or exercising the retry logic, resulting in a false positive test.

Instead of a fixed sleep, consider polling the DLQ directory in GCS using gcsResourceManager to verify that a retryable error has actually been logged before proceeding to restore the parent row.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd like to keep the Thread.sleep because I'd expect polling the DLQ would drastically slow down the test. In a streaming pipeline, writing to a GCS DLQ requires windowing, meaning we would have to wait a minute or more just for the file to be emitted.

Sleep seems to be used across of other our integration tests and 5secs provides enough time to propagte recordsin an isolated environment without unnecessarily inflating the test duration.

@codecov

codecov Bot commented Jul 21, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 55.76%. Comparing base (eeccfcf) to head (b399ff3).
⚠️ Report is 26 commits behind head on main.

Additional details and impacted files
@@             Coverage Diff              @@
##               main    #4045      +/-   ##
============================================
+ Coverage     55.74%   55.76%   +0.02%     
+ Complexity     7240     6799     -441     
============================================
  Files          1124     1126       +2     
  Lines         68476    68557      +81     
  Branches       7726     7734       +8     
============================================
+ Hits          38173    38232      +59     
- Misses        27826    27845      +19     
- Partials       2477     2480       +3     
Components Coverage Δ
spanner-templates 87.65% <ø> (-0.01%) ⬇️
spanner-import-export 68.99% <ø> (-0.02%) ⬇️
spanner-live-forward-migration 89.26% <ø> (-0.03%) ⬇️
spanner-live-reverse-replication 83.44% <ø> (-0.03%) ⬇️
spanner-bulk-migration 92.47% <ø> (-0.01%) ⬇️
gcs-spanner-dv 88.54% <ø> (-0.01%) ⬇️
Files with missing lines Coverage Δ
...es/utils/SpannerToSourceDbExceptionClassifier.java 95.00% <ø> (ø)

... and 18 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

child_id INT64 NOT NULL,
description STRING(25)
) PRIMARY KEY(id, child_id),
INTERLEAVE IN PARENT Parent ON DELETE CASCADE;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be on delete no action.

When you on delete cascade, the child will get removed as well.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed - reworked schema and the actual test

.set("description")
.to("Parent row")
.build());
spannerResourceManager.write(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The first part of the test does not accomplish much. We can just do the 2nd part, which is create the child first and parent 2nd, and that will assert the DLQ flow

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed - reworked schema and the actual test

@pull-request-size pull-request-size Bot added size/M and removed size/L labels Jul 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants