Repository: dask/dask

Issue:
Sequential DataFrame joining error
I ran across a case where multiple DataFrame joins will sometimes fail, depending on how the DataFrames are partitioned.

The below example does sequential inner joins (i.e. `df1.join(df2, how='inner').join(df3, how='inner')`) using pandas DataFrames and then using dask DataFrames. 

```python

import pandas as pd
import dask.dataframe as dd
from dask.dataframe.utils import assert_eq

# Pandas version of multiple inner joins
df1 = pd.DataFrame({'key': list(range(6)),
                    'A': ['A0', 'A1', 'A2', 'A3', 'A4', 'A5']})
df2 = pd.DataFrame({'key': list(range(4)),
                    'B': ['B0', 'B1', 'B2', 'B3']})
df3 = pd.DataFrame({'key': list(range(1, 5)),
                    'C': ['C0', 'C1', 'C2', 'C3']})

join_pd = df1.join(df2, how='inner', lsuffix='_l', rsuffix='_r')
multi_join_pd = join_pd.join(df3, how='inner', lsuffix='_l', rsuffix='_r')
print(f'join_pd = {join_pd}')
print(f'multi_join_pd = {multi_join_pd}')

# Dask version of multiple inner joins
ddf1 = dd.from_pandas(df1, npartitions=3)
ddf2 = dd.from_pandas(df2, npartitions=2)
ddf3 = dd.from_pandas(df3, npartitions=2)

join_dd = ddf1.join(ddf2, how='inner', lsuffix='_l', rsuffix='_r')
multi_join_dd = join_dd.join(ddf3, how='inner', lsuffix='_l', rsuffix='_r')
print(f'join_dd.compute() = {join_dd.compute()}')
print(f'multi_join_dd.compute() = {multi_join_dd.compute()}')
```

The pandas case works as expected, however the dask case fails on `master`. 

<details>
<summary>Traceback details</summary>

