Skip to content

Commit 4afbc52

Browse files
committed
BigQuery: Poll via getQueryResults method.
This modifies the QueryJob's Futures interface implementation to poll using getQueryResults instead of jobs.get. This was recommended by BigQuery engineers because getQueryResults does HTTP long-polling for closer to realtime results.
1 parent 406ecad commit 4afbc52

8 files changed

Lines changed: 208 additions & 37 deletions

File tree

bigquery/google/cloud/bigquery/client.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,41 @@ def dataset(self, dataset_name, project=None):
162162
"""
163163
return Dataset(dataset_name, client=self, project=project)
164164

165+
def get_query_results(self, job_id, project=None, timeout_ms=None):
166+
"""Get the query results object for a query job.
167+
168+
:type job_id: str
169+
:param job_id: Name of the query job.
170+
171+
:type project: str
172+
:param project:
173+
(Optional) project ID for the query job (defaults to the project of
174+
the client).
175+
176+
:type timeout_ms: int
177+
:param timeout_ms:
178+
(Optional) number of milliseconds the the API call should wait for
179+
the query to complete before the request times out.
180+
181+
:rtype: :class:`google.cloud.bigquery.query.QueryResults`
182+
:returns: a new ``QueryResults`` instance
183+
"""
184+
185+
extra_params = {'maxResults': 0}
186+
187+
if project is None:
188+
project = self.project
189+
190+
if timeout_ms is not None:
191+
extra_params['timeoutMs'] = timeout_ms
192+
193+
path = '/projects/{}/queries/{}'.format(project, job_id)
194+
195+
resource = self._connection.api_request(
196+
method='GET', path=path, query_params=extra_params)
197+
198+
return QueryResults.from_api_repr(resource, self)
199+
165200
def job_from_resource(self, resource):
166201
"""Detect correct job type from resource and instantiate.
167202

bigquery/google/cloud/bigquery/dbapi/cursor.py

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -154,20 +154,13 @@ def execute(self, operation, parameters=None, job_id=None):
154154
query_parameters=query_parameters)
155155
query_job.use_legacy_sql = False
156156

157+
# Wait for the query to finish.
157158
try:
158-
query_results = query_job.result()
159+
query_job = query_job.result()
159160
except google.cloud.exceptions.GoogleCloudError:
160161
raise exceptions.DatabaseError(query_job.errors)
161162

162-
# Force the iterator to run because the query_results doesn't
163-
# have the total_rows populated. See:
164-
# https://github.com/GoogleCloudPlatform/google-cloud-python/issues/3506
165-
query_iterator = query_results.fetch_data()
166-
try:
167-
six.next(iter(query_iterator))
168-
except StopIteration:
169-
pass
170-
163+
query_results = query_job.query_results()
171164
self._query_data = iter(
172165
query_results.fetch_data(max_results=self.arraysize))
173166
self._set_rowcount(query_results)

bigquery/google/cloud/bigquery/job.py

