Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion tableauserverclient/server/endpoint/datasources_endpoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -497,7 +497,22 @@ def create_extract(self, datasource_item: DatasourceItem, encrypt: bool = False)
Returns
-------
JobItem
The job item.
The job item. The id it carries cannot be round-tripped through
`server.jobs.get_by_id(job.id)` -- that call raises
`ServerResponseError` with error code `400031` (see Notes).
`server.jobs.wait_for_job(job)` handles this transparently.

Notes
-----
Unlike `datasources.refresh(...)`, the JobItem returned here is not
addressable by `server.jobs.get_by_id(job.id)`: that call raises
`ServerResponseError` with error code `400031` and the message
"There was a problem querying job '{id}'.". See
tableau/server-client-python#1093. `server.jobs.wait_for_job(job)`
works normally -- it detects that error and falls back to polling
the paginated `/jobs` listing (see its own docstring for detail).
Callers who need a single JobItem outside the polling flow can
match on id themselves via `TSC.Pager(server.jobs)`.
"""
id_ = getattr(datasource_item, "id", datasource_item)
url = f"{self.baseurl}/{id_}/createExtract?encrypt={encrypt}"
Expand Down
96 changes: 92 additions & 4 deletions tableauserverclient/server/endpoint/jobs_endpoint.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,32 @@
import logging
from typing import cast
from typing_extensions import Self, overload


from tableauserverclient.models import JobItem, BackgroundJobItem, PaginationItem
from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api
from tableauserverclient.server.endpoint.exceptions import JobCancelledException, JobFailedException
from tableauserverclient.server.endpoint.exceptions import (
JobCancelledException,
JobFailedException,
ServerResponseError,
)
from tableauserverclient.server.pager import Endpoint, Pager
from tableauserverclient.server.query import QuerySet
from tableauserverclient.server.request_options import RequestOptionsBase
from tableauserverclient.server.request_options import RequestOptions, RequestOptionsBase
from tableauserverclient.exponential_backoff import ExponentialBackoffTimer

from tableauserverclient.helpers.logging import logger

# Server error code raised by GET /jobs/{id} for jobs whose ids are not
# addressable via the single-job endpoint (notably the JobItems returned by
# `create_extract`). Tracked at tableau/server-client-python#1093.
_UNQUERYABLE_JOB_ERROR_CODE = "400031"

# Server max for the pageSize query parameter (Tableau raises 403014 above
# this). Used when the fallback poll walks /jobs looking for a specific id;
# a large page keeps sequential GETs on a busy site down to O(1) per poll.
_JOBS_LISTING_FALLBACK_PAGE_SIZE = 1000


class Jobs(QuerysetEndpoint[BackgroundJobItem]):
@property
Expand Down Expand Up @@ -171,17 +187,37 @@ def wait_for_job(self, job_id: str | JobItem, *, timeout: float | None = None) -

JobCancelledException
If the job was cancelled.

Notes
-----
Some job ids returned elsewhere in the client are not addressable
via `GET /jobs/{id}` and produce a `ServerResponseError` with code
`400031`. When that happens, this method transparently falls back
to polling the paginated `/jobs` listing and matches on `id`. See
tableau/server-client-python#1093.

On the fallback path the returned `JobItem` is translated from a
`BackgroundJobItem` (the shape the listing endpoint returns).
`BackgroundJobItem` doesn't carry every field a `JobItem` normally
has, so these come back as defaults:
* `progress` = `""`
* `notes` = `[]`
* `status_notes` = `[]`
* `mode`, `workbook_id`, `datasource_id`, `updated_at`,
`workbook_name`, `datasource_name`, `flow_run` = `None`
Check the values, not the type -- e.g. use `if not job.notes:`, not
`if job.notes is None:`.
"""
if isinstance(job_id, JobItem):
job_id = job_id.id
assert isinstance(job_id, str)
logger.debug(f"Waiting for job {job_id}")

backoffTimer = ExponentialBackoffTimer(timeout=timeout)
job = self.get_by_id(job_id)
job = self._poll_job(job_id)
while job.completed_at is None:
backoffTimer.sleep()
job = self.get_by_id(job_id)
job = self._poll_job(job_id)
logger.debug(f"\tJob {job_id} progress={job.progress}")