```
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
<ipython-input-1-b3483754a3b1> in <module>()
     24 multi_join_dd = join_dd.join(ddf3, how='inner', lsuffix='_l', rsuffix='_r')
     25 print(f'join_dd.compute() = {join_dd.compute()}')
---> 26 print(f'multi_join_dd.compute() = {multi_join_dd.compute()}')

~/github/dask/dask/dask/base.py in compute(self, **kwargs)
    154         dask.base.compute
    155         """
--> 156         (result,) = compute(self, traverse=False, **kwargs)
    157         return result
    158

~/github/dask/dask/dask/base.py in compute(*args, **kwargs)
    395     keys = [x.__dask_keys__() for x in collections]
    396     postcomputes = [x.__dask_postcompute__() for x in collections]
--> 397     results = schedule(dsk, keys, **kwargs)
    398     return repack([f(r, *a) for r, (f, a) in zip(results, postcomputes)])
    399

~/github/dask/dask/dask/threaded.py in get(dsk, result, cache, num_workers, **kwargs)
     74     results = get_async(pool.apply_async, len(pool._pool), dsk, result,
     75                         cache=cache, get_id=_thread_get_id,
---> 76                         pack_exception=pack_exception, **kwargs)
     77
     78     # Cleanup pools associated to dead threads

~/github/dask/dask/dask/local.py in get_async(apply_async, num_workers, dsk, result, cache, get_id, rerun_exceptions_locally, pack_exception, raise_exception, callbacks, dumps, loads, **kwargs)
    499                         _execute_task(task, data)  # Re-execute locally
    500                     else:
--> 501                         raise_exception(exc, tb)
    502                 res, worker_id = loads(res_info)
    503                 state['cache'][key] = res

~/github/dask/dask/dask/compatibility.py in reraise(exc, tb)
    110         if exc.__traceback__ is not tb:
    111             raise exc.with_traceback(tb)
--> 112         raise exc
    113
    114 else:

~/github/dask/dask/dask/local.py in execute_task(key, task_info, dumps, loads, get_id, pack_exception)
    270     try:
    271         task, data = loads(task_info)
--> 272         result = _execute_task(task, data)
    273         id = get_id()
    274         result = dumps((result, id))

~/github/dask/dask/dask/local.py in _execute_task(arg, cache, dsk)
    251         func, args = arg[0], arg[1:]
    252         args2 = [_execute_task(a, cache) for a in args]
--> 253         return func(*args2)
    254     elif not ishashable(arg):
    255         return arg

~/github/dask/dask/dask/dataframe/methods.py in boundary_slice(df, start, stop, right_boundary, left_boundary, kind)
    101         return df
    102     else:
--> 103         result = getattr(df, kind)[start:stop]
    104     if not right_boundary:
    105         right_index = result.index.get_slice_bound(stop, 'left', kind)

~/miniconda/envs/dask_dev/lib/python3.6/site-packages/pandas/core/indexing.py in __getitem__(self, key)
   1476
   1477             maybe_callable = com._apply_if_callable(key, self.obj)
-> 1478             return self._getitem_axis(maybe_callable, axis=axis)
   1479
   1480     def _is_scalar_access(self, key):

~/miniconda/envs/dask_dev/lib/python3.6/site-packages/pandas/core/indexing.py in _getitem_axis(self, key, axis)
   1864         if isinstance(key, slice):
   1865             self._validate_key(key, axis)
-> 1866             return self._get_slice_axis(key, axis=axis)
   1867         elif com.is_bool_indexer(key):
   1868             return self._getbool_axis(key, axis=axis)

~/miniconda/envs/dask_dev/lib/python3.6/site-packages/pandas/core/indexing.py in _get_slice_axis(self, slice_obj, axis)
   1509         labels = obj._get_axis(axis)
   1510         indexer = labels.slice_indexer(slice_obj.start, slice_obj.stop,
-> 1511                                        slice_obj.step, kind=self.name)
   1512
   1513         if isinstance(indexer, slice):

~/miniconda/envs/dask_dev/lib/python3.6/site-packages/pandas/core/indexes/base.py in slice_indexer(self, start, end, step, kind)
   4105         """
   4106         start_slice, end_slice = self.slice_locs(start, end, step=step,
-> 4107                                                  kind=kind)
   4108
   4109         # return a slice

~/miniconda/envs/dask_dev/lib/python3.6/site-packages/pandas/core/indexes/base.py in slice_locs(self, start, end, step, kind)
   4306         start_slice = None
   4307         if start is not None:
-> 4308             start_slice = self.get_slice_bound(start, 'left', kind)
   4309         if start_slice is None:
   4310             start_slice = 0

~/miniconda/envs/dask_dev/lib/python3.6/site-packages/pandas/core/indexes/base.py in get_slice_bound(self, label, side, kind)
   4232         # For datetime indices label may be a string that has to be converted
   4233         # to datetime boundary according to its resolution.
-> 4234         label = self._maybe_cast_slice_bound(label, side, kind)
   4235
   4236         # we need to look up the label

~/miniconda/envs/dask_dev/lib/python3.6/site-packages/pandas/core/indexes/base.py in _maybe_cast_slice_bound(self, label, side, kind)
   4183         # this is rejected (generally .loc gets you here)
   4184         elif is_integer(label):
-> 4185             self._invalid_indexer('slice', label)
   4186
   4187         return label

~/miniconda/envs/dask_dev/lib/python3.6/site-packages/pandas/core/indexes/base.py in _invalid_indexer(self, form, key)
   1861                         "indexers [{key}] of {kind}".format(
   1862                             form=form, klass=type(self), key=key,
-> 1863                             kind=type(key)))
   1864
   1865     def get_duplicates(self):

TypeError: cannot do slice indexing on <class 'pandas.core.indexes.base.Index'> with these indexers [4] of <class 'int'>
```

</details>

<br>

Interestingly, the failure is partition-dependent. For instance in the above example, if you change the partitions for `ddf1` so that `ddf1 = dd.from_pandas(df1, npartitions=2)`, then the sequential joining of dask DataFrames works. 


Discussion:

Resolving patch:
diff --git a/dask/dataframe/methods.py b/dask/dataframe/methods.py
--- a/dask/dataframe/methods.py
+++ b/dask/dataframe/methods.py
@@ -51,6 +51,8 @@ def boundary_slice(df, start, stop, right_boundary=True, left_boundary=True,
                    kind='loc'):
     """Index slice start/stop. Can switch include/exclude boundaries.
 
+    Examples
+    --------
     >>> df = pd.DataFrame({'x': [10, 20, 30, 40, 50]}, index=[1, 2, 2, 3, 4])
     >>> boundary_slice(df, 2, None)
         x
@@ -69,7 +71,18 @@ def boundary_slice(df, start, stop, right_boundary=True, left_boundary=True,
     1  10
     2  20
     2  30
+
+    Empty input DataFrames are returned
+
+    >>> df_empty = pd.DataFrame()
+    >>> boundary_slice(df_empty, 1, 3)
+    Empty DataFrame
+    Columns: []
+    Index: []
     """
+    if df.empty:
+        return df
+
     if kind == 'loc' and not df.index.is_monotonic:
         # Pandas treats missing keys differently for label-slicing
         # on monotonic vs. non-monotonic indexes


Test patch:
diff --git a/dask/dataframe/tests/test_dataframe.py b/dask/dataframe/tests/test_dataframe.py
--- a/dask/dataframe/tests/test_dataframe.py
+++ b/dask/dataframe/tests/test_dataframe.py
@@ -3076,6 +3076,13 @@ def test_boundary_slice_nonmonotonic():
     tm.assert_frame_equal(result, expected)
 
 
+def test_boundary_slice_empty():
+    df = pd.DataFrame()
+    result = methods.boundary_slice(df, 1, 4)
+    expected = pd.DataFrame()
+    tm.assert_frame_equal(result, expected)
+
+
 @pytest.mark.parametrize('start, stop, right_boundary, left_boundary, drop', [
     (-1, None, False, False, [-1, -2]),
     (-1, None, False, True, [-2]),
diff --git a/dask/dataframe/tests/test_multi.py b/dask/dataframe/tests/test_multi.py
--- a/dask/dataframe/tests/test_multi.py
+++ b/dask/dataframe/tests/test_multi.py
@@ -239,6 +239,28 @@ def test_hash_join(how, shuffle):
             hash_join(a, 'y', b, 'y', 'outer', shuffle=shuffle)._name)
 
 
+def test_sequential_joins():
+    # Pandas version of multiple inner joins
+    df1 = pd.DataFrame({'key': list(range(6)),
+                        'A': ['A0', 'A1', 'A2', 'A3', 'A4', 'A5']})
+    df2 = pd.DataFrame({'key': list(range(4)),
+                        'B': ['B0', 'B1', 'B2', 'B3']})
+    df3 = pd.DataFrame({'key': list(range(1, 5)),
+                        'C': ['C0', 'C1', 'C2', 'C3']})
+
+    join_pd = df1.join(df2, how='inner', lsuffix='_l', rsuffix='_r')
+    multi_join_pd = join_pd.join(df3, how='inner', lsuffix='_l', rsuffix='_r')
+
+    # Dask version of multiple inner joins
+    ddf1 = dd.from_pandas(df1, npartitions=3)
+    ddf2 = dd.from_pandas(df2, npartitions=2)
+    ddf3 = dd.from_pandas(df3, npartitions=2)
+
+    join_dd = ddf1.join(ddf2, how='inner', lsuffix='_l', rsuffix='_r')
+    multi_join_dd = join_dd.join(ddf3, how='inner', lsuffix='_l', rsuffix='_r')
+    assert_eq(multi_join_pd, multi_join_dd)
+
+
 @pytest.mark.parametrize('join', ['inner', 'outer'])
 def test_indexed_concat(join):
     A = pd.DataFrame({'x': [1, 2, 3, 4, 6, 7], 'y': list('abcdef')},