| # | |
| # Licensed to the Apache Software Foundation (ASF) under one or more | |
| # contributor license agreements. See the NOTICE file distributed with | |
| # this work for additional information regarding copyright ownership. | |
| # The ASF licenses this file to You under the Apache License, Version 2.0 | |
| # (the "License"); you may not use this file except in compliance with | |
| # the License. You may obtain a copy of the License at | |
| # | |
| # http://www.apache.org/licenses/LICENSE-2.0 | |
| # | |
| # Unless required by applicable law or agreed to in writing, software | |
| # distributed under the License is distributed on an "AS IS" BASIS, | |
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |
| # See the License for the specific language governing permissions and | |
| # limitations under the License. | |
| # | |
| """Module to instrument interactivity to the given pipeline. | |
| For internal use only; no backwards-compatibility guarantees. | |
| This module accesses current interactive environment and analyzes given pipeline | |
| to transform original pipeline into a one-shot pipeline with interactivity. | |
| """ | |
| # pytype: skip-file | |
| import logging | |
| import apache_beam as beam | |
| from apache_beam.pipeline import PipelineVisitor | |
| from apache_beam.portability.api import beam_runner_api_pb2 | |
| from apache_beam.runners.interactive import background_caching_job | |
| from apache_beam.runners.interactive import interactive_environment as ie | |
| from apache_beam.runners.interactive import pipeline_fragment as pf | |
| from apache_beam.runners.interactive import utils | |
| from apache_beam.runners.interactive.caching.cacheable import Cacheable | |
| from apache_beam.runners.interactive.caching.cacheable import CacheKey | |
| from apache_beam.runners.interactive.caching.reify import WRITE_CACHE | |
| from apache_beam.runners.interactive.caching.reify import reify_to_cache | |
| from apache_beam.runners.interactive.caching.reify import unreify_from_cache | |
| from apache_beam.testing import test_stream | |
| _LOGGER = logging.getLogger(__name__) | |
| class PipelineInstrument(object): | |
| """A pipeline instrument for pipeline to be executed by interactive runner. | |
| This module should never depend on underlying runner that interactive runner | |
| delegates. It instruments the original instance of pipeline directly by | |
| appending or replacing transforms with help of cache. It provides | |
| interfaces to recover states of original pipeline. It's the interactive | |
| runner's responsibility to coordinate supported underlying runners to run | |
| the pipeline instrumented and recover the original pipeline states if needed. | |
| """ | |
| def __init__(self, pipeline, options=None): | |
| self._pipeline = pipeline | |
| self._user_pipeline = ie.current_env().user_pipeline(pipeline) | |
| if not self._user_pipeline: | |
| self._user_pipeline = pipeline | |
| self._cache_manager = ie.current_env().get_cache_manager( | |
| self._user_pipeline, create_if_absent=True) | |
| # Check if the user defined pipeline contains any source to cache. | |
| # If so, during the check, the cache manager is converted into a | |
| # streaming cache manager, thus re-assign. | |
| if background_caching_job.has_source_to_cache(self._user_pipeline): | |
| self._cache_manager = ie.current_env().get_cache_manager( | |
| self._user_pipeline) | |
| self._background_caching_pipeline = beam.pipeline.Pipeline.from_runner_api( | |
| pipeline.to_runner_api(), pipeline.runner, options) | |
| ie.current_env().add_derived_pipeline( | |
| self._pipeline, self._background_caching_pipeline) | |
| # Snapshot of original pipeline information. | |
| (self._original_pipeline_proto, | |
| context) = self._pipeline.to_runner_api(return_context=True) | |
| # All compute-once-against-original-pipeline fields. | |
| self._unbounded_sources = utils.unbounded_sources( | |
| self._background_caching_pipeline) | |
| self._pcoll_to_pcoll_id = pcoll_to_pcoll_id(self._pipeline, context) | |
| # A Dict[str, Cacheable] from a PCollection id to a Cacheable that belongs | |
| # to the analyzed pipeline. | |
| self._cacheables = self.find_cacheables() | |
| # A dict from cache key to PCollection that is read from cache. | |
| # If exists, caller should reuse the PCollection read. If not, caller | |
| # should create new transform and track the PCollection read from cache. | |
| # (Dict[str, AppliedPTransform]). | |
| self._cached_pcoll_read = {} | |
| # A dict from PCollections in the runner pipeline instance to their | |
| # corresponding PCollections in the user pipeline instance. Populated | |
| # after preprocess(). | |
| self._runner_pcoll_to_user_pcoll = {} | |
| self._pruned_pipeline_proto = None | |
| # Refers target pcolls output by instrumented write cache transforms, used | |
| # by pruning logic as supplemental targets to build pipeline fragment up | |
| # from. | |
| self._extended_targets = set() | |
| # Refers pcolls used as inputs but got replaced by outputs of read cache | |
| # transforms instrumented, used by pruning logic as targets no longer need | |
| # to be produced during pipeline runs. | |
| self._ignored_targets = set() | |
| # Set of PCollections that are written to cache. | |
| self.cached_pcolls = set() | |
| def instrumented_pipeline_proto(self): | |
| """Always returns a new instance of portable instrumented proto.""" | |
| targets = set(self._runner_pcoll_to_user_pcoll.keys()) | |
| targets.update(self._extended_targets) | |
| targets = targets.difference(self._ignored_targets) | |
| if len(targets) > 0: | |
| # Prunes upstream transforms that don't contribute to the targets the | |
| # instrumented pipeline run cares. | |
| return pf.PipelineFragment( | |
| list(targets)).deduce_fragment().to_runner_api() | |
| return self._pipeline.to_runner_api() | |
| def _required_components( | |
| self, | |
| pipeline_proto, | |
| required_transforms_ids, | |
| visited, | |
| follow_outputs=False, | |
| follow_inputs=False): | |
| """Returns the components and subcomponents of the given transforms. | |
| This method returns required components such as transforms and PCollections | |
| related to the given transforms and to all of their subtransforms. This | |
| method accomplishes this recursively. | |
| """ | |
| if not required_transforms_ids: | |
| return ({}, {}) | |
| transforms = pipeline_proto.components.transforms | |
| pcollections = pipeline_proto.components.pcollections | |
| # Cache the transforms that will be copied into the new pipeline proto. | |
| required_transforms = {k: transforms[k] for k in required_transforms_ids} | |
| # Cache all the output PCollections of the transforms. | |
| pcollection_ids = [ | |
| pc for t in required_transforms.values() for pc in t.outputs.values() | |
| ] | |
| required_pcollections = { | |
| pc_id: pcollections[pc_id] | |
| for pc_id in pcollection_ids | |
| } | |
| subtransforms = {} | |
| subpcollections = {} | |
| # Recursively go through all the subtransforms and add their components. | |
| for transform_id, transform in required_transforms.items(): | |
| if transform_id in pipeline_proto.root_transform_ids: | |
| continue | |
| (t, pc) = self._required_components( | |
| pipeline_proto, | |
| transform.subtransforms, | |
| visited, | |
| follow_outputs=False, | |
| follow_inputs=False) | |
| subtransforms.update(t) | |
| subpcollections.update(pc) | |
| if follow_outputs: | |
| outputs = [ | |
| pc_id for t in required_transforms.values() | |
| for pc_id in t.outputs.values() | |
| ] | |
| visited_copy = visited.copy() | |
| consuming_transforms = { | |
| t_id: t | |
| for t_id, t in transforms.items() | |
| if set(outputs).intersection(set(t.inputs.values())) | |
| } | |
| consuming_transforms = set(consuming_transforms.keys()) | |
| visited.update(consuming_transforms) | |
| consuming_transforms = consuming_transforms - visited_copy | |
| (t, pc) = self._required_components( | |
| pipeline_proto, | |
| list(consuming_transforms), | |
| visited, | |
| follow_outputs, | |
| follow_inputs) | |
| subtransforms.update(t) | |
| subpcollections.update(pc) | |
| if follow_inputs: | |
| inputs = [ | |
| pc_id for t in required_transforms.values() | |
| for pc_id in t.inputs.values() | |
| ] | |
| producing_transforms = { | |
| t_id: t | |
| for t_id, t in transforms.items() | |
| if set(inputs).intersection(set(t.outputs.values())) | |
| } | |
| (t, pc) = self._required_components( | |
| pipeline_proto, | |
| list(producing_transforms.keys()), | |
| visited, | |
| follow_outputs, | |
| follow_inputs) | |
| subtransforms.update(t) | |
| subpcollections.update(pc) | |
| # Now we got all the components and their subcomponents, so return the | |
| # complete collection. | |
| required_transforms.update(subtransforms) | |
| required_pcollections.update(subpcollections) | |
| return (required_transforms, required_pcollections) | |
| def prune_subgraph_for(self, pipeline, required_transform_ids): | |
| # Create the pipeline_proto to read all the components from. It will later | |
| # create a new pipeline proto from the cut out components. | |
| pipeline_proto, context = pipeline.to_runner_api(return_context=True) | |
| # Get all the root transforms. The caching transforms will be subtransforms | |
| # of one of these roots. | |
| roots = [root for root in pipeline_proto.root_transform_ids] | |
| (t, p) = self._required_components( | |
| pipeline_proto, | |
| roots + required_transform_ids, | |
| set(), | |
| follow_outputs=True, | |
| follow_inputs=True) | |
| def set_proto_map(proto_map, new_value): | |
| proto_map.clear() | |
| for key, value in new_value.items(): | |
| proto_map[key].CopyFrom(value) | |
| # Copy the transforms into the new pipeline. | |
| pipeline_to_execute = beam_runner_api_pb2.Pipeline() | |
| pipeline_to_execute.root_transform_ids[:] = roots | |
| set_proto_map(pipeline_to_execute.components.transforms, t) | |
| set_proto_map(pipeline_to_execute.components.pcollections, p) | |
| set_proto_map( | |
| pipeline_to_execute.components.coders, context.to_runner_api().coders) | |
| set_proto_map( | |
| pipeline_to_execute.components.windowing_strategies, | |
| context.to_runner_api().windowing_strategies) | |
| # Cut out all subtransforms in the root that aren't the required transforms. | |
| for root_id in roots: | |
| root = pipeline_to_execute.components.transforms[root_id] | |
| root.subtransforms[:] = [ | |
| transform_id for transform_id in root.subtransforms | |
| if transform_id in pipeline_to_execute.components.transforms | |
| ] | |
| return pipeline_to_execute | |
| def background_caching_pipeline_proto(self): | |
| """Returns the background caching pipeline. | |
| This method creates a background caching pipeline by: adding writes to cache | |
| from each unbounded source (done in the instrument method), and cutting out | |
| all components (transform, PCollections, coders, windowing strategies) that | |
| are not the unbounded sources or writes to cache (or subtransforms thereof). | |
| """ | |
| # Create the pipeline_proto to read all the components from. It will later | |
| # create a new pipeline proto from the cut out components. | |
| pipeline_proto, context = self._background_caching_pipeline.to_runner_api( | |
| return_context=True) | |
| # Get all the sources we want to cache. | |
| sources = utils.unbounded_sources(self._background_caching_pipeline) | |
| # Get all the root transforms. The caching transforms will be subtransforms | |
| # of one of these roots. | |
| roots = [root for root in pipeline_proto.root_transform_ids] | |
| # Get the transform IDs of the caching transforms. These caching operations | |
| # are added to the _background_caching_pipeline in the instrument() method. | |
| # It's added there so that multiple calls to this method won't add multiple | |
| # caching operations (idempotent). | |
| transforms = pipeline_proto.components.transforms | |
| caching_transform_ids = [ | |
| t_id for root in roots for t_id in transforms[root].subtransforms | |
| if WRITE_CACHE in t_id | |
| ] | |
| # Get the IDs of the unbounded sources. | |
| required_transform_labels = [src.full_label for src in sources] | |
| unbounded_source_ids = [ | |
| k for k, v in transforms.items() | |
| if v.unique_name in required_transform_labels | |
| ] | |
| # The required transforms are the transforms that we want to cut out of | |
| # the pipeline_proto and insert into a new pipeline to return. | |
| required_transform_ids = ( | |
| roots + caching_transform_ids + unbounded_source_ids) | |
| (t, p) = self._required_components( | |
| pipeline_proto, required_transform_ids, set()) | |
| def set_proto_map(proto_map, new_value): | |
| proto_map.clear() | |
| for key, value in new_value.items(): | |
| proto_map[key].CopyFrom(value) | |
| # Copy the transforms into the new pipeline. | |
| pipeline_to_execute = beam_runner_api_pb2.Pipeline() | |
| pipeline_to_execute.root_transform_ids[:] = roots | |
| set_proto_map(pipeline_to_execute.components.transforms, t) | |
| set_proto_map(pipeline_to_execute.components.pcollections, p) | |
| set_proto_map( | |
| pipeline_to_execute.components.coders, context.to_runner_api().coders) | |
| set_proto_map( | |
| pipeline_to_execute.components.windowing_strategies, | |
| context.to_runner_api().windowing_strategies) | |
| # Cut out all subtransforms in the root that aren't the required transforms. | |
| for root_id in roots: | |
| root = pipeline_to_execute.components.transforms[root_id] | |
| root.subtransforms[:] = [ | |
| transform_id for transform_id in root.subtransforms | |
| if transform_id in pipeline_to_execute.components.transforms | |
| ] | |
| return pipeline_to_execute | |
| @property | |
| def cacheables(self) -> dict[str, Cacheable]: | |
| """Returns the Cacheables by PCollection ids. | |
| If you're already working with user defined pipelines and PCollections, | |
| do not build a PipelineInstrument just to get the cacheables. Instead, | |
| use apache_beam.runners.interactive.utils.cacheables. | |
| """ | |
| return self._cacheables | |
| @property | |
| def has_unbounded_sources(self): | |
| """Returns whether the pipeline has any recordable sources. | |
| """ | |
| return len(self._unbounded_sources) > 0 | |
| @property | |
| def original_pipeline_proto(self): | |
| """Returns a snapshot of the pipeline proto before instrumentation.""" | |
| return self._original_pipeline_proto | |
| @property | |
| def user_pipeline(self): | |
| """Returns a reference to the pipeline instance defined by the user. If a | |
| pipeline has no cacheable PCollection and the user pipeline cannot be | |
| found, return None indicating there is nothing to be cached in the user | |
| pipeline. | |
| The pipeline given for instrumenting and mutated in this class is not | |
| necessarily the pipeline instance defined by the user. From the watched | |
| scopes, this class figures out what the user pipeline instance is. | |
| This metadata can be used for tracking pipeline results. | |
| """ | |
| return self._user_pipeline | |
| @property | |
| def runner_pcoll_to_user_pcoll(self): | |
| """Returns cacheable PCollections correlated from instances in the runner | |
| pipeline to instances in the user pipeline.""" | |
| return self._runner_pcoll_to_user_pcoll | |
| def find_cacheables(self) -> dict[str, Cacheable]: | |
| """Finds PCollections that need to be cached for analyzed pipeline. | |
| There might be multiple pipelines defined and watched, this will only find | |
| cacheables belong to the analyzed pipeline. | |
| """ | |
| result = {} | |
| cacheables = utils.cacheables() | |
| for _, cacheable in cacheables.items(): | |
| if cacheable.pcoll.pipeline is not self._user_pipeline: | |
| # Ignore all cacheables from other pipelines. | |
| continue | |
| pcoll_id = self.pcoll_id(cacheable.pcoll) | |
| if not pcoll_id: | |
| _LOGGER.debug( | |
| 'Unable to retrieve PCollection id for %s. Ignored.', | |
| cacheable.pcoll) | |
| continue | |
| result[self.pcoll_id(cacheable.pcoll)] = cacheable | |
| return result | |
| def instrument(self): | |
| """Instruments original pipeline with cache. | |
| For cacheable output PCollection, if cache for the key doesn't exist, do | |
| _write_cache(); for cacheable input PCollection, if cache for the key | |
| exists, do _read_cache(). No instrument in any other situation. | |
| Modifies: | |
| self._pipeline | |
| """ | |
| cacheable_inputs = set() | |
| all_inputs = set() | |
| all_outputs = set() | |
| unbounded_source_pcolls = set() | |
| class InstrumentVisitor(PipelineVisitor): | |
| """Visitor utilizes cache to instrument the pipeline.""" | |
| def __init__(self, pin): | |
| self._pin = pin | |
| def enter_composite_transform(self, transform_node): | |
| self.visit_transform(transform_node) | |
| def visit_transform(self, transform_node): | |
| if isinstance(transform_node.transform, | |
| tuple(ie.current_env().options.recordable_sources)): | |
| unbounded_source_pcolls.update(transform_node.outputs.values()) | |
| cacheable_inputs.update(self._pin._cacheable_inputs(transform_node)) | |
| ins, outs = self._pin._all_inputs_outputs(transform_node) | |
| all_inputs.update(ins) | |
| all_outputs.update(outs) | |
| v = InstrumentVisitor(self) | |
| self._pipeline.visit(v) | |
| # Every output PCollection that is never used as an input PCollection is | |
| # considered as a side effect of the pipeline run and should be included. | |
| self._extended_targets.update(all_outputs.difference(all_inputs)) | |
| # Add the unbounded source PCollections to the cacheable inputs. This allows | |
| # for the caching of unbounded sources without a variable reference. | |
| cacheable_inputs.update(unbounded_source_pcolls) | |
| # Create ReadCache transforms. | |
| for cacheable_input in cacheable_inputs: | |
| self._read_cache( | |
| self._pipeline, | |
| cacheable_input, | |
| cacheable_input in unbounded_source_pcolls) | |
| # Replace/wire inputs w/ cached PCollections from ReadCache transforms. | |
| self._replace_with_cached_inputs(self._pipeline) | |
| # Write cache for all cacheables. | |
| for _, cacheable in self._cacheables.items(): | |
| self._write_cache( | |
| self._pipeline, cacheable.pcoll, ignore_unbounded_reads=True) | |
| # Instrument the background caching pipeline if we can. | |
| if self.has_unbounded_sources: | |
| for source in self._unbounded_sources: | |
| self._write_cache( | |
| self._background_caching_pipeline, | |
| source.outputs[None], | |
| output_as_extended_target=False, | |
| is_capture=True) | |
| class TestStreamVisitor(PipelineVisitor): | |
| def __init__(self): | |
| self.test_stream = None | |
| def enter_composite_transform(self, transform_node): | |
| self.visit_transform(transform_node) | |
| def visit_transform(self, transform_node): | |
| if (self.test_stream is None and | |
| isinstance(transform_node.transform, test_stream.TestStream)): | |
| self.test_stream = transform_node.full_label | |
| v = TestStreamVisitor() | |
| self._pipeline.visit(v) | |
| pipeline_proto = self._pipeline.to_runner_api(return_context=False) | |
| test_stream_id = '' | |
| for t_id, t in pipeline_proto.components.transforms.items(): | |
| if t.unique_name == v.test_stream: | |
| test_stream_id = t_id | |
| break | |
| self._pruned_pipeline_proto = self.prune_subgraph_for( | |
| self._pipeline, [test_stream_id]) | |
| pruned_pipeline = beam.Pipeline.from_runner_api( | |
| proto=self._pruned_pipeline_proto, | |
| runner=self._pipeline.runner, | |
| options=self._pipeline._options) | |
| ie.current_env().add_derived_pipeline(self._pipeline, pruned_pipeline) | |
| self._pipeline = pruned_pipeline | |
| def preprocess(self): | |
| """Pre-processes the pipeline. | |
| Since the pipeline instance in the class might not be the same instance | |
| defined in the user code, the pre-process will figure out the relationship | |
| of cacheable PCollections between these 2 instances by replacing 'pcoll' | |
| fields in the cacheable dictionary with ones from the running instance. | |
| """ | |
| class PreprocessVisitor(PipelineVisitor): | |
| def __init__(self, pin): | |
| self._pin = pin | |
| def enter_composite_transform(self, transform_node): | |
| self.visit_transform(transform_node) | |
| def visit_transform(self, transform_node): | |
| for in_pcoll in transform_node.inputs: | |
| self._process(in_pcoll) | |
| for out_pcoll in transform_node.outputs.values(): | |
| self._process(out_pcoll) | |
| def _process(self, pcoll): | |
| pcoll_id = self._pin._pcoll_to_pcoll_id.get(str(pcoll), '') | |
| if pcoll_id in self._pin._cacheables: | |
| pcoll_id = self._pin.pcoll_id(pcoll) | |
| user_pcoll = self._pin._cacheables[pcoll_id].pcoll | |
| if (pcoll_id in self._pin._cacheables and user_pcoll != pcoll): | |
| self._pin._runner_pcoll_to_user_pcoll[pcoll] = user_pcoll | |
| self._pin._cacheables[pcoll_id].pcoll = pcoll | |
| v = PreprocessVisitor(self) | |
| self._pipeline.visit(v) | |
| def _write_cache( | |
| self, | |
| pipeline, | |
| pcoll, | |
| output_as_extended_target=True, | |
| ignore_unbounded_reads=False, | |
| is_capture=False): | |
| """Caches a cacheable PCollection. | |
| For the given PCollection, by appending sub transform part that materialize | |
| the PCollection through sink into cache implementation. The cache write is | |
| not immediate. It happens when the runner runs the transformed pipeline | |
| and thus not usable for this run as intended. This function always writes | |
| the cache for the given PCollection as long as the PCollection belongs to | |
| the pipeline being instrumented and the keyed cache is absent. | |
| Modifies: | |
| pipeline | |
| """ | |
| # Makes sure the pcoll belongs to the pipeline being instrumented. | |
| if pcoll.pipeline is not pipeline: | |
| return | |
| # Ignore the unbounded reads from recordable sources as these will be pruned | |
| # out using the PipelineFragment later on. | |
| if ignore_unbounded_reads: | |
| ignore = False | |
| producer = pcoll.producer | |
| while producer: | |
| if isinstance(producer.transform, | |
| tuple(ie.current_env().options.recordable_sources)): | |
| ignore = True | |
| break | |
| producer = producer.parent | |
| if ignore: | |
| self._ignored_targets.add(pcoll) | |
| return | |
| # The keyed cache is always valid within this instrumentation. | |
| key = self.cache_key(pcoll) | |
| # Only need to write when the cache with expected key doesn't exist. | |
| if not self._cache_manager.exists('full', key): | |
| self.cached_pcolls.add(self.runner_pcoll_to_user_pcoll.get(pcoll, pcoll)) | |
| # Read the windowing information and cache it along with the element. This | |
| # caches the arguments to a WindowedValue object because Python has logic | |
| # that detects if a DoFn returns a WindowedValue. When it detecs one, it | |
| # puts the element into the correct window then emits the value to | |
| # downstream transforms. | |
| extended_target = reify_to_cache( | |
| pcoll=pcoll, | |
| cache_key=key, | |
| cache_manager=self._cache_manager, | |
| is_capture=is_capture) | |
| if output_as_extended_target: | |
| self._extended_targets.add(extended_target) | |
| def _read_cache(self, pipeline, pcoll, is_unbounded_source_output): | |
| """Reads a cached pvalue. | |
| A noop will cause the pipeline to execute the transform as | |
| it is and cache nothing from this transform for next run. | |
| Modifies: | |
| pipeline | |
| """ | |
| # Makes sure the pcoll belongs to the pipeline being instrumented. | |
| if pcoll.pipeline is not pipeline: | |
| return | |
| # The keyed cache is always valid within this instrumentation. | |
| key = self.cache_key(pcoll) | |
| # Can only read from cache when the cache with expected key exists and its | |
| # computation has been completed. | |
| is_cached = self._cache_manager.exists('full', key) | |
| is_computed = ( | |
| pcoll in self._runner_pcoll_to_user_pcoll and | |
| self._runner_pcoll_to_user_pcoll[pcoll] | |
| in ie.current_env().computed_pcollections) | |
| if ((is_cached and is_computed) or is_unbounded_source_output): | |
| if key not in self._cached_pcoll_read: | |
| # Mutates the pipeline with cache read transform attached | |
| # to root of the pipeline. | |
| # To put the cached value into the correct window, simply return a | |
| # WindowedValue constructed from the element. | |
| pcoll_from_cache = unreify_from_cache( | |
| pipeline=pipeline, cache_key=key, cache_manager=self._cache_manager) | |
| self._cached_pcoll_read[key] = pcoll_from_cache | |
| # else: NOOP when cache doesn't exist, just compute the original graph. | |
| def _replace_with_cached_inputs(self, pipeline): | |
| """Replace PCollection inputs in the pipeline with cache if possible. | |
| For any input PCollection, find out whether there is valid cache. If so, | |
| replace the input of the AppliedPTransform with output of the | |
| AppliedPtransform that sources pvalue from the cache. If there is no valid | |
| cache, noop. | |
| """ | |
| # Find all cached unbounded PCollections. | |
| # If the pipeline has unbounded sources, then we want to force all cache | |
| # reads to go through the TestStream (even if they are bounded sources). | |
| if self.has_unbounded_sources: | |
| class CacheableUnboundedPCollectionVisitor(PipelineVisitor): | |
| def __init__(self, pin): | |
| self._pin = pin | |
| self.unbounded_pcolls = set() | |
| def enter_composite_transform(self, transform_node): | |
| self.visit_transform(transform_node) | |
| def visit_transform(self, transform_node): | |
| if transform_node.outputs: | |
| for output_pcoll in transform_node.outputs.values(): | |
| key = self._pin.cache_key(output_pcoll) | |
| if key in self._pin._cached_pcoll_read: | |
| self.unbounded_pcolls.add(key) | |
| if transform_node.inputs: | |
| for input_pcoll in transform_node.inputs: | |
| key = self._pin.cache_key(input_pcoll) | |
| if key in self._pin._cached_pcoll_read: | |
| self.unbounded_pcolls.add(key) | |
| v = CacheableUnboundedPCollectionVisitor(self) | |
| pipeline.visit(v) | |
| # The set of keys from the cached unbounded PCollections will be used as | |
| # the output tags for the TestStream. This is to remember what cache-key | |
| # is associated with which PCollection. | |
| output_tags = v.unbounded_pcolls | |
| # Take the PCollections that will be read from the TestStream and insert | |
| # them back into the dictionary of cached PCollections. The next step will | |
| # replace the downstream consumer of the non-cached PCollections with | |
| # these PCollections. | |
| if output_tags: | |
| output_pcolls = pipeline | test_stream.TestStream( | |
| output_tags=output_tags, coder=self._cache_manager._default_pcoder) | |
| for tag, pcoll in output_pcolls.items(): | |
| self._cached_pcoll_read[tag] = pcoll | |
| class ReadCacheWireVisitor(PipelineVisitor): | |
| """Visitor wires cache read as inputs to replace corresponding original | |
| input PCollections in pipeline. | |
| """ | |
| def __init__(self, pin): | |
| """Initializes with a PipelineInstrument.""" | |
| self._pin = pin | |
| def enter_composite_transform(self, transform_node): | |
| self.visit_transform(transform_node) | |
| def visit_transform(self, transform_node): | |
| if transform_node.inputs: | |
| main_inputs = dict(transform_node.main_inputs) | |
| for tag, input_pcoll in main_inputs.items(): | |
| key = self._pin.cache_key(input_pcoll) | |
| # Replace the input pcollection with the cached pcollection (if it | |
| # has been cached). | |
| if key in self._pin._cached_pcoll_read: | |
| # Ignore this pcoll in the final pruned instrumented pipeline. | |
| self._pin._ignored_targets.add(input_pcoll) | |
| main_inputs[tag] = self._pin._cached_pcoll_read[key] | |
| # Update the transform with its new inputs. | |
| transform_node.main_inputs = main_inputs | |
| v = ReadCacheWireVisitor(self) | |
| pipeline.visit(v) | |
| def _cacheable_inputs(self, transform): | |
| inputs = set() | |
| for in_pcoll in transform.inputs: | |
| if self.pcoll_id(in_pcoll) in self._cacheables: | |
| inputs.add(in_pcoll) | |
| return inputs | |
| def _all_inputs_outputs(self, transform): | |
| inputs = set() | |
| outputs = set() | |
| for in_pcoll in transform.inputs: | |
| inputs.add(in_pcoll) | |
| for _, out_pcoll in transform.outputs.items(): | |
| outputs.add(out_pcoll) | |
| return inputs, outputs | |
| def pcoll_id(self, pcoll): | |
| """Gets the PCollection id of the given pcoll. | |
| Returns '' if not found. | |
| """ | |
| return self._pcoll_to_pcoll_id.get(str(pcoll), '') | |
| def cache_key(self, pcoll): | |
| """Gets the identifier of a cacheable PCollection in cache. | |
| If the pcoll is not a cacheable, return ''. | |
| This is only needed in pipeline instrument when the origin of given pcoll | |
| is unknown (whether it's from the user pipeline or a runner pipeline). If | |
| a pcoll is from the user pipeline, always use CacheKey.from_pcoll to build | |
| the key. | |
| The key is what the pcoll would use as identifier if it's materialized in | |
| cache. It doesn't mean that there would definitely be such cache already. | |
| Also, the pcoll can come from the original user defined pipeline object or | |
| an equivalent pcoll from a transformed copy of the original pipeline. | |
| """ | |
| cacheable = self._cacheables.get(self.pcoll_id(pcoll), None) | |
| if cacheable: | |
| if cacheable.pcoll in self.runner_pcoll_to_user_pcoll: | |
| user_pcoll = self.runner_pcoll_to_user_pcoll[cacheable.pcoll] | |
| else: | |
| user_pcoll = cacheable.pcoll | |
| return CacheKey.from_pcoll(cacheable.var, user_pcoll).to_str() | |
| return '' | |
| def build_pipeline_instrument(pipeline, options=None): | |
| """Creates PipelineInstrument for a pipeline and its options with cache. | |
| Throughout the process, the returned PipelineInstrument snapshots the given | |
| pipeline and then mutates the pipeline. It's invoked by interactive components | |
| such as the InteractiveRunner and the given pipeline should be implicitly | |
| created runner pipelines instead of pipeline instances defined by the user. | |
| This is the shorthand for doing 3 steps: 1) compute once for metadata of the | |
| given runner pipeline and everything watched from user pipelines; 2) associate | |
| info between the runner pipeline and its corresponding user pipeline, | |
| eliminate data from other user pipelines if there are any; 3) mutate the | |
| runner pipeline to apply interactivity. | |
| """ | |
| pi = PipelineInstrument(pipeline, options) | |
| pi.preprocess() | |
| pi.instrument() # Instruments the pipeline only once. | |
| return pi | |
| def pcoll_to_pcoll_id(pipeline, original_context): | |
| """Returns a dict mapping PCollections string to PCollection IDs. | |
| Using a PipelineVisitor to iterate over every node in the pipeline, | |
| records the mapping from PCollections to PCollections IDs. This mapping | |
| will be used to query cached PCollections. | |
| Returns: | |
| (dict from str to str) a dict mapping str(pcoll) to pcoll_id. | |
| """ | |
| class PCollVisitor(PipelineVisitor): | |
| """"A visitor that records input and output values to be replaced. | |
| Input and output values that should be updated are recorded in maps | |
| input_replacements and output_replacements respectively. | |
| We cannot update input and output values while visiting since that | |
| results in validation errors. | |
| """ | |
| def __init__(self): | |
| self.pcoll_to_pcoll_id = {} | |
| def enter_composite_transform(self, transform_node): | |
| self.visit_transform(transform_node) | |
| def visit_transform(self, transform_node): | |
| for pcoll in transform_node.outputs.values(): | |
| self.pcoll_to_pcoll_id[str(pcoll)] = ( | |
| original_context.pcollections.get_id(pcoll)) | |
| v = PCollVisitor() | |
| pipeline.visit(v) | |
| return v.pcoll_to_pcoll_id |