github.com/apache/beam/sdks/v2@v2.48.2/python/apache_beam/runners/portability/portable_runner.py (about)

     1  #
     2  # Licensed to the Apache Software Foundation (ASF) under one or more
     3  # contributor license agreements.  See the NOTICE file distributed with
     4  # this work for additional information regarding copyright ownership.
     5  # The ASF licenses this file to You under the Apache License, Version 2.0
     6  # (the "License"); you may not use this file except in compliance with
     7  # the License.  You may obtain a copy of the License at
     8  #
     9  #    http://www.apache.org/licenses/LICENSE-2.0
    10  #
    11  # Unless required by applicable law or agreed to in writing, software
    12  # distributed under the License is distributed on an "AS IS" BASIS,
    13  # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    14  # See the License for the specific language governing permissions and
    15  # limitations under the License.
    16  #
    17  
    18  # pytype: skip-file
    19  # mypy: check-untyped-defs
    20  
    21  import atexit
    22  import functools
    23  import itertools
    24  import logging
    25  import threading
    26  import time
    27  from typing import TYPE_CHECKING
    28  from typing import Any
    29  from typing import Dict
    30  from typing import Iterator
    31  from typing import Optional
    32  from typing import Tuple
    33  
    34  import grpc
    35  
    36  from apache_beam.metrics import metric
    37  from apache_beam.metrics.execution import MetricResult
    38  from apache_beam.options.pipeline_options import DebugOptions
    39  from apache_beam.options.pipeline_options import PortableOptions
    40  from apache_beam.options.pipeline_options import SetupOptions
    41  from apache_beam.options.pipeline_options import StandardOptions
    42  from apache_beam.options.pipeline_options import TypeOptions
    43  from apache_beam.options.value_provider import ValueProvider
    44  from apache_beam.portability import common_urns
    45  from apache_beam.portability.api import beam_artifact_api_pb2_grpc
    46  from apache_beam.portability.api import beam_job_api_pb2
    47  from apache_beam.runners import runner
    48  from apache_beam.runners.common import group_by_key_input_visitor
    49  from apache_beam.runners.job import utils as job_utils
    50  from apache_beam.runners.portability import artifact_service
    51  from apache_beam.runners.portability import job_server
    52  from apache_beam.runners.portability import portable_metrics
    53  from apache_beam.runners.portability.fn_api_runner.fn_runner import translations
    54  from apache_beam.runners.worker import sdk_worker_main
    55  from apache_beam.runners.worker import worker_pool_main
    56  from apache_beam.transforms import environments
    57  
    58  if TYPE_CHECKING:
    59    from google.protobuf import struct_pb2  # pylint: disable=ungrouped-imports
    60    from apache_beam.options.pipeline_options import PipelineOptions
    61    from apache_beam.pipeline import Pipeline
    62    from apache_beam.portability.api import beam_runner_api_pb2
    63  
    64  __all__ = ['PortableRunner']
    65  
    66  MESSAGE_LOG_LEVELS = {
    67      beam_job_api_pb2.JobMessage.MESSAGE_IMPORTANCE_UNSPECIFIED: logging.INFO,
    68      beam_job_api_pb2.JobMessage.JOB_MESSAGE_DEBUG: logging.DEBUG,
    69      beam_job_api_pb2.JobMessage.JOB_MESSAGE_DETAILED: logging.DEBUG,
    70      beam_job_api_pb2.JobMessage.JOB_MESSAGE_BASIC: logging.INFO,
    71      beam_job_api_pb2.JobMessage.JOB_MESSAGE_WARNING: logging.WARNING,
    72      beam_job_api_pb2.JobMessage.JOB_MESSAGE_ERROR: logging.ERROR,
    73  }
    74  
    75  TERMINAL_STATES = [
    76      beam_job_api_pb2.JobState.DONE,
    77      beam_job_api_pb2.JobState.DRAINED,
    78      beam_job_api_pb2.JobState.FAILED,
    79      beam_job_api_pb2.JobState.CANCELLED,
    80  ]
    81  
    82  ENV_TYPE_ALIASES = {'LOOPBACK': 'EXTERNAL'}
    83  
    84  _LOGGER = logging.getLogger(__name__)
    85  
    86  
    87  class JobServiceHandle(object):
    88    """
    89    Encapsulates the interactions necessary to submit a pipeline to a job service.
    90  
    91    The base set of interactions consists of 3 steps:
    92    - prepare
    93    - stage
    94    - run
    95    """
    96    def __init__(self, job_service, options, retain_unknown_options=False):
    97      self.job_service = job_service
    98      self.options = options
    99      self.timeout = options.view_as(PortableOptions).job_server_timeout
   100      self.artifact_endpoint = options.view_as(PortableOptions).artifact_endpoint
   101      self._retain_unknown_options = retain_unknown_options
   102  
   103    def submit(self, proto_pipeline):
   104      # type: (beam_runner_api_pb2.Pipeline) -> Tuple[str, Iterator[beam_job_api_pb2.JobStateEvent], Iterator[beam_job_api_pb2.JobMessagesResponse]]
   105  
   106      """
   107      Submit and run the pipeline defined by `proto_pipeline`.
   108      """
   109      prepare_response = self.prepare(proto_pipeline)
   110      artifact_endpoint = (
   111          self.artifact_endpoint or
   112          prepare_response.artifact_staging_endpoint.url)
   113      self.stage(
   114          proto_pipeline,
   115          artifact_endpoint,
   116          prepare_response.staging_session_token)
   117      return self.run(prepare_response.preparation_id)
   118  
   119    def get_pipeline_options(self):
   120      # type: () -> struct_pb2.Struct
   121  
   122      """
   123      Get `self.options` as a protobuf Struct
   124      """
   125  
   126      # fetch runner options from job service
   127      # retries in case the channel is not ready
   128      def send_options_request(max_retries=5):
   129        num_retries = 0
   130        while True:
   131          try:
   132            # This reports channel is READY but connections may fail
   133            # Seems to be only an issue on Mac with port forwardings
   134            return self.job_service.DescribePipelineOptions(
   135                beam_job_api_pb2.DescribePipelineOptionsRequest(),
   136                timeout=self.timeout)
   137          except grpc.FutureTimeoutError:
   138            # no retry for timeout errors
   139            raise
   140          except grpc.RpcError as e:
   141            num_retries += 1
   142            if num_retries > max_retries:
   143              raise e
   144            time.sleep(1)
   145  
   146      options_response = send_options_request()
   147  
   148      def add_runner_options(parser):
   149        for option in options_response.options:
   150          try:
   151            # no default values - we don't want runner options
   152            # added unless they were specified by the user
   153            add_arg_args = {'action': 'store', 'help': option.description}
   154            if option.type == beam_job_api_pb2.PipelineOptionType.BOOLEAN:
   155              add_arg_args['action'] = 'store_true' \
   156                if option.default_value != 'true' else 'store_false'
   157            elif option.type == beam_job_api_pb2.PipelineOptionType.INTEGER:
   158              add_arg_args['type'] = int
   159            elif option.type == beam_job_api_pb2.PipelineOptionType.ARRAY:
   160              add_arg_args['action'] = 'append'
   161            parser.add_argument("--%s" % option.name, **add_arg_args)
   162          except Exception as e:
   163            # ignore runner options that are already present
   164            # only in this case is duplicate not treated as error
   165            if 'conflicting option string' not in str(e):
   166              raise
   167            _LOGGER.debug("Runner option '%s' was already added" % option.name)
   168  
   169      all_options = self.options.get_all_options(
   170          add_extra_args_fn=add_runner_options,
   171          retain_unknown_options=self._retain_unknown_options)
   172  
   173      return self.encode_pipeline_options(all_options)
   174  
   175    @staticmethod
   176    def encode_pipeline_options(
   177        all_options: Dict[str, Any]) -> 'struct_pb2.Struct':
   178      def convert_pipeline_option_value(v):
   179        # convert int values: BEAM-5509
   180        if type(v) == int:
   181          return str(v)
   182        elif isinstance(v, ValueProvider):
   183          return convert_pipeline_option_value(
   184              v.get()) if v.is_accessible() else None
   185        return v
   186  
   187      # TODO: Define URNs for options.
   188      p_options = {
   189          'beam:option:' + k + ':v1': convert_pipeline_option_value(v)
   190          for k,
   191          v in all_options.items() if v is not None
   192      }
   193      return job_utils.dict_to_struct(p_options)
   194  
   195    def prepare(self, proto_pipeline):
   196      # type: (beam_runner_api_pb2.Pipeline) -> beam_job_api_pb2.PrepareJobResponse
   197  
   198      """Prepare the job on the job service"""
   199      return self.job_service.Prepare(
   200          beam_job_api_pb2.PrepareJobRequest(
   201              job_name='job',
   202              pipeline=proto_pipeline,
   203              pipeline_options=self.get_pipeline_options()),
   204          timeout=self.timeout)
   205  
   206    def stage(self,
   207              proto_pipeline,  # type: beam_runner_api_pb2.Pipeline
   208              artifact_staging_endpoint,
   209              staging_session_token
   210             ):
   211      # type: (...) -> None
   212  
   213      """Stage artifacts"""
   214      if artifact_staging_endpoint:
   215        artifact_service.offer_artifacts(
   216            beam_artifact_api_pb2_grpc.ArtifactStagingServiceStub(
   217                channel=grpc.insecure_channel(artifact_staging_endpoint)),
   218            artifact_service.ArtifactRetrievalService(
   219                artifact_service.BeamFilesystemHandler(None).file_reader),
   220            staging_session_token)
   221  
   222    def run(self, preparation_id):
   223      # type: (str) -> Tuple[str, Iterator[beam_job_api_pb2.JobStateEvent], Iterator[beam_job_api_pb2.JobMessagesResponse]]
   224  
   225      """Run the job"""
   226      try:
   227        state_stream = self.job_service.GetStateStream(
   228            beam_job_api_pb2.GetJobStateRequest(job_id=preparation_id),
   229            timeout=self.timeout)
   230        # If there's an error, we don't always get it until we try to read.
   231        # Fortunately, there's always an immediate current state published.
   232        state_stream = itertools.chain([next(state_stream)], state_stream)
   233        message_stream = self.job_service.GetMessageStream(
   234            beam_job_api_pb2.JobMessagesRequest(job_id=preparation_id),
   235            timeout=self.timeout)
   236      except Exception:
   237        # TODO(https://github.com/apache/beam/issues/19284): Unify preparation_id
   238        # and job_id for all runners.
   239        state_stream = message_stream = None
   240  
   241      # Run the job and wait for a result, we don't set a timeout here because
   242      # it may take a long time for a job to complete and streaming
   243      # jobs currently never return a response.
   244      run_response = self.job_service.Run(
   245          beam_job_api_pb2.RunJobRequest(preparation_id=preparation_id))
   246  
   247      if state_stream is None:
   248        state_stream = self.job_service.GetStateStream(
   249            beam_job_api_pb2.GetJobStateRequest(job_id=run_response.job_id))
   250        message_stream = self.job_service.GetMessageStream(
   251            beam_job_api_pb2.JobMessagesRequest(job_id=run_response.job_id))
   252  
   253      return run_response.job_id, message_stream, state_stream
   254  
   255  
   256  class PortableRunner(runner.PipelineRunner):
   257    """
   258      Experimental: No backward compatibility guaranteed.
   259      A BeamRunner that executes Python pipelines via the Beam Job API.
   260  
   261      This runner is a stub and does not run the actual job.
   262      This runner schedules the job on a job service. The responsibility of
   263      running and managing the job lies with the job service used.
   264    """
   265    def __init__(self):
   266      self._dockerized_job_server = None  # type: Optional[job_server.JobServer]
   267  
   268    @staticmethod
   269    def _create_environment(options):
   270      # type: (PipelineOptions) -> environments.Environment
   271      portable_options = options.view_as(PortableOptions)
   272      # Do not set a Runner. Otherwise this can cause problems in Java's
   273      # PipelineOptions, i.e. ClassNotFoundException, if the corresponding Runner
   274      # does not exist in the Java SDK. In portability, the entry point is clearly
   275      # defined via the JobService.
   276      portable_options.view_as(StandardOptions).runner = None
   277      environment_type = portable_options.environment_type
   278      if not environment_type:
   279        environment_urn = common_urns.environments.DOCKER.urn
   280      elif environment_type.startswith('beam:env:'):
   281        environment_urn = environment_type
   282      else:
   283        # e.g. handle LOOPBACK -> EXTERNAL
   284        environment_type = ENV_TYPE_ALIASES.get(
   285            environment_type, environment_type)
   286        try:
   287          environment_urn = getattr(
   288              common_urns.environments, environment_type).urn
   289        except AttributeError:
   290          raise ValueError('Unknown environment type: %s' % environment_type)
   291  
   292      env_class = environments.Environment.get_env_cls_from_urn(environment_urn)
   293      return env_class.from_options(portable_options)
   294  
   295    def default_job_server(self, options):
   296      raise NotImplementedError(
   297          'You must specify a --job_endpoint when using --runner=PortableRunner. '
   298          'Alternatively, you may specify which portable runner you intend to '
   299          'use, such as --runner=FlinkRunner or --runner=SparkRunner.')
   300  
   301    def create_job_service_handle(self, job_service, options):
   302      # type: (...) -> JobServiceHandle
   303      return JobServiceHandle(job_service, options)
   304  
   305    def create_job_service(self, options):
   306      # type: (PipelineOptions) -> JobServiceHandle
   307  
   308      """
   309      Start the job service and return a `JobServiceHandle`
   310      """
   311      job_endpoint = options.view_as(PortableOptions).job_endpoint
   312      if job_endpoint:
   313        if job_endpoint == 'embed':
   314          server = job_server.EmbeddedJobServer()  # type: job_server.JobServer
   315        else:
   316          job_server_timeout = options.view_as(PortableOptions).job_server_timeout
   317          server = job_server.ExternalJobServer(job_endpoint, job_server_timeout)
   318      else:
   319        server = self.default_job_server(options)
   320      return self.create_job_service_handle(server.start(), options)
   321  
   322    @staticmethod
   323    def get_proto_pipeline(pipeline, options):
   324      # type: (Pipeline, PipelineOptions) -> beam_runner_api_pb2.Pipeline
   325      portable_options = options.view_as(PortableOptions)
   326  
   327      proto_pipeline = pipeline.to_runner_api(
   328          default_environment=PortableRunner._create_environment(
   329              portable_options))
   330  
   331      # TODO: https://github.com/apache/beam/issues/19493
   332      # Eventually remove the 'pre_optimize' option alltogether and only perform
   333      # the equivalent of the 'default' case below (minus the 'lift_combiners'
   334      # part).
   335      pre_optimize = options.view_as(DebugOptions).lookup_experiment(
   336          'pre_optimize', 'default').lower()
   337      if (not options.view_as(StandardOptions).streaming and
   338          pre_optimize != 'none'):
   339        if pre_optimize == 'default':
   340          phases = [
   341              # TODO: https://github.com/apache/beam/issues/18584
   342              #       https://github.com/apache/beam/issues/18586
   343              # Eventually remove the 'lift_combiners' phase from 'default'.
   344              translations.pack_combiners,
   345              translations.lift_combiners,
   346              translations.sort_stages
   347          ]
   348          partial = True
   349        elif pre_optimize == 'all':
   350          phases = [
   351              translations.annotate_downstream_side_inputs,
   352              translations.annotate_stateful_dofns_as_roots,
   353              translations.fix_side_input_pcoll_coders,
   354              translations.pack_combiners,
   355              translations.lift_combiners,
   356              translations.expand_sdf,
   357              translations.fix_flatten_coders,
   358              # translations.sink_flattens,
   359              translations.greedily_fuse,
   360              translations.read_to_impulse,
   361              translations.extract_impulse_stages,
   362              translations.remove_data_plane_ops,
   363              translations.sort_stages
   364          ]
   365          partial = False
   366        elif pre_optimize == 'all_except_fusion':
   367          # TODO(https://github.com/apache/beam/issues/19422): Delete this branch
   368          # after PortableRunner supports beam:runner:executable_stage:v1.
   369          phases = [
   370              translations.annotate_downstream_side_inputs,
   371              translations.annotate_stateful_dofns_as_roots,
   372              translations.fix_side_input_pcoll_coders,
   373              translations.pack_combiners,
   374              translations.lift_combiners,
   375              translations.expand_sdf,
   376              translations.fix_flatten_coders,
   377              # translations.sink_flattens,
   378              # translations.greedily_fuse,
   379              translations.read_to_impulse,
   380              translations.extract_impulse_stages,
   381              translations.remove_data_plane_ops,
   382              translations.sort_stages
   383          ]
   384          partial = True
   385        else:
   386          phases = []
   387          for phase_name in pre_optimize.split(','):
   388            # For now, these are all we allow.
   389            if phase_name in ('pack_combiners', 'lift_combiners'):
   390              phases.append(getattr(translations, phase_name))
   391            else:
   392              raise ValueError(
   393                  'Unknown or inapplicable phase for pre_optimize: %s' %
   394                  phase_name)
   395          phases.append(translations.sort_stages)
   396          partial = True
   397  
   398        # All (known) portable runners (ie Flink and Spark) support these URNs.
   399        known_urns = frozenset([
   400            common_urns.composites.RESHUFFLE.urn,
   401            common_urns.primitives.IMPULSE.urn,
   402            common_urns.primitives.FLATTEN.urn,
   403            common_urns.primitives.GROUP_BY_KEY.urn
   404        ])
   405        proto_pipeline = translations.optimize_pipeline(
   406            proto_pipeline,
   407            phases=phases,
   408            known_runner_urns=known_urns,
   409            partial=partial)
   410  
   411      return proto_pipeline
   412  
   413    def run_pipeline(self, pipeline, options):
   414      # type: (Pipeline, PipelineOptions) -> PipelineResult
   415      portable_options = options.view_as(PortableOptions)
   416  
   417      # TODO: https://github.com/apache/beam/issues/19168
   418      # portable runner specific default
   419      if options.view_as(SetupOptions).sdk_location == 'default':
   420        options.view_as(SetupOptions).sdk_location = 'container'
   421  
   422      experiments = options.view_as(DebugOptions).experiments or []
   423  
   424      # This is needed as we start a worker server if one is requested
   425      # but none is provided.
   426      if portable_options.environment_type == 'LOOPBACK':
   427        use_loopback_process_worker = options.view_as(
   428            DebugOptions).lookup_experiment('use_loopback_process_worker', False)
   429        portable_options.environment_config, server = (
   430            worker_pool_main.BeamFnExternalWorkerPoolServicer.start(
   431                state_cache_size=
   432                sdk_worker_main._get_state_cache_size(experiments),
   433                data_buffer_time_limit_ms=
   434                sdk_worker_main._get_data_buffer_time_limit_ms(experiments),
   435                use_process=use_loopback_process_worker))
   436        cleanup_callbacks = [functools.partial(server.stop, 1)]
   437      else:
   438        cleanup_callbacks = []
   439  
   440      pipeline.visit(
   441          group_by_key_input_visitor(
   442              not options.view_as(TypeOptions).allow_non_deterministic_key_coders)
   443      )
   444  
   445      proto_pipeline = self.get_proto_pipeline(pipeline, options)
   446      job_service_handle = self.create_job_service(options)
   447      job_id, message_stream, state_stream = \
   448        job_service_handle.submit(proto_pipeline)
   449  
   450      result = PipelineResult(
   451          job_service_handle.job_service,
   452          job_id,
   453          message_stream,
   454          state_stream,
   455          cleanup_callbacks)
   456      if cleanup_callbacks:
   457        # Register an exit handler to ensure cleanup on exit.
   458        atexit.register(functools.partial(result._cleanup, on_exit=True))
   459        _LOGGER.info(
   460            'Environment "%s" has started a component necessary for the '
   461            'execution. Be sure to run the pipeline using\n'
   462            '  with Pipeline() as p:\n'
   463            '    p.apply(..)\n'
   464            'This ensures that the pipeline finishes before this program exits.',
   465            portable_options.environment_type)
   466      return result
   467  
   468  
   469  class PortableMetrics(metric.MetricResults):
   470    def __init__(self, job_metrics_response):
   471      metrics = job_metrics_response.metrics
   472      self.attempted = portable_metrics.from_monitoring_infos(metrics.attempted)
   473      self.committed = portable_metrics.from_monitoring_infos(metrics.committed)
   474  
   475    @staticmethod
   476    def _combine(committed, attempted, filter):
   477      all_keys = set(committed.keys()) | set(attempted.keys())
   478      return [
   479          MetricResult(key, committed.get(key), attempted.get(key))
   480          for key in all_keys if metric.MetricResults.matches(filter, key)
   481      ]
   482  
   483    def query(self, filter=None):
   484      counters, distributions, gauges = [
   485          self._combine(x, y, filter)
   486          for x, y in zip(self.committed, self.attempted)
   487      ]
   488  
   489      return {
   490          self.COUNTERS: counters,
   491          self.DISTRIBUTIONS: distributions,
   492          self.GAUGES: gauges
   493      }
   494  
   495  
   496  class PipelineResult(runner.PipelineResult):
   497    def __init__(
   498        self,
   499        job_service,
   500        job_id,
   501        message_stream,
   502        state_stream,
   503        cleanup_callbacks=()):
   504      super().__init__(beam_job_api_pb2.JobState.UNSPECIFIED)
   505      self._job_service = job_service
   506      self._job_id = job_id
   507      self._messages = []
   508      self._message_stream = message_stream
   509      self._state_stream = state_stream
   510      self._cleanup_callbacks = cleanup_callbacks
   511      self._metrics = None
   512      self._runtime_exception = None
   513  
   514    def cancel(self):
   515      # type: () -> None
   516      try:
   517        self._job_service.Cancel(
   518            beam_job_api_pb2.CancelJobRequest(job_id=self._job_id))
   519      finally:
   520        self._cleanup()
   521  
   522    @property
   523    def state(self):
   524      runner_api_state = self._job_service.GetState(
   525          beam_job_api_pb2.GetJobStateRequest(job_id=self._job_id)).state
   526      self._state = self.runner_api_state_to_pipeline_state(runner_api_state)
   527      return self._state
   528  
   529    @staticmethod
   530    def runner_api_state_to_pipeline_state(runner_api_state):
   531      return getattr(
   532          runner.PipelineState,
   533          beam_job_api_pb2.JobState.Enum.Name(runner_api_state))
   534  
   535    @staticmethod
   536    def pipeline_state_to_runner_api_state(pipeline_state):
   537      if pipeline_state == runner.PipelineState.PENDING:
   538        return beam_job_api_pb2.JobState.STARTING
   539      else:
   540        try:
   541          return beam_job_api_pb2.JobState.Enum.Value(pipeline_state)
   542        except ValueError:
   543          return beam_job_api_pb2.JobState.UNSPECIFIED
   544  
   545    def metrics(self):
   546      if not self._metrics:
   547  
   548        job_metrics_response = self._job_service.GetJobMetrics(
   549            beam_job_api_pb2.GetJobMetricsRequest(job_id=self._job_id))
   550  
   551        self._metrics = PortableMetrics(job_metrics_response)
   552      return self._metrics
   553  
   554    def _last_error_message(self):
   555      # type: () -> str
   556      # Filter only messages with the "message_response" and error messages.
   557      messages = [
   558          m.message_response for m in self._messages
   559          if m.HasField('message_response')
   560      ]
   561      error_messages = [
   562          m for m in messages
   563          if m.importance == beam_job_api_pb2.JobMessage.JOB_MESSAGE_ERROR
   564      ]
   565      if error_messages:
   566        return error_messages[-1].message_text
   567      else:
   568        return 'unknown error'
   569  
   570    def wait_until_finish(self, duration=None):
   571      """
   572      :param duration: The maximum time in milliseconds to wait for the result of
   573      the execution. If None or zero, will wait until the pipeline finishes.
   574      :return: The result of the pipeline, i.e. PipelineResult.
   575      """
   576      def read_messages():
   577        # type: () -> None
   578        previous_state = -1
   579        for message in self._message_stream:
   580          if message.HasField('message_response'):
   581            logging.log(
   582                MESSAGE_LOG_LEVELS[message.message_response.importance],
   583                "%s",
   584                message.message_response.message_text)
   585          else:
   586            current_state = message.state_response.state
   587            if current_state != previous_state:
   588              _LOGGER.info(
   589                  "Job state changed to %s",
   590                  self.runner_api_state_to_pipeline_state(current_state))
   591              previous_state = current_state
   592          self._messages.append(message)
   593  
   594      message_thread = threading.Thread(
   595          target=read_messages, name='wait_until_finish_read')
   596      message_thread.daemon = True
   597      message_thread.start()
   598  
   599      if duration:
   600        state_thread = threading.Thread(
   601            target=functools.partial(self._observe_state, message_thread),
   602            name='wait_until_finish_state_observer')
   603        state_thread.daemon = True
   604        state_thread.start()
   605        start_time = time.time()
   606        duration_secs = duration / 1000
   607        while (time.time() - start_time < duration_secs and
   608               state_thread.is_alive()):
   609          time.sleep(1)
   610      else:
   611        self._observe_state(message_thread)
   612  
   613      if self._runtime_exception:
   614        raise self._runtime_exception
   615  
   616      return self._state
   617  
   618    def _observe_state(self, message_thread):
   619      try:
   620        for state_response in self._state_stream:
   621          self._state = self.runner_api_state_to_pipeline_state(
   622              state_response.state)
   623          if state_response.state in TERMINAL_STATES:
   624            # Wait for any last messages.
   625            message_thread.join(10)
   626            break
   627        if self._state != runner.PipelineState.DONE:
   628          self._runtime_exception = RuntimeError(
   629              'Pipeline %s failed in state %s: %s' %
   630              (self._job_id, self._state, self._last_error_message()))
   631      except Exception as e:
   632        self._runtime_exception = e
   633      finally:
   634        self._cleanup()
   635  
   636    def _cleanup(self, on_exit=False):
   637      # type: (bool) -> None
   638      if on_exit and self._cleanup_callbacks:
   639        _LOGGER.info(
   640            'Running cleanup on exit. If your pipeline should continue running, '
   641            'be sure to use the following syntax:\n'
   642            '  with Pipeline() as p:\n'
   643            '    p.apply(..)\n'
   644            'This ensures that the pipeline finishes before this program exits.')
   645      callback_exceptions = []
   646      for callback in self._cleanup_callbacks:
   647        try:
   648          callback()
   649        except Exception as e:
   650          callback_exceptions.append(e)
   651  
   652      self._cleanup_callbacks = ()
   653      if callback_exceptions:
   654        formatted_exceptions = ''.join(
   655            [f"\n\t{repr(e)}" for e in callback_exceptions])
   656        raise RuntimeError('Errors: {}'.format(formatted_exceptions))