github.com/apache/beam/sdks/v2@v2.48.2/python/apache_beam/io/source_test_utils.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  """Helper functions and test harnesses for source implementations.
    19  
    20  This module contains helper functions and test harnesses for checking
    21  correctness of source (a subclass of ``iobase.BoundedSource``) and range
    22  tracker (a subclass of``iobase.RangeTracker``) implementations.
    23  
    24  Contains a few lightweight utilities (e.g. reading items from a source such as
    25  ``readFromSource()``, as well as heavyweight property testing and stress
    26  testing harnesses that help getting a large amount of test coverage with few
    27  code.
    28  
    29  Most notable ones are:
    30  * ``assertSourcesEqualReferenceSource()`` helps testing that the data read by
    31  the union of sources produced by ``BoundedSource.split()`` is the same as data
    32  read by the original source.
    33  * If your source implements dynamic work rebalancing, use the
    34  ``assertSplitAtFraction()`` family of functions - they test behavior of
    35  ``RangeTracker.try_split()``, in particular, that various consistency
    36  properties are respected and the total set of data read by the source is
    37  preserved when splits happen. Use ``assertSplitAtFractionBehavior()`` to test
    38  individual cases of ``RangeTracker.try_split()`` and use
    39  ``assertSplitAtFractionExhaustive()`` as a heavy-weight stress test including
    40  concurrency. We strongly recommend to use both.
    41  
    42  For example usages, see the unit tests of modules such as
    43   * apache_beam.io.source_test_utils_test.py
    44   * apache_beam.io.avroio_test.py
    45  """
    46  # pytype: skip-file
    47  
    48  import logging
    49  import threading
    50  import weakref
    51  from collections import namedtuple
    52  from multiprocessing.pool import ThreadPool
    53  
    54  from apache_beam.io import iobase
    55  from apache_beam.testing.util import equal_to
    56  
    57  __all__ = [
    58      'read_from_source',
    59      'assert_sources_equal_reference_source',
    60      'assert_reentrant_reads_succeed',
    61      'assert_split_at_fraction_behavior',
    62      'assert_split_at_fraction_binary',
    63      'assert_split_at_fraction_exhaustive',
    64      'assert_split_at_fraction_fails',
    65      'assert_split_at_fraction_succeeds_and_consistent'
    66  ]
    67  
    68  _LOGGER = logging.getLogger(__name__)
    69  
    70  
    71  class ExpectedSplitOutcome(object):
    72    MUST_SUCCEED_AND_BE_CONSISTENT = 1
    73    MUST_FAIL = 2
    74    MUST_BE_CONSISTENT_IF_SUCCEEDS = 3
    75  
    76  
    77  SplitAtFractionResult = namedtuple(
    78      'SplitAtFractionResult', 'num_primary_items num_residual_items')
    79  
    80  SplitFractionStatistics = namedtuple(
    81      'SplitFractionStatistics', 'successful_fractions non_trivial_fractions')
    82  
    83  
    84  def read_from_source(source, start_position=None, stop_position=None):
    85    """Reads elements from the given ```BoundedSource```.
    86  
    87    Only reads elements within the given position range.
    88    Args:
    89      source (~apache_beam.io.iobase.BoundedSource):
    90        :class:`~apache_beam.io.iobase.BoundedSource` implementation.
    91      start_position (int): start position for reading.
    92      stop_position (int): stop position for reading.
    93  
    94    Returns:
    95      List[str]: the set of values read from the sources.
    96    """
    97    values = []
    98    range_tracker = source.get_range_tracker(start_position, stop_position)
    99    assert isinstance(range_tracker, iobase.RangeTracker)
   100    reader = source.read(range_tracker)
   101    for value in reader:
   102      values.append(value)
   103  
   104    return values
   105  
   106  
   107  def _ThreadPool(threads):
   108    # ThreadPool crashes in old versions of Python (< 2.7.5) if created from a
   109    # child thread. (http://bugs.python.org/issue10015)
   110    if not hasattr(threading.current_thread(), '_children'):
   111      threading.current_thread()._children = weakref.WeakKeyDictionary()
   112    return ThreadPool(threads)
   113  
   114  
   115  def assert_sources_equal_reference_source(reference_source_info, sources_info):
   116    """Tests if a reference source is equal to a given set of sources.
   117  
   118    Given a reference source (a :class:`~apache_beam.io.iobase.BoundedSource`
   119    and a position range) and a list of sources, assert that the union of the
   120    records read from the list of sources is equal to the records read from the
   121    reference source.
   122  
   123    Args:
   124      reference_source_info\
   125          (Tuple[~apache_beam.io.iobase.BoundedSource, int, int]):
   126        a three-tuple that gives the reference
   127        :class:`~apache_beam.io.iobase.BoundedSource`, position to start
   128        reading at, and position to stop reading at.
   129      sources_info\
   130          (Iterable[Tuple[~apache_beam.io.iobase.BoundedSource, int, int]]):
   131        a set of sources. Each source is a three-tuple that is of the same
   132        format described above.
   133  
   134    Raises:
   135      ValueError: if the set of data produced by the reference source
   136        and the given set of sources are not equivalent.
   137  
   138    """
   139  
   140    if not (isinstance(reference_source_info, tuple) and
   141            len(reference_source_info) == 3 and
   142            isinstance(reference_source_info[0], iobase.BoundedSource)):
   143      raise ValueError(
   144          'reference_source_info must a three-tuple where first'
   145          'item of the tuple gives a '
   146          'iobase.BoundedSource. Received: %r' % reference_source_info)
   147    reference_records = read_from_source(*reference_source_info)
   148  
   149    source_records = []
   150    for source_info in sources_info:
   151      assert isinstance(source_info, tuple)
   152      assert len(source_info) == 3
   153      if not (isinstance(source_info, tuple) and len(source_info) == 3 and
   154              isinstance(source_info[0], iobase.BoundedSource)):
   155        raise ValueError(
   156            'source_info must a three tuple where first'
   157            'item of the tuple gives a '
   158            'iobase.BoundedSource. Received: %r' % source_info)
   159      if (type(reference_source_info[0].default_output_coder()) != type(
   160          source_info[0].default_output_coder())):
   161        raise ValueError(
   162            'Reference source %r and the source %r must use the same coder. '
   163            'They are using %r and %r respectively instead.' % (
   164                reference_source_info[0],
   165                source_info[0],
   166                type(reference_source_info[0].default_output_coder()),
   167                type(source_info[0].default_output_coder())))
   168      source_records.extend(read_from_source(*source_info))
   169  
   170    if len(reference_records) != len(source_records):
   171      raise ValueError(
   172          'Reference source must produce the same number of records as the '
   173          'list of sources. Number of records were %d and %d instead.' %
   174          (len(reference_records), len(source_records)))
   175  
   176    if equal_to(reference_records)(source_records):
   177      raise ValueError(
   178          'Reference source and provided list of sources must produce the '
   179          'same set of records.')
   180  
   181  
   182  def assert_reentrant_reads_succeed(source_info):
   183    """Tests if a given source can be read in a reentrant manner.
   184  
   185    Assume that given source produces the set of values ``{v1, v2, v3, ... vn}``.
   186    For ``i`` in range ``[1, n-1]`` this method performs a reentrant read after
   187    reading ``i`` elements and verifies that both the original and reentrant read
   188    produce the expected set of values.
   189  
   190    Args:
   191      source_info (Tuple[~apache_beam.io.iobase.BoundedSource, int, int]):
   192        a three-tuple that gives the reference
   193        :class:`~apache_beam.io.iobase.BoundedSource`, position to start reading
   194        at, and a position to stop reading at.
   195  
   196    Raises:
   197      ValueError: if source is too trivial or reentrant read result
   198        in an incorrect read.
   199    """
   200  
   201    source, start_position, stop_position = source_info
   202    assert isinstance(source, iobase.BoundedSource)
   203  
   204    expected_values = [
   205        val for val in source.read(
   206            source.get_range_tracker(start_position, stop_position))
   207    ]
   208    if len(expected_values) < 2:
   209      raise ValueError(
   210          'Source is too trivial since it produces only %d '
   211          'values. Please give a source that reads at least 2 '
   212          'values.' % len(expected_values))
   213  
   214    for i in range(1, len(expected_values) - 1):
   215      read_iter = source.read(
   216          source.get_range_tracker(start_position, stop_position))
   217      original_read = []
   218      for _ in range(i):
   219        original_read.append(next(read_iter))
   220  
   221      # Reentrant read
   222      reentrant_read = [
   223          val for val in source.read(
   224              source.get_range_tracker(start_position, stop_position))
   225      ]
   226  
   227      # Continuing original read.
   228      for val in read_iter:
   229        original_read.append(val)
   230  
   231      if equal_to(original_read)(expected_values):
   232        raise ValueError(
   233            'Source did not produce expected values when '
   234            'performing a reentrant read after reading %d values. '
   235            'Expected %r received %r.' % (i, expected_values, original_read))
   236  
   237      if equal_to(reentrant_read)(expected_values):
   238        raise ValueError(
   239            'A reentrant read of source after reading %d values '
   240            'did not produce expected values. Expected %r '
   241            'received %r.' % (i, expected_values, reentrant_read))
   242  
   243  
   244  def assert_split_at_fraction_behavior(
   245      source, num_items_to_read_before_split, split_fraction, expected_outcome):
   246    """Verifies the behaviour of splitting a source at a given fraction.
   247  
   248    Asserts that splitting a :class:`~apache_beam.io.iobase.BoundedSource` either
   249    fails after reading **num_items_to_read_before_split** items, or succeeds in
   250    a way that is consistent according to
   251    :func:`assert_split_at_fraction_succeeds_and_consistent()`.
   252  
   253    Args:
   254      source (~apache_beam.io.iobase.BoundedSource): the source to perform
   255        dynamic splitting on.
   256      num_items_to_read_before_split (int): number of items to read before
   257        splitting.
   258      split_fraction (float): fraction to split at.
   259      expected_outcome (int): a value from
   260        :class:`~apache_beam.io.source_test_utils.ExpectedSplitOutcome`.
   261  
   262    Returns:
   263      Tuple[int, int]: a tuple that gives the number of items produced by reading
   264      the two ranges produced after dynamic splitting. If splitting did not
   265      occur, the first value of the tuple will represent the full set of records
   266      read by the source while the second value of the tuple will be ``-1``.
   267    """
   268    assert isinstance(source, iobase.BoundedSource)
   269    expected_items = read_from_source(source, None, None)
   270    return _assert_split_at_fraction_behavior(
   271        source,
   272        expected_items,
   273        num_items_to_read_before_split,
   274        split_fraction,
   275        expected_outcome)
   276  
   277  
   278  def _assert_split_at_fraction_behavior(
   279      source,
   280      expected_items,
   281      num_items_to_read_before_split,
   282      split_fraction,
   283      expected_outcome,
   284      start_position=None,
   285      stop_position=None):
   286  
   287    range_tracker = source.get_range_tracker(start_position, stop_position)
   288    assert isinstance(range_tracker, iobase.RangeTracker)
   289    current_items = []
   290    reader = source.read(range_tracker)
   291    # Reading 'num_items_to_read_before_split' items.
   292    reader_iter = iter(reader)
   293    for _ in range(num_items_to_read_before_split):
   294      current_items.append(next(reader_iter))
   295  
   296    suggested_split_position = range_tracker.position_at_fraction(split_fraction)
   297  
   298    stop_position_before_split = range_tracker.stop_position()
   299    split_result = range_tracker.try_split(suggested_split_position)
   300  
   301    if split_result is not None:
   302      if len(split_result) != 2:
   303        raise ValueError(
   304            'Split result must be a tuple that contains split '
   305            'position and split fraction. Received: %r' % (split_result, ))
   306  
   307      if range_tracker.stop_position() != split_result[0]:
   308        raise ValueError(
   309            'After a successful split, the stop position of the '
   310            'RangeTracker must be the same as the returned split '
   311            'position. Observed %r and %r which are different.' %
   312            (range_tracker.stop_position() % (split_result[0], )))
   313  
   314      if split_fraction < 0 or split_fraction > 1:
   315        raise ValueError(
   316            'Split fraction must be within the range [0,1]',
   317            'Observed split fraction was %r.' % (split_result[1], ))
   318  
   319    stop_position_after_split = range_tracker.stop_position()
   320    if split_result and stop_position_after_split == stop_position_before_split:
   321      raise ValueError(
   322          'Stop position %r did not change after a successful '
   323          'split of source %r at fraction %r.' %
   324          (stop_position_before_split, source, split_fraction))
   325  
   326    if expected_outcome == ExpectedSplitOutcome.MUST_SUCCEED_AND_BE_CONSISTENT:
   327      if not split_result:
   328        raise ValueError(
   329            'Expected split of source %r at fraction %r to be '
   330            'successful after reading %d elements. But '
   331            'the split failed.' %
   332            (source, split_fraction, num_items_to_read_before_split))
   333    elif expected_outcome == ExpectedSplitOutcome.MUST_FAIL:
   334      if split_result:
   335        raise ValueError(
   336            'Expected split of source %r at fraction %r after '
   337            'reading %d elements to fail. But splitting '
   338            'succeeded with result %r.' % (
   339                source,
   340                split_fraction,
   341                num_items_to_read_before_split,
   342                split_result))
   343  
   344    elif (
   345        expected_outcome != ExpectedSplitOutcome.MUST_BE_CONSISTENT_IF_SUCCEEDS):
   346      raise ValueError('Unknown type of expected outcome: %r' % expected_outcome)
   347    current_items.extend([value for value in reader_iter])
   348  
   349    residual_range = (
   350        split_result[0], stop_position_before_split) if split_result else None
   351  
   352    return _verify_single_split_fraction_result(
   353        source,
   354        expected_items,
   355        current_items,
   356        split_result,
   357        (range_tracker.start_position(), range_tracker.stop_position()),
   358        residual_range,
   359        split_fraction)
   360  
   361  
   362  def _range_to_str(start, stop):
   363    return '[' + (str(start) + ',' + str(stop) + ')')
   364  
   365  
   366  def _verify_single_split_fraction_result(
   367      source,
   368      expected_items,
   369      current_items,
   370      split_successful,
   371      primary_range,
   372      residual_range,
   373      split_fraction):
   374  
   375    assert primary_range
   376    primary_items = read_from_source(source, *primary_range)
   377  
   378    if not split_successful:
   379      # For unsuccessful splits, residual_range should be None.
   380      assert not residual_range
   381  
   382    residual_items = (
   383        read_from_source(source, *residual_range) if split_successful else [])
   384  
   385    total_items = primary_items + residual_items
   386  
   387    if current_items != primary_items:
   388      raise ValueError(
   389          'Current source %r and a source created using the '
   390          'range of the primary source %r determined '
   391          'by performing dynamic work rebalancing at fraction '
   392          '%r produced different values. Expected '
   393          'these sources to produce the same list of values.' %
   394          (source, _range_to_str(*primary_range), split_fraction))
   395  
   396    if expected_items != total_items:
   397      raise ValueError(
   398          'Items obtained by reading the source %r for primary '
   399          'and residual ranges %s and %s did not produce the '
   400          'expected list of values.' %
   401          (source, _range_to_str(*primary_range), _range_to_str(*residual_range)))
   402  
   403    result = (len(primary_items), len(residual_items) if split_successful else -1)
   404    return result
   405  
   406  
   407  def assert_split_at_fraction_succeeds_and_consistent(
   408      source, num_items_to_read_before_split, split_fraction):
   409    """Verifies some consistency properties of dynamic work rebalancing.
   410  
   411    Equivalent to the following pseudocode:::
   412  
   413      original_range_tracker = source.getRangeTracker(None, None)
   414      original_reader = source.read(original_range_tracker)
   415      items_before_split = read N items from original_reader
   416      suggested_split_position = original_range_tracker.position_for_fraction(
   417        split_fraction)
   418      original_stop_position - original_range_tracker.stop_position()
   419      split_result = range_tracker.try_split()
   420      split_position, split_fraction = split_result
   421      primary_range_tracker = source.get_range_tracker(
   422        original_range_tracker.start_position(), split_position)
   423      residual_range_tracker = source.get_range_tracker(split_position,
   424        original_stop_position)
   425  
   426      assert that: items when reading source.read(primary_range_tracker) ==
   427        items_before_split + items from continuing to read 'original_reader'
   428      assert that: items when reading source.read(original_range_tracker) =
   429        items when reading source.read(primary_range_tracker) + items when reading
   430      source.read(residual_range_tracker)
   431  
   432    Args:
   433  
   434      source: source to perform dynamic work rebalancing on.
   435      num_items_to_read_before_split: number of items to read before splitting.
   436      split_fraction: fraction to split at.
   437    """
   438  
   439    assert_split_at_fraction_behavior(
   440        source,
   441        num_items_to_read_before_split,
   442        split_fraction,
   443        ExpectedSplitOutcome.MUST_SUCCEED_AND_BE_CONSISTENT)
   444  
   445  
   446  def assert_split_at_fraction_fails(
   447      source, num_items_to_read_before_split, split_fraction):
   448    """Asserts that dynamic work rebalancing at a given fraction fails.
   449  
   450    Asserts that trying to perform dynamic splitting after reading
   451    'num_items_to_read_before_split' items from the source fails.
   452  
   453    Args:
   454      source: source to perform dynamic splitting on.
   455      num_items_to_read_before_split: number of items to read before splitting.
   456      split_fraction: fraction to split at.
   457    """
   458  
   459    assert_split_at_fraction_behavior(
   460        source,
   461        num_items_to_read_before_split,
   462        split_fraction,
   463        ExpectedSplitOutcome.MUST_FAIL)
   464  
   465  
   466  def assert_split_at_fraction_binary(
   467      source,
   468      expected_items,
   469      num_items_to_read_before_split,
   470      left_fraction,
   471      left_result,
   472      right_fraction,
   473      right_result,
   474      stats,
   475      start_position=None,
   476      stop_position=None):
   477    """Performs dynamic work rebalancing for fractions within a given range.
   478  
   479    Asserts that given a start position, a source can be split at every
   480    interesting fraction (halfway between two fractions that differ by at
   481    least one item) and the results are consistent if a split succeeds.
   482  
   483    Args:
   484      source: source to perform dynamic splitting on.
   485      expected_items: total set of items expected when reading the source.
   486      num_items_to_read_before_split: number of items to read before splitting.
   487      left_fraction: left fraction for binary splitting.
   488      left_result: result received by splitting at left fraction.
   489      right_fraction: right fraction for binary splitting.
   490      right_result: result received by splitting at right fraction.
   491      stats: a ``SplitFractionStatistics`` for storing results.
   492    """
   493    assert right_fraction > left_fraction
   494  
   495    if right_fraction - left_fraction < 0.001:
   496      # This prevents infinite recursion.
   497      return
   498  
   499    middle_fraction = (left_fraction + right_fraction) / 2
   500  
   501    if left_result is None:
   502      left_result = _assert_split_at_fraction_behavior(
   503          source,
   504          expected_items,
   505          num_items_to_read_before_split,
   506          left_fraction,
   507          ExpectedSplitOutcome.MUST_BE_CONSISTENT_IF_SUCCEEDS)
   508  
   509    if right_result is None:
   510      right_result = _assert_split_at_fraction_behavior(
   511          source,
   512          expected_items,
   513          num_items_to_read_before_split,
   514          right_fraction,
   515          ExpectedSplitOutcome.MUST_BE_CONSISTENT_IF_SUCCEEDS)
   516  
   517    middle_result = _assert_split_at_fraction_behavior(
   518        source,
   519        expected_items,
   520        num_items_to_read_before_split,
   521        middle_fraction,
   522        ExpectedSplitOutcome.MUST_BE_CONSISTENT_IF_SUCCEEDS)
   523  
   524    if middle_result[1] != -1:
   525      stats.successful_fractions.append(middle_fraction)
   526    if middle_result[1] > 0:
   527      stats.non_trivial_fractions.append(middle_fraction)
   528  
   529    # Two split results are equivalent if primary and residual ranges of them
   530    # produce the same number of records (simply checking the size of primary
   531    # enough since the total number of records is constant).
   532  
   533    if left_result[0] != middle_result[0]:
   534      assert_split_at_fraction_binary(
   535          source,
   536          expected_items,
   537          num_items_to_read_before_split,
   538          left_fraction,
   539          left_result,
   540          middle_fraction,
   541          middle_result,
   542          stats)
   543  
   544    # We special case right_fraction=1.0 since that could fail due to being out
   545    # of range. (even if a dynamic split fails at 'middle_fraction' and at
   546    # fraction 1.0, there might be fractions in range ('middle_fraction', 1.0)
   547    # where dynamic splitting succeeds).
   548    if right_fraction == 1.0 or middle_result[0] != right_result[0]:
   549      assert_split_at_fraction_binary(
   550          source,
   551          expected_items,
   552          num_items_to_read_before_split,
   553          middle_fraction,
   554          middle_result,
   555          right_fraction,
   556          right_result,
   557          stats)
   558  
   559  
   560  MAX_CONCURRENT_SPLITTING_TRIALS_PER_ITEM = 100
   561  MAX_CONCURRENT_SPLITTING_TRIALS_TOTAL = 1000
   562  
   563  
   564  def assert_split_at_fraction_exhaustive(
   565      source,
   566      start_position=None,
   567      stop_position=None,
   568      perform_multi_threaded_test=True):
   569    """Performs and tests dynamic work rebalancing exhaustively.
   570  
   571    Asserts that for each possible start position, a source can be split at
   572    every interesting fraction (halfway between two fractions that differ by at
   573    least one item) and the results are consistent if a split succeeds.
   574    Verifies multi threaded splitting as well.
   575  
   576    Args:
   577      source (~apache_beam.io.iobase.BoundedSource): the source to perform
   578        dynamic splitting on.
   579      perform_multi_threaded_test (bool): if :data:`True` performs a
   580        multi-threaded test, otherwise this test is skipped.
   581  
   582    Raises:
   583      ValueError: if the exhaustive splitting test fails.
   584    """
   585  
   586    expected_items = read_from_source(source, start_position, stop_position)
   587    if not expected_items:
   588      raise ValueError('Source %r is empty.' % source)
   589  
   590    if len(expected_items) == 1:
   591      raise ValueError('Source %r only reads a single item.' % source)
   592  
   593    all_non_trivial_fractions = []
   594  
   595    any_successful_fractions = False
   596    any_non_trivial_fractions = False
   597  
   598    for i in range(len(expected_items)):
   599      stats = SplitFractionStatistics([], [])
   600  
   601      assert_split_at_fraction_binary(
   602          source, expected_items, i, 0.0, None, 1.0, None, stats)
   603  
   604      if stats.successful_fractions:
   605        any_successful_fractions = True
   606      if stats.non_trivial_fractions:
   607        any_non_trivial_fractions = True
   608  
   609      all_non_trivial_fractions.append(stats.non_trivial_fractions)
   610  
   611    if not any_successful_fractions:
   612      raise ValueError(
   613          'SplitAtFraction test completed vacuously: no '
   614          'successful split fractions found')
   615  
   616    if not any_non_trivial_fractions:
   617      raise ValueError(
   618          'SplitAtFraction test completed vacuously: no non-trivial split '
   619          'fractions found')
   620  
   621    if not perform_multi_threaded_test:
   622      return
   623  
   624    num_total_trials = 0
   625    for i in range(len(expected_items)):
   626      non_trivial_fractions = [2.0]  # 2.0 is larger than any valid fraction.
   627      non_trivial_fractions.extend(all_non_trivial_fractions[i])
   628      min_non_trivial_fraction = min(non_trivial_fractions)
   629  
   630      if min_non_trivial_fraction == 2.0:
   631        # This will not happen all the time. Otherwise previous test will fail
   632        # due to vacuousness.
   633        continue
   634  
   635      num_trials = 0
   636      have_success = False
   637      have_failure = False
   638  
   639      thread_pool = _ThreadPool(2)
   640      try:
   641        while True:
   642          num_trials += 1
   643          if num_trials > MAX_CONCURRENT_SPLITTING_TRIALS_PER_ITEM:
   644            _LOGGER.warning(
   645                'After %d concurrent splitting trials at item #%d, observed '
   646                'only %s, giving up on this item',
   647                num_trials,
   648                i,
   649                'success' if have_success else 'failure')
   650            break
   651  
   652          if _assert_split_at_fraction_concurrent(source,
   653                                                  expected_items,
   654                                                  i,
   655                                                  min_non_trivial_fraction,
   656                                                  thread_pool):
   657            have_success = True
   658          else:
   659            have_failure = True
   660  
   661          if have_success and have_failure:
   662            _LOGGER.info(
   663                '%d trials to observe both success and failure of '
   664                'concurrent splitting at item #%d',
   665                num_trials,
   666                i)
   667            break
   668      finally:
   669        thread_pool.close()
   670  
   671      num_total_trials += num_trials
   672  
   673      if num_total_trials > MAX_CONCURRENT_SPLITTING_TRIALS_TOTAL:
   674        _LOGGER.warning(
   675            'After %d total concurrent splitting trials, considered '
   676            'only %d items, giving up.',
   677            num_total_trials,
   678            i)
   679        break
   680  
   681    _LOGGER.info(
   682        '%d total concurrent splitting trials for %d items',
   683        num_total_trials,
   684        len(expected_items))
   685  
   686  
   687  def _assert_split_at_fraction_concurrent(
   688      source,
   689      expected_items,
   690      num_items_to_read_before_splitting,
   691      split_fraction,
   692      thread_pool=None):
   693  
   694    range_tracker = source.get_range_tracker(None, None)
   695    stop_position_before_split = range_tracker.stop_position()
   696    reader = source.read(range_tracker)
   697    reader_iter = iter(reader)
   698  
   699    current_items = []
   700    for _ in range(num_items_to_read_before_splitting):
   701      current_items.append(next(reader_iter))
   702  
   703    def read_or_split(test_params):
   704      if test_params[0]:
   705        return [val for val in test_params[1]]
   706      else:
   707        position = test_params[1].position_at_fraction(test_params[2])
   708        result = test_params[1].try_split(position)
   709        return result
   710  
   711    inputs = []
   712    pool = thread_pool if thread_pool else _ThreadPool(2)
   713    try:
   714      inputs.append([True, reader_iter])
   715      inputs.append([False, range_tracker, split_fraction])
   716  
   717      results = pool.map(read_or_split, inputs)
   718    finally:
   719      if not thread_pool:
   720        pool.close()
   721  
   722    current_items.extend(results[0])
   723    primary_range = (
   724        range_tracker.start_position(), range_tracker.stop_position())
   725  
   726    split_result = results[1]
   727    residual_range = (
   728        split_result[0], stop_position_before_split) if split_result else None
   729  
   730    res = _verify_single_split_fraction_result(
   731        source,
   732        expected_items,
   733        current_items,
   734        split_result,
   735        primary_range,
   736        residual_range,
   737        split_fraction)
   738  
   739    return res[1] > 0