Lines changed: 19 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1085,6 +1085,7 @@ def __init__(self, name, query, client,
10851085
self.udf_resources = udf_resources
10861086
self.query_parameters = query_parameters
10871087
self._configuration = _AsyncQueryConfiguration()
1088+
self._query_results = None
10881089

10891090
allow_large_results = _TypedProperty('allow_large_results', bool)
10901091
"""See
@@ -1284,23 +1285,25 @@ def query_results(self):
12841285
:rtype: :class:`~google.cloud.bigquery.query.QueryResults`
12851286
:returns: results instance
12861287
"""
1287-
from google.cloud.bigquery.query import QueryResults
1288-
return QueryResults.from_query_job(self)
1288+
if not self._query_results:
1289+
self._query_results = self._client.get_query_results(self.name)
1290+
return self._query_results
12891291

1290-
def result(self, timeout=None):
1291-
"""Start the job and wait for it to complete and get the result.
1292+
def done(self):
1293+
"""Refresh the job and checks if it is complete.
12921294
1293-
:type timeout: int
1294-
:param timeout: How long to wait for job to complete before raising
1295-
a :class:`TimeoutError`.
1295+
:rtype: bool
1296+
:returns: True if the job is complete, False otherwise.
1297+
"""
1298+
# Do not refresh is the state is already done, as the job will not
1299+
# change once complete.
1300+
if self.state != _DONE_STATE:
1301+
self._query_results = self._client.get_query_results(self.name)
12961302

1297-
:rtype: :class:`~google.cloud.bigquery.query.QueryResults`
1298-
:returns: The query results.
1303+
# Only reload the job once we know the query is complete.
1304+
# This will ensure that fields such as the destination table are
1305+
# correctly populated.
1306+
if self._query_results.complete:
1307+
self.reload()
12991308

1300-
:raises: :class:`~google.cloud.exceptions.GoogleCloudError` if the job
1301-
failed or :class:`TimeoutError` if the job did not complete in the
1302-
given timeout.
1303-
"""
1304-
super(QueryJob, self).result(timeout=timeout)
1305-
# Return a QueryResults instance instead of returning the job.
1306-
return self.query_results()
1309+
return self.state == _DONE_STATE

bigquery/google/cloud/bigquery/query.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,12 @@ def __init__(self, query, client, udf_resources=(), query_parameters=()):
7676
self.query_parameters = query_parameters
7777
self._job = None
7878

79+
@classmethod
80+
def from_api_repr(cls, api_response, client):
81+
instance = cls(None, client)
82+
instance._set_properties(api_response)
83+
return instance
84+
7985
@classmethod
8086
def from_query_job(cls, job):
8187
"""Factory: construct from an existing job.

bigquery/tests/system.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -599,6 +599,13 @@ def test_job_cancel(self):
599599
# raise an error, and that the job completed (in the `retry()`
600600
# above).
601601

602+
def test_get_query_results(self):
603+
job_id = 'test-get-query-results-' + str(uuid.uuid4())
604+
query_job = Config.CLIENT.run_async_query(job_id, 'SELECT 1')
605+
query_job.begin()
606+
results = Config.CLIENT.get_query_results(job_id)
607+
self.assertEqual(results.total_rows, 1)
608+
602609
def test_sync_query_w_legacy_sql_types(self):
603610
naive = datetime.datetime(2016, 12, 5, 12, 41, 9)
604611
stamp = '%s %s' % (naive.date().isoformat(), naive.time().isoformat())
@@ -1086,7 +1093,8 @@ def test_async_query_future(self):
10861093
str(uuid.uuid4()), 'SELECT 1')
10871094
query_job.use_legacy_sql = False
10881095

1089-
iterator = query_job.result(timeout=JOB_TIMEOUT).fetch_data()
1096+
query_job = query_job.result(timeout=JOB_TIMEOUT)
1097+
iterator = query_job.query_results().fetch_data()
10901098
rows = list(iterator)
10911099
self.assertEqual(rows, [(1,)])
10921100

bigquery/tests/unit/test_client.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,64 @@ def test_ctor(self):
4545
self.assertIs(client._connection.credentials, creds)
4646
self.assertIs(client._connection.http, http)
4747

48+
def test_get_job_miss_w_explicit_project_and_timeout(self):
49+
from google.cloud.exceptions import NotFound
50+
51+
project = 'PROJECT'
52+
creds = _make_credentials()
53+
client = self._make_one(project, creds)
54+
conn = client._connection = _Connection()
55+
56+
with self.assertRaises(NotFound):
57+
client.get_query_results(
58+
'nothere', project='other-project', timeout_ms=500)
59+
60+
self.assertEqual(len(conn._requested), 1)
61+
req = conn._requested[0]
62+
self.assertEqual(req['method'], 'GET')
63+
self.assertEqual(
64+
req['path'], '/projects/other-project/queries/nothere')
65+
self.assertEqual(
66+
req['query_params'], {'maxResults': 0, 'timeoutMs': 500})
67+
68+
def test_get_query_results_hit(self):
69+
project = 'PROJECT'
70+
job_id = 'query_job'
71+
data = {
72+
'kind': 'bigquery#getQueryResultsResponse',
73+
'etag': 'some-tag',
74+
'schema': {
75+
'fields': [
76+
{
77+
'name': 'title',
78+
'type': 'STRING',
79+
'mode': 'NULLABLE'
80+
},
81+
{
82+
'name': 'unique_words',
83+
'type': 'INTEGER',
84+
'mode': 'NULLABLE'
85+
}
86+
]
87+
},
88+
'jobReference': {
89+
'projectId': project,
90+
'jobId': job_id,
91+
},
92+
'totalRows': '10',
93+
'totalBytesProcessed': '2464625',
94+
'jobComplete': True,
95+
'cacheHit': False,
96+
}
97+
98+
creds = _make_credentials()
99+
client = self._make_one(project, creds)
100+
client._connection = _Connection(data)
101+
query_results = client.get_query_results(job_id)
102+
103+
self.assertEqual(query_results.total_rows, 10)
104+
self.assertTrue(query_results.complete)
105+
48106
def test_list_projects_defaults(self):
49107
import six
50108
from google.cloud.bigquery.client import Project
@@ -607,6 +665,11 @@ def __init__(self, *responses):
607665
self._requested = []
608666

609667
def api_request(self, **kw):
668+
from google.cloud.exceptions import NotFound
610669
self._requested.append(kw)
670+
671+
if len(self._responses) == 0:
672+
raise NotFound('miss')
673+
611674
response, self._responses = self._responses[0], self._responses[1:]
612675
return response

bigquery/tests/unit/test_dbapi_cursor.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,8 @@ def _mock_job(
4242
mock_job = mock.create_autospec(job.QueryJob)
4343
mock_job.error_result = None
4444
mock_job.state = 'DONE'
45-
mock_job.result.return_value = self._mock_results(
45+
mock_job.result.return_value = mock_job
46+
mock_job.query_results.return_value = self._mock_results(
4647
rows=rows, schema=schema,
4748
num_dml_affected_rows=num_dml_affected_rows)
4849
return mock_job

bigquery/tests/unit/test_job.py

Lines changed: 71 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,7 @@ def _makeResource(self, started=False, ended=False):
171171
}
172172

173173
if ended:
174+
resource['status'] = {'state': 'DONE'}
174175
resource['statistics']['load']['inputFiles'] = self.INPUT_FILES
175176
resource['statistics']['load']['inputFileBytes'] = self.INPUT_BYTES
176177
resource['statistics']['load']['outputBytes'] = self.OUTPUT_BYTES
@@ -310,6 +311,37 @@ def test_ctor_w_schema(self):
310311
schema=[full_name, age])
311312
self.assertEqual(job.schema, [full_name, age])
312313

314+
def test_done(self):
315+
client = _Client(self.PROJECT)
316+
resource = self._makeResource(ended=True)
317+
job = self._get_target_class().from_api_repr(resource, client)
318+
self.assertTrue(job.done())
319+
320+
def test_result(self):
321+
client = _Client(self.PROJECT)
322+
resource = self._makeResource(ended=True)
323+
job = self._get_target_class().from_api_repr(resource, client)
324+
325+
result = job.result()
326+
327+
self.assertIs(result, job)
328+
329+
def test_result_invokes_begins(self):
330+
begun_resource = self._makeResource()
331+
done_resource = copy.deepcopy(begun_resource)
332+
done_resource['status'] = {'state': 'DONE'}
333+
connection = _Connection(begun_resource, done_resource)
334+
client = _Client(self.PROJECT, connection=connection)
335+
table = _Table()
336+
job = self._make_one(self.JOB_NAME, table, [self.SOURCE1], client)
337+
338+
job.result()
339+
340+
self.assertEqual(len(connection._requested), 2)
341+
begin_request, reload_request = connection._requested
342+
self.assertEqual(begin_request['method'], 'POST')
343+
self.assertEqual(reload_request['method'], 'GET')
344+
313345
def test_schema_setter_non_list(self):
314346
client = _Client(self.PROJECT)
315347
table = _Table()
@@ -1421,6 +1453,10 @@ def _makeResource(self, started=False, ended=False):
14211453
started, ended)
14221454
config = resource['configuration']['query']
14231455
config['query'] = self.QUERY
1456+
1457+
if ended:
1458+
resource['status'] = {'state': 'DONE'}
1459+
14241460
return resource
14251461

14261462
def _verifyBooleanResourceProperties(self, job, config):
@@ -1640,40 +1676,60 @@ def test_cancelled(self):
16401676

16411677
self.assertTrue(job.cancelled())
16421678

1679+
def test_done(self):
1680+
client = _Client(self.PROJECT)
1681+
resource = self._makeResource(ended=True)
1682+
job = self._get_target_class().from_api_repr(resource, client)
1683+
self.assertTrue(job.done())
1684+
16431685
def test_query_results(self):
16441686
from google.cloud.bigquery.query import QueryResults
16451687

1646-
client = _Client(self.PROJECT)
1688+
query_resource = {'jobComplete': True}
1689+
connection = _Connection(query_resource)
1690+
client = _Client(self.PROJECT, connection=connection)
16471691
job = self._make_one(self.JOB_NAME, self.QUERY, client)
16481692
results = job.query_results()
16491693
self.assertIsInstance(results, QueryResults)
1650-
self.assertIs(results._job, job)
16511694

1652-
def test_result(self):
1695+
def test_query_results_w_cached_value(self):
16531696
from google.cloud.bigquery.query import QueryResults
16541697

16551698
client = _Client(self.PROJECT)
16561699
job = self._make_one(self.JOB_NAME, self.QUERY, client)
1657-
job._properties['status'] = {'state': 'DONE'}
1700+
query_results = QueryResults(None, client)
1701+
job._query_results = query_results
1702+
1703+
results = job.query_results()
1704+
1705+
self.assertIs(results, query_results)
1706+
1707+
def test_result(self):
1708+
client = _Client(self.PROJECT)
1709+
resource = self._makeResource(ended=True)
1710+
job = self._get_target_class().from_api_repr(resource, client)
16581711

16591712
result = job.result()
16601713

1661-
self.assertIsInstance(result, QueryResults)
1662-
self.assertIs(result._job, job)
1714+
self.assertIs(result, job)
16631715

16641716
def test_result_invokes_begins(self):
16651717
begun_resource = self._makeResource()
1718+
incomplete_resource = {'jobComplete': False}
1719+
query_resource = {'jobComplete': True}
16661720
done_resource = copy.deepcopy(begun_resource)
16671721
done_resource['status'] = {'state': 'DONE'}
1668-
connection = _Connection(begun_resource, done_resource)
1722+
connection = _Connection(
1723+
begun_resource, incomplete_resource, query_resource, done_resource)
16691724
client = _Client(self.PROJECT, connection=connection)
16701725
job = self._make_one(self.JOB_NAME, self.QUERY, client)
16711726

16721727
job.result()
16731728

1674-
self.assertEqual(len(connection._requested), 2)
1675-
begin_request, reload_request = connection._requested
1729+
self.assertEqual(len(connection._requested), 4)
1730+
begin_request, _, query_request, reload_request = connection._requested
16761731
self.assertEqual(begin_request['method'], 'POST')
1732+
self.assertEqual(query_request['method'], 'GET')
16771733
self.assertEqual(reload_request['method'], 'GET')
16781734

16791735
def test_result_error(self):
@@ -2088,6 +2144,12 @@ def dataset(self, name):
20882144

20892145
return Dataset(name, client=self)
20902146

2147+
def get_query_results(self, job_id):
2148+
from google.cloud.bigquery.query import QueryResults
2149+
2150+
resource = self._connection.api_request(method='GET')
2151+
return QueryResults.from_api_repr(resource, self)
2152+
20912153

20922154
class _Table(object):
20932155

0 commit comments

Comments
 (0)