Skip to content

fix(storage): budget create-table/swap-tables job waits, add --timeout (#713) - #720

Draft
padak wants to merge 2 commits into
mainfrom
claude/issue-713-table-job-timeout
Draft

fix(storage): budget create-table/swap-tables job waits, add --timeout (#713)#720
padak wants to merge 2 commits into
mainfrom
claude/issue-713-table-job-timeout

Conversation

@padak

@padak padak commented Aug 30, 2026

Copy link
Copy Markdown
Member

What

storage create-table and storage swap-tables now default to a 300s job budget and accept --timeout SECONDS. The matching REST routes take an optional timeout field.

Why

Issue #713 asks for a metadata-only clustering update on an existing BigQuery table. That part is blocked upstream, not here — verified independently against the canonical Storage API PHP client, whose own type for the update endpoint is:

// keboola/storage-api-php-client, src/Keboola/StorageApi/Client.php:47
@phpstan-type TableDefinitionUpdate array{displayName?: string, description?: string|null, columns?: list<TableDefinitionColumnUpdate>}

No clustering, no partitioning. PUT /v2/storage/tables/{id}/definition cannot express the change, so a storage set-clustering command would have to be written against an endpoint that does not exist. Not implemented here.

What is fixable on the kbagent side is the second cost the issue lists: the copy+swap workaround — the only repartition path available today — has a job budget that is too short to survive it.

Both create-table --source-table-id (a full data copy) and swap-tables inherited STORAGE_JOB_MAX_WAIT = 60s, which exists for metadata jobs. Measured on an 800 MB BigQuery table: create ~15s, swap ~31s. A table a few times larger blows the budget, and because giving up locally never cancels the Storage job, the result is a STORAGE_JOB_TIMEOUT reported for an operation that is still running and will succeed. A short budget buys nothing and costs a false negative.

How

  • TABLE_DATA_JOB_MAX_WAIT = 300.0 in constants.py — the new default for both commands, with the measurements recorded as the rationale.
  • --timeout SECONDS on both commands; max_wait threaded through service → client → _wait_for_storage_job.
  • timeout: float | None = Field(default=None, gt=0) on the CreateTable / SwapTables request models, so a bad budget is a 422 at the boundary rather than an INVALID_ARGUMENT from the service.
  • The None-sentinel guard (reject <= 0, never timeout or DEFAULT0.0 is falsy) is now a single shared normalize_job_timeout() in services/base.py. workspace_service's private _normalize_timeout is migrated onto it rather than duplicated.

Deliberately unchanged: the client-level default. max_wait=None still resolves to 60s, so the raw SDK client and the upload-table auto-create path (an empty-table metadata create) keep their current behaviour. Only the two data-moving commands get the larger budget.

Behaviour change

A create/swap that previously failed at 60s now waits up to 300s before reporting a timeout. There is no case where the earlier failure was the desired outcome — it was a false negative on a live job — and --timeout 60 restores the old budget.

Testing

make check green (6383 passed, 12 skipped). New coverage:

  • tests/test_base_service.pynormalize_job_timeout: None → default, positive override wins, 0.0/negative rejected as INVALID_ARGUMENT.
  • tests/test_storage_swap.py — default is the data budget (asserted to exceed STORAGE_JOB_MAX_WAIT), explicit forwarding, non-positive rejected before any HTTP (including on --dry-run), budget reaches the poller, and an exhausted budget raises STORAGE_JOB_TIMEOUT with retryable=True naming the still-running job.
  • tests/test_storage_write.py — same matrix for create-table, plus CLI forwarding.
  • tests/test_server_router_calls.py — both routes forward timeout, omit → None, non-positive → 422 with the service never called.

Four pre-existing assert_called_once_with assertions were updated for the new kwarg.

No live-project verification: that needs a BigQuery project and credentials I do not handle.

Docs

All silent-drift surfaces from CLAUDE.md convention #17 updated: context.py (AGENT_CONTEXT), the CLAUDE.md command list, keboola-expert.md, commands-reference.md, gotchas.md (new entry: a STORAGE_JOB_TIMEOUT is not a failure — do not blindly re-issue a swap, repeating one that landed swaps the tables back), and storage-types-workflow.md. Tagged (since vNEXT, #713) per the feature-PR rule; no version bump, no changelog entry.

Note: inside CLAUDE.md's command fence every # line reads as an ATX heading to check_version_gates.py, so that blurb is indented past the 3-space allowance. Backticking the placeholder would also have passed, but the release-time residue scan applies the same quotation rule and would then never resolve it.

Not addressed

The clustering endpoint itself. #713 should stay open against the Storage API; this PR only makes its documented workaround survive a large table.

Refs #713 — deliberately not Fixes: the issue's actual ask (a metadata-only
clustering update) needs a Storage API change that does not exist yet, so merging this
must not auto-close it.

padak added 2 commits August 30, 2026 07:25
#713)

`storage create-table` and `storage swap-tables` -- the two halves of the
BigQuery repartition path -- both move real data, yet both inherited the
60s STORAGE_JOB_MAX_WAIT meant for metadata jobs. Measured on an 800 MB
BigQuery table: create ~15s, swap ~31s, so a table a few times larger blew
the budget and reported STORAGE_JOB_TIMEOUT for a job that was still
running and would succeed.

Giving up locally never cancels the Storage job, so a short budget buys
nothing and costs a false negative on the only in-place repartition path
available today (the Storage API's PUT .../definition has no clustering
field, so the copy+swap workaround cannot be replaced).

- New TABLE_DATA_JOB_MAX_WAIT (300s) default for both commands.
- New --timeout SECONDS on both, plus an optional `timeout` on the two
  REST routes (gt=0, so a bad budget is a 422 at the boundary).
- The `None`-sentinel/reject-non-positive guard is now one shared
  normalize_job_timeout() in services/base.py; workspace_service's
  private copy is migrated onto it.

The client-level default is unchanged (max_wait=None still means 60s), so
the raw SDK client and the upload-table auto-create path keep their
existing behaviour.
The new test armed a 0.0001s budget and patched out time.sleep, so it
depended on real elapsed time between two loop iterations. time.monotonic()
advances in ~15.6ms ticks on Windows, so the deadline never registered as
expired there: the poller kept polling and the failure surfaced as
ErrorCode.TIMEOUT plus unmatched GET /jobs/777 requests. It passed on macOS
only because one HTTP roundtrip happens to exceed 100us.

Use an already-expired budget (max_wait=-1.0) instead: the deadline is
behind the first check, so the timeout branch is reached with no poll, no
sleep and no dependence on the clock. Reproduced both shapes under a
simulated 15.6ms tick -- old: TIMEOUT after 8 polls; new: STORAGE_JOB_TIMEOUT
after 0.

Stubbing time.monotonic was rejected as the fix: patching it on the client
module patches the global time module, which httpx reads too.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant