Repository: zarr-developers/zarr-python

Issue:
behavior of `is_total_slice` for boundary chunks
The `is_total_slice` function is used to determine whether a selection corresponds to an entire chunk (`is_total_slice` -> `True`) or not (`is_total_slice` -> `False`). 

I noticed that `is_total_slice` is always `False` for "partial chunks", i.e. chunks that are not filled by the array: 

```python
import zarr
from zarr.util import is_total_slice
# create an array with 1 full chunk and 1 partial chunk
a = zarr.open('test.zarr', path='test', shape=(10,), chunks=(9,), dtype='uint8', mode='w')
for x in BasicIndexer(slice(None), a):
    print(x.chunk_selection, is_total_slice(x.chunk_selection, a._chunks))
```
Which prints this:
```python
(slice(0, 9, 1),) True
(slice(0, 1, 1),) False
```
Although the last selection is not the size of a full chunk, it is "total" with respect to the output of that selection in the array.
A direct consequence of this behavior is unnecessary chunk loading when performing array assignments -- zarr uses the result of `is_total_slice` to decide whether to load existing chunk data or not. Because `is_total_slice` is always `False` for partial chunks, zarr _always_ loads boundary chunks during assignment.

A solution to this would be to augment the `is_total_slice` function to account for partial chunks. I'm not sure at the moment how to do this exactly, but it shouldn't be hard. Happy to bring forth a PR if people agree that this is an issue worth addressing. 



Discussion:
dcherian: https://github.com/zarr-developers/zarr-python/pull/2784 fixes the `is_total_slice` problem.
d-v-b: I find the indexing code pretty hard to follow, but this looks good to me
jhamman: @normanrz - do you have the bandwidth to give this a review?
dcherian: @normanrz if you have time, please prioritize reviewing https://github.com/zarr-developers/numcodecs/pull/700 over this one.
dcherian: I'll merge tomorrow if there are no more comments.

Patch:
diff --git a/changes/2784.feature.rst b/changes/2784.feature.rst
new file mode 100644
index 00000000..e3218e6d
--- /dev/null
+++ b/changes/2784.feature.rst
@@ -0,0 +1,1 @@
+Avoid reading chunks during writes where possible. :issue:`757`
diff --git a/src/zarr/abc/codec.py b/src/zarr/abc/codec.py
index fabd042d..16400f5f 100644
--- a/src/zarr/abc/codec.py
+++ b/src/zarr/abc/codec.py
@@ -357,7 +357,7 @@ class CodecPipeline:
     @abstractmethod
     async def read(
         self,
-        batch_info: Iterable[tuple[ByteGetter, ArraySpec, SelectorTuple, SelectorTuple]],
+        batch_info: Iterable[tuple[ByteGetter, ArraySpec, SelectorTuple, SelectorTuple, bool]],
         out: NDBuffer,
         drop_axes: tuple[int, ...] = (),
     ) -> None:
@@ -379,7 +379,7 @@ class CodecPipeline:
     @abstractmethod
     async def write(
         self,
-        batch_info: Iterable[tuple[ByteSetter, ArraySpec, SelectorTuple, SelectorTuple]],
+        batch_info: Iterable[tuple[ByteSetter, ArraySpec, SelectorTuple, SelectorTuple, bool]],
         value: NDBuffer,
         drop_axes: tuple[int, ...] = (),
     ) -> None:
diff --git a/src/zarr/codecs/sharding.py b/src/zarr/codecs/sharding.py
index e8730c86..459805d8 100644
--- a/src/zarr/codecs/sharding.py
+++ b/src/zarr/codecs/sharding.py
@@ -455,8 +455,9 @@ class ShardingCodec(
                     chunk_spec,
                     chunk_selection,
                     out_selection,
+                    is_complete_shard,
                 )
-                for chunk_coords, chunk_selection, out_selection in indexer
+                for chunk_coords, chunk_selection, out_selection, is_complete_shard in indexer
             ],
             out,
         )
@@ -486,7 +487,7 @@ class ShardingCodec(
         )
 
         indexed_chunks = list(indexer)
-        all_chunk_coords = {chunk_coords for chunk_coords, _, _ in indexed_chunks}
+        all_chunk_coords = {chunk_coords for chunk_coords, *_ in indexed_chunks}
 
         # reading bytes of all requested chunks
         shard_dict: ShardMapping = {}
@@ -524,8 +525,9 @@ class ShardingCodec(
                     chunk_spec,
                     chunk_selection,
                     out_selection,
+                    is_complete_shard,
                 )
-                for chunk_coords, chunk_selection, out_selection in indexer
+                for chunk_coords, chunk_selection, out_selection, is_complete_shard in indexer
             ],
             out,
         )
@@ -558,8 +560,9 @@ class ShardingCodec(
                     chunk_spec,
                     chunk_selection,
                     out_selection,
+                    is_complete_shard,
                 )
-                for chunk_coords, chunk_selection, out_selection in indexer
+                for chunk_coords, chunk_selection, out_selection, is_complete_shard in indexer
             ],
             shard_array,
         )
@@ -601,8 +604,9 @@ class ShardingCodec(
                     chunk_spec,
                     chunk_selection,
                     out_selection,
+                    is_complete_shard,
                 )
-                for chunk_coords, chunk_selection, out_selection in indexer
+                for chunk_coords, chunk_selection, out_selection, is_complete_shard in indexer
             ],
             shard_array,
         )
diff --git a/src/zarr/core/array.py b/src/zarr/core/array.py
index 4c444a81..9e2fdf37 100644
--- a/src/zarr/core/array.py
+++ b/src/zarr/core/array.py
@@ -1290,8 +1290,9 @@ class AsyncArray(Generic[T_ArrayMetadata]):
                         self.metadata.get_chunk_spec(chunk_coords, _config, prototype=prototype),
                         chunk_selection,
                         out_selection,
+                        is_complete_chunk,
                     )
-                    for chunk_coords, chunk_selection, out_selection in indexer
+                    for chunk_coords, chunk_selection, out_selection, is_complete_chunk in indexer
                 ],
                 out_buffer,
                 drop_axes=indexer.drop_axes,
@@ -1417,8 +1418,9 @@ class AsyncArray(Generic[T_ArrayMetadata]):
                     self.metadata.get_chunk_spec(chunk_coords, _config, prototype),
                     chunk_selection,
                     out_selection,
+                    is_complete_chunk,
                 )
-                for chunk_coords, chunk_selection, out_selection in indexer
+                for chunk_coords, chunk_selection, out_selection, is_complete_chunk in indexer
             ],
             value_buffer,
             drop_axes=indexer.drop_axes,
diff --git a/src/zarr/core/codec_pipeline.py b/src/zarr/core/codec_pipeline.py
index a35c5ca2..0c53cda9 100644
--- a/src/zarr/core/codec_pipeline.py
+++ b/src/zarr/core/codec_pipeline.py
@@ -16,7 +16,7 @@ from zarr.abc.codec import (
 )
 from zarr.core.common import ChunkCoords, concurrent_map
 from zarr.core.config import config
-from zarr.core.indexing import SelectorTuple, is_scalar, is_total_slice
+from zarr.core.indexing import SelectorTuple, is_scalar
 from zarr.core.metadata.v2 import _default_fill_value
 from zarr.registry import register_pipeline
 
@@ -243,7 +243,7 @@ class BatchedCodecPipeline(CodecPipeline):
 
     async def read_batch(
         self,
-        batch_info: Iterable[tuple[ByteGetter, ArraySpec, SelectorTuple, SelectorTuple]],
+        batch_info: Iterable[tuple[ByteGetter, ArraySpec, SelectorTuple, SelectorTuple, bool]],
         out: NDBuffer,
         drop_axes: tuple[int, ...] = (),
     ) -> None:
@@ -251,10 +251,10 @@ class BatchedCodecPipeline(CodecPipeline):
             chunk_array_batch = await self.decode_partial_batch(
                 [
                     (byte_getter, chunk_selection, chunk_spec)
-                    for byte_getter, chunk_spec, chunk_selection, _ in batch_info
+                    for byte_getter, chunk_spec, chunk_selection, *_ in batch_info
                 ]
             )
-            for chunk_array, (_, chunk_spec, _, out_selection) in zip(
+            for chunk_array, (_, chunk_spec, _, out_selection, _) in zip(
                 chunk_array_batch, batch_info, strict=False
             ):
                 if chunk_array is not None:
@@ -263,22 +263,19 @@ class BatchedCodecPipeline(CodecPipeline):
                     out[out_selection] = fill_value_or_default(chunk_spec)
         else:
             chunk_bytes_batch = await concurrent_map(
-                [
-                    (byte_getter, array_spec.prototype)
-                    for byte_getter, array_spec, _, _ in batch_info
-                ],
+                [(byte_getter, array_spec.prototype) for byte_getter, array_spec, *_ in batch_info],
                 lambda byte_getter, prototype: byte_getter.get(prototype),
                 config.get("async.concurrency"),
             )
             chunk_array_batch = await self.decode_batch(
                 [
                     (chunk_bytes, chunk_spec)
-                    for chunk_bytes, (_, chunk_spec, _, _) in zip(
+                    for chunk_bytes, (_, chunk_spec, *_) in zip(
                         chunk_bytes_batch, batch_info, strict=False
                     )
                 ],
             )
-            for chunk_array, (_, chunk_spec, chunk_selection, out_selection) in zip(
+            for chunk_array, (_, chunk_spec, chunk_selection, out_selection, _) in zip(
                 chunk_array_batch, batch_info, strict=False
             ):
                 if chunk_array is not None:
@@ -296,9 +293,10 @@ class BatchedCodecPipeline(CodecPipeline):
         out_selection: SelectorTuple,
         chunk_spec: ArraySpec,
         chunk_selection: SelectorTuple,
+        is_complete_chunk: bool,
         drop_axes: tuple[int, ...],
     ) -> NDBuffer:
-        if is_total_slice(chunk_selection, chunk_spec.shape) and value.shape == chunk_spec.shape:
+        if is_complete_chunk and value.shape == chunk_spec.shape:
             return value
         if existing_chunk_array is None:
             chunk_array = chunk_spec.prototype.nd_buffer.create(
@@ -327,7 +325,7 @@ class BatchedCodecPipeline(CodecPipeline):
 
     async def write_batch(
         self,
-        batch_info: Iterable[tuple[ByteSetter, ArraySpec, SelectorTuple, SelectorTuple]],
+        batch_info: Iterable[tuple[ByteSetter, ArraySpec, SelectorTuple, SelectorTuple, bool]],
         value: NDBuffer,
         drop_axes: tuple[int, ...] = (),
     ) -> None:
@@ -337,14 +335,14 @@ class BatchedCodecPipeline(CodecPipeline):
                 await self.encode_partial_batch(
                     [
                         (byte_setter, value, chunk_selection, chunk_spec)
-                        for byte_setter, chunk_spec, chunk_selection, out_selection in batch_info
+                        for byte_setter, chunk_spec, chunk_selection, out_selection, _ in batch_info
                     ],
                 )
             else:
                 await self.encode_partial_batch(
                     [
                         (byte_setter, value[out_selection], chunk_selection, chunk_spec)
-                        for byte_setter, chunk_spec, chunk_selection, out_selection in batch_info
+                        for byte_setter, chunk_spec, chunk_selection, out_selection, _ in batch_info
                     ],
                 )
 
@@ -361,10 +359,10 @@ class BatchedCodecPipeline(CodecPipeline):
             chunk_bytes_batch = await concurrent_map(
                 [
                     (
-                        None if is_total_slice(chunk_selection, chunk_spec.shape) else byte_setter,
+                        None if is_complete_chunk else byte_setter,
                         chunk_spec.prototype,
                     )
-                    for byte_setter, chunk_spec, chunk_selection, _ in batch_info
+                    for byte_setter, chunk_spec, chunk_selection, _, is_complete_chunk in batch_info
                 ],
                 _read_key,
                 config.get("async.concurrency"),
@@ -372,7 +370,7 @@ class BatchedCodecPipeline(CodecPipeline):
             chunk_array_decoded = await self.decode_batch(
                 [
                     (chunk_bytes, chunk_spec)
-                    for chunk_bytes, (_, chunk_spec, _, _) in zip(
+                    for chunk_bytes, (_, chunk_spec, *_) in zip(
                         chunk_bytes_batch, batch_info, strict=False
                     )
                 ],
@@ -380,14 +378,24 @@ class BatchedCodecPipeline(CodecPipeline):
 
             chunk_array_merged = [
                 self._merge_chunk_array(
-                    chunk_array, value, out_selection, chunk_spec, chunk_selection, drop_axes
-                )
-                for chunk_array, (_, chunk_spec, chunk_selection, out_selection) in zip(
-                    chunk_array_decoded, batch_info, strict=False
+                    chunk_array,
+                    value,
+                    out_selection,
+                    chunk_spec,
+                    chunk_selection,
+                    is_complete_chunk,
+                    drop_axes,
                 )
+                for chunk_array, (
+                    _,
+                    chunk_spec,
+                    chunk_selection,
+                    out_selection,
+                    is_complete_chunk,
+                ) in zip(chunk_array_decoded, batch_info, strict=False)
             ]
             chunk_array_batch: list[NDBuffer | None] = []
-            for chunk_array, (_, chunk_spec, _, _) in zip(
+            for chunk_array, (_, chunk_spec, *_) in zip(
                 chunk_array_merged, batch_info, strict=False
             ):
                 if chunk_array is None:
@@ -403,7 +411,7 @@ class BatchedCodecPipeline(CodecPipeline):
             chunk_bytes_batch = await self.encode_batch(
                 [
                     (chunk_array, chunk_spec)
-                    for chunk_array, (_, chunk_spec, _, _) in zip(
+                    for chunk_array, (_, chunk_spec, *_) in zip(
                         chunk_array_batch, batch_info, strict=False
                     )
                 ],
@@ -418,7 +426,7 @@ class BatchedCodecPipeline(CodecPipeline):
             await concurrent_map(
                 [
                     (byte_setter, chunk_bytes)
-                    for chunk_bytes, (byte_setter, _, _, _) in zip(
+                    for chunk_bytes, (byte_setter, *_) in zip(
                         chunk_bytes_batch, batch_info, strict=False
                     )
                 ],
@@ -446,7 +454,7 @@ class BatchedCodecPipeline(CodecPipeline):
 
     async def read(
         self,
-        batch_info: Iterable[tuple[ByteGetter, ArraySpec, SelectorTuple, SelectorTuple]],
+        batch_info: Iterable[tuple[ByteGetter, ArraySpec, SelectorTuple, SelectorTuple, bool]],
         out: NDBuffer,
         drop_axes: tuple[int, ...] = (),
     ) -> None:
@@ -461,7 +469,7 @@ class BatchedCodecPipeline(CodecPipeline):
 
     async def write(
         self,
-        batch_info: Iterable[tuple[ByteSetter, ArraySpec, SelectorTuple, SelectorTuple]],
+        batch_info: Iterable[tuple[ByteSetter, ArraySpec, SelectorTuple, SelectorTuple, bool]],
         value: NDBuffer,
         drop_axes: tuple[int, ...] = (),
     ) -> None:
diff --git a/src/zarr/core/indexing.py b/src/zarr/core/indexing.py
index 733b2464..c197f6f3 100644
--- a/src/zarr/core/indexing.py
+++ b/src/zarr/core/indexing.py
@@ -321,12 +321,12 @@ class ChunkDimProjection(NamedTuple):
         Selection of items from chunk array.
     dim_out_sel
         Selection of items in target (output) array.
-
     """
 
     dim_chunk_ix: int
     dim_chunk_sel: Selector
     dim_out_sel: Selector | None
+    is_complete_chunk: bool
 
 
 @dataclass(frozen=True)
@@ -346,7 +346,8 @@ class IntDimIndexer:
         dim_offset = dim_chunk_ix * self.dim_chunk_len
         dim_chunk_sel = self.dim_sel - dim_offset
         dim_out_sel = None
-        yield ChunkDimProjection(dim_chunk_ix, dim_chunk_sel, dim_out_sel)
+        is_complete_chunk = self.dim_chunk_len == 1
+        yield ChunkDimProjection(dim_chunk_ix, dim_chunk_sel, dim_out_sel, is_complete_chunk)
 
 
 @dataclass(frozen=True)
@@ -420,7 +421,10 @@ class SliceDimIndexer:
 
             dim_out_sel = slice(dim_out_offset, dim_out_offset + dim_chunk_nitems)
 
-            yield ChunkDimProjection(dim_chunk_ix, dim_chunk_sel, dim_out_sel)
+            is_complete_chunk = (
+                dim_chunk_sel_start == 0 and (self.stop >= dim_limit) and self.step in [1, None]
+            )
+            yield ChunkDimProjection(dim_chunk_ix, dim_chunk_sel, dim_out_sel, is_complete_chunk)
 
 
 def check_selection_length(selection: SelectionNormalized, shape: ChunkCoords) -> None:
@@ -493,12 +497,14 @@ class ChunkProjection(NamedTuple):
         Selection of items from chunk array.
     out_selection
         Selection of items in target (output) array.
-
+    is_complete_chunk:
+        True if a complete chunk is indexed
     """
 
     chunk_coords: ChunkCoords
     chunk_selection: tuple[Selector, ...] | npt.NDArray[np.intp]
     out_selection: tuple[Selector, ...] | npt.NDArray[np.intp] | slice
+    is_complete_chunk: bool
 
 
 def is_slice(s: Any) -> TypeGuard[slice]:
@@ -574,8 +580,8 @@ class BasicIndexer(Indexer):
             out_selection = tuple(
                 p.dim_out_sel for p in dim_projections if p.dim_out_sel is not None
             )
-
-            yield ChunkProjection(chunk_coords, chunk_selection, out_selection)
+            is_complete_chunk = all(p.is_complete_chunk for p in dim_projections)
+            yield ChunkProjection(chunk_coords, chunk_selection, out_selection, is_complete_chunk)
 
 
 @dataclass(frozen=True)
@@ -643,8 +649,9 @@ class BoolArrayDimIndexer:
                 start = self.chunk_nitems_cumsum[dim_chunk_ix - 1]
             stop = self.chunk_nitems_cumsum[dim_chunk_ix]
             dim_out_sel = slice(start, stop)
+            is_complete_chunk = False  # TODO
 
-            yield ChunkDimProjection(dim_chunk_ix, dim_chunk_sel, dim_out_sel)
+            yield ChunkDimProjection(dim_chunk_ix, dim_chunk_sel, dim_out_sel, is_complete_chunk)
 
 
 class Order(Enum):
@@ -783,8 +790,8 @@ class IntArrayDimIndexer:
             # find region in chunk
             dim_offset = dim_chunk_ix * self.dim_chunk_len
             dim_chunk_sel = self.dim_sel[start:stop] - dim_offset
-
-            yield ChunkDimProjection(dim_chunk_ix, dim_chunk_sel, dim_out_sel)
+            is_complete_chunk = False  # TODO
+            yield ChunkDimProjection(dim_chunk_ix, dim_chunk_sel, dim_out_sel, is_complete_chunk)
 
 
 def slice_to_range(s: slice, length: int) -> range:
@@ -921,7 +928,8 @@ class OrthogonalIndexer(Indexer):
                 if not is_basic_selection(out_selection):
                     out_selection = ix_(out_selection, self.shape)
 
-            yield ChunkProjection(chunk_coords, chunk_selection, out_selection)
+            is_complete_chunk = all(p.is_complete_chunk for p in dim_projections)
+            yield ChunkProjection(chunk_coords, chunk_selection, out_selection, is_complete_chunk)
 
 
 @dataclass(frozen=True)
@@ -1030,8 +1038,8 @@ class BlockIndexer(Indexer):
             out_selection = tuple(
                 p.dim_out_sel for p in dim_projections if p.dim_out_sel is not None
             )
-
-            yield ChunkProjection(chunk_coords, chunk_selection, out_selection)
+            is_complete_chunk = all(p.is_complete_chunk for p in dim_projections)
+            yield ChunkProjection(chunk_coords, chunk_selection, out_selection, is_complete_chunk)
 
 
 @dataclass(frozen=True)
@@ -1198,7 +1206,8 @@ class CoordinateIndexer(Indexer):
                 for (dim_sel, dim_chunk_offset) in zip(self.selection, chunk_offsets, strict=True)
             )
 
-            yield ChunkProjection(chunk_coords, chunk_selection, out_selection)
+            is_complete_chunk = False  # TODO
+            yield ChunkProjection(chunk_coords, chunk_selection, out_selection, is_complete_chunk)
 
 
 @dataclass(frozen=True)
@@ -1361,32 +1370,6 @@ def c_order_iter(chunks_per_shard: ChunkCoords) -> Iterator[ChunkCoords]:
     return itertools.product(*(range(x) for x in chunks_per_shard))
 
 
-def is_total_slice(item: Selection, shape: ChunkCoords) -> bool:
-    """Determine whether `item` specifies a complete slice of array with the
-    given `shape`. Used to optimize __setitem__ operations on the Chunk
-    class."""
-
-    # N.B., assume shape is normalized
-    if item == slice(None):
-        return True
-    if isinstance(item, slice):
-        item = (item,)
-    if isinstance(item, tuple):
-        return all(
-            (isinstance(dim_sel, int) and dim_len == 1)
-            or (
-                isinstance(dim_sel, slice)
-                and (
-                    (dim_sel == slice(None))
-                    or ((dim_sel.stop - dim_sel.start == dim_len) and (dim_sel.step in [1, None]))
-                )
-            )
-            for dim_sel, dim_len in zip(item, shape, strict=False)
-        )
-    else:
-        raise TypeError(f"expected slice or tuple of slices, found {item!r}")
-
-
 def get_indexer(
     selection: SelectionWithFields, shape: ChunkCoords, chunk_grid: ChunkGrid
 ) -> Indexer:
diff --git a/src/zarr/storage/_logging.py b/src/zarr/storage/_logging.py
index e9d62115..5f1a97ac 100644
--- a/src/zarr/storage/_logging.py
+++ b/src/zarr/storage/_logging.py
@@ -88,7 +88,7 @@ class LoggingStore(WrapperStore[T_Store]):
         op = f"{type(self._store).__name__}.{method}"
         if hint:
             op = f"{op}({hint})"
-        self.logger.info("Calling %s", op)
+        self.logger.info(" Calling %s", op)
         start_time = time.time()
         try:
             self.counter[method] += 1


Test patch:
diff --git a/src/zarr/testing/strategies.py b/src/zarr/testing/strategies.py
index 5722f3c9..0883d79b 100644
--- a/src/zarr/testing/strategies.py
+++ b/src/zarr/testing/strategies.py
@@ -209,6 +209,43 @@ def basic_indices(draw: st.DrawFn, *, shape: tuple[int], **kwargs: Any) -> Any:
     )
 
 
+@st.composite  # type: ignore[misc]
+def orthogonal_indices(
+    draw: st.DrawFn, *, shape: tuple[int]
+) -> tuple[tuple[np.ndarray[Any, Any], ...], tuple[np.ndarray[Any, Any], ...]]:
+    """
+    Strategy that returns
+    (1) a tuple of integer arrays used for orthogonal indexing of Zarr arrays.
+    (2) an tuple of integer arrays that can be used for equivalent indexing of numpy arrays
+    """
+    zindexer = []
+    npindexer = []
+    ndim = len(shape)
+    for axis, size in enumerate(shape):
+        val = draw(
+            npst.integer_array_indices(
+                shape=(size,), result_shape=npst.array_shapes(min_side=1, max_side=size, max_dims=1)
+            )
+            | basic_indices(min_dims=1, shape=(size,), allow_ellipsis=False)
+            .map(lambda x: (x,) if not isinstance(x, tuple) else x)  # bare ints, slices
+            .filter(lambda x: bool(x))  # skip empty tuple
+        )
+        (idxr,) = val
+        if isinstance(idxr, int):
+            idxr = np.array([idxr])
+        zindexer.append(idxr)
+        if isinstance(idxr, slice):
+            idxr = np.arange(*idxr.indices(size))
+        elif isinstance(idxr, (tuple, int)):
+            idxr = np.array(idxr)
+        newshape = [1] * ndim
+        newshape[axis] = idxr.size
+        npindexer.append(idxr.reshape(newshape))
+
+    # casting the output of broadcast_arrays is needed for numpy 1.25
+    return tuple(zindexer), tuple(np.broadcast_arrays(*npindexer))
+
+
 def key_ranges(
     keys: SearchStrategy = node_names, max_size: int = sys.maxsize
 ) -> SearchStrategy[list[int]]:
diff --git a/tests/test_array.py b/tests/test_array.py
index 1b84d1d0..6aaf1072 100644
--- a/tests/test_array.py
+++ b/tests/test_array.py
@@ -1338,9 +1338,18 @@ async def test_orthogonal_set_total_slice() -> None:
     """Ensure that a whole chunk overwrite does not read chunks"""
     store = MemoryStore()
     array = zarr.create_array(store, shape=(20, 20), chunks=(1, 2), dtype=int, fill_value=-1)
-    with mock.patch("zarr.storage.MemoryStore.get", side_effect=ValueError):
+    with mock.patch("zarr.storage.MemoryStore.get", side_effect=RuntimeError):
         array[0, slice(4, 10)] = np.arange(6)
 
+    array = zarr.create_array(
+        store, shape=(20, 21), chunks=(1, 2), dtype=int, fill_value=-1, overwrite=True
+    )
+    with mock.patch("zarr.storage.MemoryStore.get", side_effect=RuntimeError):
+        array[0, :] = np.arange(21)
+
+    with mock.patch("zarr.storage.MemoryStore.get", side_effect=RuntimeError):
+        array[:] = 1
+
 
 @pytest.mark.skipif(
     Version(numcodecs.__version__) < Version("0.15.1"),
diff --git a/tests/test_indexing.py b/tests/test_indexing.py
index 932c32f1..30d0d75f 100644
--- a/tests/test_indexing.py
+++ b/tests/test_indexing.py
@@ -19,7 +19,6 @@ from zarr.core.indexing import (
     OrthogonalSelection,
     Selection,
     _iter_grid,
-    is_total_slice,
     make_slice_selection,
     normalize_integer_selection,
     oindex,
@@ -1954,8 +1953,3 @@ def test_vectorized_indexing_incompatible_shape(store) -> None:
     )
     with pytest.raises(ValueError, match="Attempting to set"):
         arr[np.array([1, 2]), np.array([1, 2])] = np.array([[-1, -2], [-3, -4]])
-
-
-def test_is_total_slice():
-    assert is_total_slice((0, slice(4, 6)), (1, 2))
-    assert is_total_slice((slice(0, 1, None), slice(4, 6)), (1, 2))
diff --git a/tests/test_properties.py b/tests/test_properties.py
index 2e60c951..cfa6a706 100644
--- a/tests/test_properties.py
+++ b/tests/test_properties.py
@@ -8,7 +8,13 @@ import hypothesis.extra.numpy as npst
 import hypothesis.strategies as st
 from hypothesis import given
 
-from zarr.testing.strategies import arrays, basic_indices, numpy_arrays, zarr_formats
+from zarr.testing.strategies import (
+    arrays,
+    basic_indices,
+    numpy_arrays,
+    orthogonal_indices,
+    zarr_formats,
+)
 
 
 @given(data=st.data(), zarr_format=zarr_formats)
@@ -32,6 +38,17 @@ def test_basic_indexing(data: st.DataObject) -> None:
     assert_array_equal(nparray, zarray[:])
 
 
+@given(data=st.data())
+def test_oindex(data: st.DataObject) -> None:
+    # integer_array_indices can't handle 0-size dimensions.
+    zarray = data.draw(arrays(shapes=npst.array_shapes(max_dims=4, min_side=1)))
+    nparray = zarray[:]
+
+    zindexer, npindexer = data.draw(orthogonal_indices(shape=nparray.shape))
+    actual = zarray.oindex[zindexer]
+    assert_array_equal(nparray[npindexer], actual)
+
+
 @given(data=st.data())
 def test_vindex(data: st.DataObject) -> None:
     # integer_array_indices can't handle 0-size dimensions.