Repository: googleapis/google-cloud-python

Issue:
'_AsyncQuery.cancel' fails to update from returned resource
E.g.:

``` python
Traceback (most recent call last):
  File ...
    job.cancel()
  File ".../google/cloud/bigquery/job.py", line 378, in cancel
    self._set_properties(api_response)
  File ".../google/cloud/bigquery/job.py", line 262, in _set_properties
    self._scrub_local_properties(cleaned)
  File ".../google/cloud/bigquery/job.py", line 1050, in _scrub_local_properties
    configuration = cleaned['configuration']['query']
KeyError: 'configuration'
```

The [docs for `job.cancel`](https://cloud.google.com/bigquery/docs/reference/v2/jobs/cancel#response) show that the job resource is in a `job` subkey of the response.



Discussion:
@tseaver Germane to this change, I've run the following before to get a sense of our actual usage of `streaming`:

```
nosetests system_tests/storage.py system_tests/bigquery.py \
  --with-coverage  \
  --cover-branches  \
  --cover-tests  \
  --cover-package=gcloud.streaming  \
  --cover-package=gcloud.bigquery  \
  --cover-package=gcloud.storage  \
  --nocapture
```

Could be worthwhile to do it and see what code paths we aren't testing against the backend?

(**NOTE**: I'm aware this is using the old `gcloud` package and `nosetests`, sorry I am in a hurry and didn't update for the `py.test` equivalent.)

@dhermes I don't think that is germane to this issue, which doesn't mess with streaming at all.  Maybe you meant #1431?

No I just meant that if you got a sense for lines uncovered during system tests then an error like this would be detectable. The streaming bit was just an example so you could do the same locally.

Essentially this error is due to the fact that our code and unit tests make a bad assumption about the payload.


Resolving patch:
diff --git a/google/cloud/bigquery/job.py b/google/cloud/bigquery/job.py
--- a/google/cloud/bigquery/job.py
+++ b/google/cloud/bigquery/job.py
@@ -375,7 +375,7 @@ def cancel(self, client=None):
 
         api_response = client.connection.api_request(
             method='POST', path='%s/cancel' % (self.path,))
-        self._set_properties(api_response)
+        self._set_properties(api_response['job'])
 
 
 class _LoadConfiguration(object):


Test patch:
diff --git a/system_tests/bigquery.py b/system_tests/bigquery.py
--- a/system_tests/bigquery.py
+++ b/system_tests/bigquery.py
@@ -435,3 +435,37 @@ def _job_done(instance):
         by_age = operator.itemgetter(1)
         self.assertEqual(sorted(rows, key=by_age),
                          sorted(ROWS, key=by_age))
+
+    def test_job_cancel(self):
+        DATASET_NAME = _make_dataset_name('job_cancel')
+        JOB_NAME = 'fetch_' + DATASET_NAME
+        TABLE_NAME = 'test_table'
+        QUERY = 'SELECT * FROM %s.%s' % (DATASET_NAME, TABLE_NAME)
+
+        dataset = Config.CLIENT.dataset(DATASET_NAME)
+
+        retry_403(dataset.create)()
+        self.to_delete.append(dataset)
+
+        full_name = bigquery.SchemaField('full_name', 'STRING',
+                                         mode='REQUIRED')
+        age = bigquery.SchemaField('age', 'INTEGER', mode='REQUIRED')
+        table = dataset.table(TABLE_NAME, schema=[full_name, age])
+        table.create()
+        self.to_delete.insert(0, table)
+
+        job = Config.CLIENT.run_async_query(JOB_NAME, QUERY)
+        job.begin()
+        job.cancel()
+
+        def _job_done(instance):
+            return instance.state in ('DONE', 'done')
+
+        retry = RetryInstanceState(_job_done, max_tries=8)
+        retry(job.reload)()
+
+        # The `cancel` API doesn't leave any reliable traces on
+        # the status of the job resource, so we can't really assert for
+        # them here.  The best we can do is not that the API call didn't
+        # raise an error, and that the job completed (in the `retry()`
+        # above).
diff --git a/unit_tests/bigquery/test_job.py b/unit_tests/bigquery/test_job.py
--- a/unit_tests/bigquery/test_job.py
+++ b/unit_tests/bigquery/test_job.py
@@ -601,8 +601,9 @@ def test_reload_w_alternate_client(self):
 
     def test_cancel_w_bound_client(self):
         PATH = 'projects/%s/jobs/%s/cancel' % (self.PROJECT, self.JOB_NAME)
-        RESOURCE = self._makeResource()
-        conn = _Connection(RESOURCE)
+        RESOURCE = self._makeResource(ended=True)
+        RESPONSE = {'job': RESOURCE}
+        conn = _Connection(RESPONSE)
         client = _Client(project=self.PROJECT, connection=conn)
         table = _Table()
         job = self._makeOne(self.JOB_NAME, table, [self.SOURCE1], client)
@@ -617,10 +618,11 @@ def test_cancel_w_bound_client(self):
 
     def test_cancel_w_alternate_client(self):
         PATH = 'projects/%s/jobs/%s/cancel' % (self.PROJECT, self.JOB_NAME)
-        RESOURCE = self._makeResource()
+        RESOURCE = self._makeResource(ended=True)
+        RESPONSE = {'job': RESOURCE}
         conn1 = _Connection()
         client1 = _Client(project=self.PROJECT, connection=conn1)
-        conn2 = _Connection(RESOURCE)
+        conn2 = _Connection(RESPONSE)
         client2 = _Client(project=self.PROJECT, connection=conn2)
         table = _Table()
         job = self._makeOne(self.JOB_NAME, table, [self.SOURCE1], client1)