logger.info(f"Job {job_id} Completed: Finish Code: {job.finish_code} - Notes:{job.notes}")
Expand All @@ -195,6 +231,58 @@ def wait_for_job(self, job_id: str | JobItem, *, timeout: float | None = None) -
else:
raise AssertionError("Unexpected finish_code in job", job)

def _poll_job(self, job_id: str) -> JobItem:
try:
return self.get_by_id(job_id)
except ServerResponseError as err:
if err.code != _UNQUERYABLE_JOB_ERROR_CODE:
raise
logger.debug(
f"Job {job_id} not queryable via /jobs/{{id}} (400031); falling back to /jobs listing (see #1093)."
)

# `Jobs.get`'s overloads don't line up with the single-signature
# `Endpoint`/`CallableEndpoint` protocols, so a bare `Pager(self)`
# fails mypy even though it works at runtime. Cast at the call
# site: expresses intent locally, and if `Jobs.get`'s overloads
# ever change to match the protocol the cast surfaces the
# mismatch instead of silently hiding it.
options = RequestOptions(pagesize=_JOBS_LISTING_FALLBACK_PAGE_SIZE)
pager: Pager[BackgroundJobItem] = Pager(cast(Endpoint[BackgroundJobItem], self), request_opts=options)
for bg_job in pager:
if bg_job.id == job_id:
return self._background_to_job(bg_job)
raise

@staticmethod
def _background_to_job(bg_job: BackgroundJobItem) -> JobItem:
status_map = {
BackgroundJobItem.Status.Success: JobItem.FinishCode.Success,
BackgroundJobItem.Status.Failed: JobItem.FinishCode.Failed,
BackgroundJobItem.Status.Cancelled: JobItem.FinishCode.Cancelled,
}
# Success, Failed, and Cancelled map to their FinishCode. Anything else --
# Pending, InProgress, a None status, or a future addition -- keeps
# completed_at=None so wait_for_job's loop re-polls, and uses finish_code=-1
# (never a real FinishCode) so a reader inspecting finish_code alone can't
# mistake an unfinished job for Success.
if bg_job.status in status_map:
finish_code = status_map[bg_job.status]
completed_at = bg_job.ended_at
else:
finish_code = -1
completed_at = None
return JobItem(
id_=bg_job.id,
job_type=bg_job.type,
progress="",
created_at=bg_job.created_at,
started_at=bg_job.started_at,
completed_at=completed_at,
finish_code=finish_code,
notes=None,
)

def filter(self, *invalid, page_size: int | None = None, **kwargs) -> QuerySet[BackgroundJobItem]:
"""
Queries the Tableau Server for items using the specified filters. Page
Expand Down
19 changes: 18 additions & 1 deletion tableauserverclient/server/endpoint/workbooks_endpoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,23 @@ def create_extract(
Returns
-------
JobItem
The job item for the extract creation.
The job item for the extract creation. The id it carries cannot
be round-tripped through `server.jobs.get_by_id(job.id)` -- that
call raises `ServerResponseError` with error code `400031` (see
Notes). `server.jobs.wait_for_job(job)` handles this
transparently.

Notes
-----
Unlike `workbooks.refresh(...)`, the JobItem returned here is not
addressable by `server.jobs.get_by_id(job.id)`: that call raises
`ServerResponseError` with error code `400031` and the message
"There was a problem querying job '{id}'.". See
tableau/server-client-python#1093. `server.jobs.wait_for_job(job)`
works normally -- it detects that error and falls back to polling
the paginated `/jobs` listing (see its own docstring for detail).
Callers who need a single JobItem outside the polling flow can
match on id themselves via `TSC.Pager(server.jobs)`.
"""
id_ = getattr(workbook_item, "id", workbook_item)
url = f"{self.baseurl}/{id_}/createExtract?encrypt={encrypt}"
Expand Down Expand Up @@ -222,6 +238,7 @@ def delete_extract(self, workbook_item: WorkbookItem, includeAll: bool = True, d
Returns
-------
JobItem
The job item.
"""
id_ = getattr(workbook_item, "id", workbook_item)
url = f"{self.baseurl}/{id_}/deleteExtract"
Expand Down
Loading
Loading