fix(bigquery): clamp progress-bar cursor when query_plan shrinks (#16168)#17011
fix(bigquery): clamp progress-bar cursor when query_plan shrinks (#16168)#17011jbbqqf wants to merge 1 commit intogoogleapis:mainfrom
Conversation
…gleapis#16168) `wait_for_query` indexes into `query_job.query_plan` with a cursor `i` that survives across iterations of the polling loop, but `reload()` can return a shorter `query_plan` than the previous iteration (observed for MERGE queries — see googleapis#16168). Clamp `i` to the current plan length before indexing so the progress bar plateaus at the last stage instead of raising `IndexError: list index out of range`. Adds tests/unit/test__tqdm_helpers.py with a regression test that fails on origin/main with the exact stack trace from the issue and passes after the fix.
There was a problem hiding this comment.
Code Review
This pull request addresses an IndexError in the wait_for_query helper by clamping the stage index when the BigQuery query plan shrinks between iterations. It also introduces unit tests to validate this behavior. A review comment identified a non-functional loop in the newly added tests and suggested replacing it with a proper assertion to ensure the progress bar total is correctly set.
| for call in bar.total.__class__ == int and [] or []: | ||
| # placeholder: bar.total is a Mock attribute, no length assertion here | ||
| pass |
There was a problem hiding this comment.
This loop is non-functional because the expression bar.total.__class__ == int and [] or [] always evaluates to an empty list [], meaning the loop body never executes. Since progress_bar.total is assigned an integer value in wait_for_query, bar.total will be an integer after the function runs. Replacing this with a standard assertion improves test clarity and ensures the expected value is correctly set.
assert bar.total == len(plan)
Thank you for opening a Pull Request! Before submitting your PR, there are a few things you can do to make sure it goes smoothly:
Fixes #16168 🦕
Summary
wait_for_queryin_tqdm_helpers.pycrashes withIndexError: list index out of rangewhen the progress-bar cursoriadvances past the length ofquery_job.query_planafter areload(). This patch clampsito the current plan length before each indexed access so the progress bar plateaus at the last available stage instead of raising.Context
The polling loop in
wait_for_queryreads the query plan once per iteration (line 113 —current_stage = query_job.query_plan[i]) and only advancesiinside the COMPLETE branch of the previous iteration. Theif i < default_total - 1guard on line 131 uses the previous iteration'sdefault_total, so on the next pass:query_job.reload()runs and may return a shorter plan (BigQuery has been observed to coalesce or restructure stages between polls — issue reporter saw it on a MERGE query).default_totalis recomputed from the new (shorter) plan.query_job.query_plan[i]is indexed without re-checking — IndexError.The fix is a one-line clamp before the indexing access. The semantics are: when the plan shrinks, the cursor "freezes" at the last valid index and the bar holds steady until the query result arrives. This matches user expectation better than an exception.
Changes
packages/google-cloud-bigquery/google/cloud/bigquery/_tqdm_helpers.py— clampitodefault_total - 1if it has overrun the current plan, then index. Replaced redundantlen(query_job.query_plan)re-read with the freshly computeddefault_total. A short comment explains the invariant for future readers (the reason for the clamp is non-obvious from the diff alone).packages/google-cloud-bigquery/tests/unit/test__tqdm_helpers.py— new file, two regression tests. The first reproduces the exactIndexErrorfrom the issue's stack trace using a mockQueryJobwhosereload()swaps thequery_planto a shorter one between polls.Reproduce BEFORE/AFTER yourself (copy-paste)
A reviewer can verify the fix in ≤60s by pasting the block below.
The only thing that changes between BEFORE and AFTER is which version of
_tqdm_helpers.pyis checked out. The regression test is identical in both runs.What I ran locally
pytest tests/unit/test__tqdm_helpers.py -v→ 2/2 passed on the fix branch; 1 failed (the new regression test) + 1 passed on origin/main with exactly the IndexError from the issue's stack trace.tests/unit/job/test_query_pandas.pyandtests/unit/test_table.pytest files import pandas/pyarrow + dev-onlytest_utils; their full run is what GHAunittest.ymlalready exercises. The change here only touches the bounds-clamp code path that was previously unreachable from those existing tests (which use plans of fixed length).Edge cases tested
query_planevolutiontest_wait_for_query_handles_shrinking_query_plantest_wait_for_query_progress_does_not_overflow_default_totaltest_to_arrow_w_tqdm_w_query_planetc. — still passRisk / blast radius
wait_for_query), one extra branch. No public-API change. No type-signature change.i < default_total).query_planmutations — read-only access pattern unchanged.Release note
PR drafted with assistance from Claude Code. The change was reviewed manually against
googleapis/google-cloud-python's source. The reproducer block above was used during development; it is the same one a reviewer can paste verbatim.