Skip to content

bigquery: query() never uses the jobs.query fast path when jobTimeoutMs is set #9119

Description

@takaebato

Library Name

@google-cloud/bigquery

A screenshot that you have tested with "Try this API".

The screenshots show the issue is client-side, not API-side:

  1. jobs.query accepts jobTimeoutMs and returns results normally (HTTP 200).Image
  2. jobs.query enforces it: a ~30s query with jobTimeoutMs: "1000" returns HTTP 499 with "Job execution was cancelled: Job timed out after 1 sec".Image

What would you like to see in the library?

This is a request for the handwritten client logic in handwritten/bigquery, not for the API surface: the API already supports the field, and the limitation is a client-side denylist.

Current behavior

BigQuery.query() routes queries to the accelerated jobs.query endpoint unless an option appears on a denylist in buildQueryRequest_().
jobTimeoutMs is on that list, so setting it silently forces every query through the slower jobs.insert + getQueryResults path:

!!queryObj.jobTimeoutMs ||

// always goes through jobs.insert + polling, even for a tiny query
const [rows] = await bigquery.query({query: 'SELECT 1', jobTimeoutMs: 60000});

The API now supports jobTimeoutMs on jobs.query

  • The jobs.query request body now has a jobTimeoutMs field. This repository's generated IQueryRequest type already includes it:
    /**
    * Optional. Job timeout in milliseconds. If this time limit is exceeded, BigQuery will attempt to stop a longer job, but may not always succeed in canceling it before the job completes. For example, a job that takes more than 60 seconds to complete has a better chance of being stopped than a job that takes 10 seconds to complete. This timeout applies to the query even if a job does not need to be created.
    */
    jobTimeoutMs?: string;
  • The field's documentation explicitly covers the no-job case: "This timeout applies to the query even if a job does not need to be created."

Proposed change

Remove jobTimeoutMs from the denylist and map it into the IQueryRequest, converting the number to a string as the API expects an int64 field. This mirrors what createQueryJob() already does for the jobs.insert path.

A patch with a unit test is ready on my fork, based on current main. I will open a PR if the approach looks good: main...takaebato:google-cloud-node:bigquery-jobtimeoutms-fast-path

Describe alternatives you've considered

No real alternative: users today can only drop jobTimeoutMs or fall back to createQueryJob() and manage the job manually.

Additional context/notes

I verified the server-side behavior with direct REST calls.

1. jobTimeoutMs is enforced on the fast path, with the identical error as the slow path

A query that runs for roughly 30 seconds, submitted with a 1 second timeout via POST /projects/$PROJECT/queries:

{
  "query": "SELECT COUNT(*) FROM UNNEST(GENERATE_ARRAY(1, 1000000)) a CROSS JOIN UNNEST(GENERATE_ARRAY(1, 1000)) b WHERE MOD(a + b, 7) = 0",
  "useLegacySql": false,
  "useQueryCache": false,
  "jobTimeoutMs": "1000",
  "timeoutMs": 120000
}

returns HTTP 499 synchronously after about 7 seconds:

{
  "error": {
    "code": 499,
    "message": "Job execution was cancelled: Job timed out after 1 sec",
    "errors": [
      {
        "message": "Job execution was cancelled: Job timed out after 1 sec",
        "domain": "global",
        "reason": "stopped"
      }
    ],
    "status": "CANCELLED"
  }
}

The same query submitted via POST /projects/$PROJECT/jobs with {"configuration": {"query": {...}, "jobTimeoutMs": "1000"}} ends as DONE with an errorResult carrying the identical reason and message, and jobs.getQueryResults for that job returns a byte-identical HTTP 499 body. So the error surfaced to the client is the same regardless of path.

2. The timeout applies even when no job is created

The same fast path request with "jobCreationMode": "JOB_CREATION_OPTIONAL" added was cancelled as well, with the same error shape, confirming the documented behavior ("This timeout applies to the query even if a job does not need to be created").

3. The jobTimeoutMs: 0 case: only the request body changes, not the behavior

Before the patch, query({query, jobTimeoutMs: 0}) already took the fast path, because the denylist check is truthiness-based and 0 is falsy. The field was not sent in that case. With the patch, the field is sent as "jobTimeoutMs": "0". The server accepts "0" and treats it as "no timeout", so the observable behavior is the same, verified with

{"query": "SELECT 1", "useLegacySql": false, "jobTimeoutMs": "0"}

returning HTTP 200 with results. This also matches createQueryJob(), which has always sent "0" on the jobs.insert path.

Metadata

Metadata

Assignees

No one assigned

    Labels

    api: bigqueryIssues related to the BigQuery API.

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions