Repository: modin-project/modin (python)

Issue:
BUG: incorrect iloc behavior in modin when assigning index values based on row indices
### Modin version checks

- [X] I have checked that this issue has not already been reported.

- [X] I have confirmed this bug exists on the latest released version of Modin.

- [ ] I have confirmed this bug exists on the main branch of Modin. (In order to do this you can follow [this guide](https://modin.readthedocs.io/en/stable/getting_started/installation.html#installing-from-the-github-main-branch).)


### Reproducible Example

```python
import pandas as pd
import modin.pandas as mpd

dict1 = {
    'index_test': [-1, -1, -1]
}
df1 = pd.DataFrame(dict1)
mdf1 = mpd.DataFrame(dict1)

row_indices = [2, 0]
df1.iloc[row_indices, 0] = df1.iloc[row_indices].index
mdf1.iloc[row_indices, 0] = mdf1.iloc[row_indices].index

print(df1)  # as expected: 0, -1, 2
print('-------------')
print(mdf1)  # NOT as expected: 2, -1, 0

#    index_test
# 0           0
# 1          -1
# 2           2
# -------------
#    index_test
# 0           2
# 1          -1
# 2           0
```


### Issue Description

When assigning values using iloc in modin, the behavior deviates from the expected behavior seen with pandas. Specifically, assigning index values to a subset of rows works correctly in pandas, but modin assigns values in wrong order.

### Expected Behavior

This issue occurs consistently when trying to assign values based on row indices using iloc in modin. The expected behavior is for modin to mirror pandas behavior, but instead, the values are assigned in a different order.

expected output produced with pandas:

```
    index_test
 0           0
 1          -1
 2           2
```


actual output produced with modin:

```
    index_test
 0           2
 1          -1
 2           0
```

### Error Logs

_No response_

### Installed Versions

<details>

PyDev console: using IPython 8.23.0
INSTALLED VERSIONS
------------------
commit                : 3e951a63084a9cbfd5e73f6f36653ee12d2a2bfa
python                : 3.11.8
python-bits           : 64
OS                    : Windows
OS-release            : 10
Version               : 10.0.22631
machine               : AMD64
processor             : Intel64 Family 6 Model 186 Stepping 2, GenuineIntel
byteorder             : little
LC_ALL                : None
LANG                  : None
LOCALE                : English_Austria.1252
Modin dependencies
------------------
modin                 : 0.32.0
ray                   : 2.20.0
dask                  : 2024.5.2
distributed           : 2024.5.2
pandas dependencies
-------------------
pandas                : 2.2.3
numpy                 : 1.26.4
pytz                  : 2024.1
dateutil              : 2.9.0.post0
pip                   : 24.2
Cython                : 3.0.10
sphinx                : None
IPython               : 8.23.0
adbc-driver-postgresql: None
adbc-driver-sqlite    : None
bs4                   : 4.12.3
blosc                 : None
bottleneck            : 1.3.7
dataframe-api-compat  : None
fastparquet           : None
fsspec                : 2023.10.0
html5lib              : None
hypothesis            : None
gcsfs                 : None
jinja2                : 3.1.3
lxml.etree            : None
matplotlib            : 3.8.2
numba                 : None
numexpr               : 2.8.7
odfpy                 : None
openpyxl              : None
pandas_gbq            : None
psycopg2              : None
pymysql               : None
pyarrow               : 14.0.2
pyreadstat            : None
pytest                : 8.1.1
python-calamine       : None
pyxlsb                : None
s3fs                  : None
scipy                 : 1.14.0
sqlalchemy            : 2.0.25
tables                : 3.9.2
tabulate              : 0.9.0
xarray                : None
xlrd                  : None
xlsxwriter            : None
zstandard             : 0.23.0
tzdata                : 2024.1
qtpy                  : 2.4.1
pyqt5                 : None

</details>


Pull request description:
FIX-#7405: Internally sort indices for loc/iloc set
<!--
Thank you for your contribution!
Please review the contributing docs: https://modin.readthedocs.io/en/latest/development/contributing.html
if you have questions about contributing.
-->

## What do these changes do?

Refactors internal logic for the PandasQueryCompiler `write_items` function to sort the items array and row/column indexers before passing them to partitions. This fixes the linked bug, which occurs when the internal DataFrame representation [sorts](https://github.com/modin-project/modin/blob/35275e1c2d8a420a0fd16e3fca6ae5383fbdbc55/modin/core/dataframe/pandas/dataframe/dataframe.py#L1936) indices before performing the write operation.

I considered removing the sort in PandasDataFrame, but this created difficulties in determining which data went to which partitions. Furthermore, it was difficult to cleanly ensure that both the items and index respected the sort order at that layer.

This change will cause overhead in some indexing cases, though this should be minimal under the assumption that `len(items) << len(df)`. The code also includes logic to avoid sorting the items array if the index is an item like a range/RangeIndex/slice with `step > 0` that is known to already be sorted.

- [x] first commit message and PR title follow format outlined [here](https://modin.readthedocs.io/en/latest/development/contributing.html#commit-message-formatting)
  > **_NOTE:_**  If you edit the PR title to match this format, you need to add another commit (even if it's empty) or amend your last commit for the CI job that checks the PR title to pick up the new PR title.
- [x] passes `flake8 modin/ asv_bench/benchmarks scripts/doc_checker.py`
- [x] passes `black --check modin/ asv_bench/benchmarks scripts/doc_checker.py`
- [x] signed commit with `git commit -s` <!-- you can amend your commit with a signature via `git commit -amend -s` -->
- [x] Resolves #7405 <!-- issue must be created for each patch -->
- [x] tests added and passing
- [x] module layout described at `docs/development/architecture.rst` is up-to-date <!-- if you have added, renamed or removed files or directories please update the documentation accordingly -->


Patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index bc5ec0a1..7f929205 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -547,41 +547,61 @@ jobs:
     # The "sanity" tests run on each pull request to test that a subset of the
     # full tests work with the slower engines (ray, dask, and unidist-MPI).
     needs: [lint-flake8, execution-filter, python-filter]
-    if: github.event_name == 'pull_request'
+    # If we don't need to run any sanity tests, the job matrix that we generate
+    # here gives a single job with all the matrix fields empty (that is, os,
+    # execution, etc. are not set, so we treat them as "").
+    # so, if the matrix is going to be empty, we need to skip this job
+    # completely. This bizarre behavior is not in the official documentation,
+    # of GitHub actions matrices, but someone does mention it here:
+    # https://stackoverflow.com/a/77118991
+    if: |
+      github.event_name == 'pull_request' &&
+      (
+        needs.execution-filter.outputs.ray != 'true' ||
+        needs.execution-filter.outputs.dask != 'true' ||
+        needs.execution-filter.outputs.unidist != 'true'
+      )
     strategy:
       matrix:
         os:
           - ubuntu
           - windows
         python-version: [ "${{ needs.python-filter.outputs.python-version }}" ]
-        execution:
-          - name: ray
-            shell-ex: "python -m pytest"
-            # If we're going to run all ray tests because we've detected a
-            # change to the ray engine, we don't need to run these sanity tests
-            # on ray.
-            if: needs.execution-filter.outputs.ray != 'true'
-          - name: dask
-            shell-ex: "python -m pytest"
-            # If we're going to run all dask tests because we've detected a
-            # change to the dask engine, we don't need to run these sanity tests
-            # on dask.
-            if: needs.execution-filter.outputs.dask != 'true'
-          - name: unidist
-            shell-ex: "mpiexec -n 1 -genv AWS_ACCESS_KEY_ID foobar_key -genv AWS_SECRET_ACCESS_KEY foobar_secret python -m pytest"
-            # If we're going to run all unidist tests because we've detected a
-            # change to the unidist engine, we don't need to run these sanity tests
-            # on unidist.
-            if: needs.execution-filter.outputs.unidist != 'true'
+        running-all-ray-tests: [ "${{ needs.execution-filter.outputs.ray }}" ]
+        running-all-dask-tests: [ "${{needs.execution-filter.outputs.dask}}" ]
+        running-all-unidist-tests: [ "${{needs.execution-filter.outputs.unidist}}" ]
+        execution: [ray, dask, unidist]
+        # If we're going to run all ray tests because we've detected a
+        # change to the ray engine, we don't need to run these sanity tests
+        # on ray. Likewise for dask and unidist.
+        exclude:
+          - running-all-ray-tests: 'true'
+            execution: ray
+          - running-all-dask-tests: 'true'
+            execution: dask
+          - running-all-unidist-tests: 'true'
+            execution: unidist
     runs-on: ${{ matrix.os }}-latest
     defaults:
       run:
         shell: bash -l {0}
     env:
-      MODIN_ENGINE: ${{ matrix.execution.name }}
+      MODIN_ENGINE: ${{ matrix.execution }}
       UNIDIST_BACKEND: "mpi"
-      PARALLEL: ${{ matrix.execution.name != 'unidist' && matrix.os != 'windows' && '-n 2' || '' }}
-    name: test-${{ matrix.os }}-sanity (engine ${{ matrix.execution.name }}, python ${{matrix.python-version}})
+      PARALLEL: ${{ matrix.execution != 'unidist' && matrix.os != 'windows' && '-n 2' || '' }}
+      PYTEST_COMMAND: >-
+        ${{
+          (
+            (matrix.execution == 'ray' || matrix.execution == 'dask') &&
+            'python -m pytest'
+          ) ||
+          (
+            matrix.execution == 'unidist' &&
+            'mpiexec -n 1 -genv AWS_ACCESS_KEY_ID foobar_key -genv AWS_SECRET_ACCESS_KEY foobar_secret python -m pytest'
+          ) ||
+          'UNKNOWN_PYTEST_COMMAND'
+        }}
+    name: test-${{ matrix.os }}-sanity (engine ${{ matrix.execution }}, python ${{matrix.python-version}})
     services:
       moto:
         image: ${{ matrix.os != 'windows' && 'motoserver/moto:5.0.13' || '' }}
@@ -594,18 +614,18 @@ jobs:
       - uses: actions/checkout@v4
       - uses: ./.github/actions/mamba-env
         with:
-          environment-file: ${{ matrix.os == 'ubuntu' && matrix.execution.name == 'unidist' && 'requirements/env_unidist_linux.yml' || matrix.os == 'windows' && matrix.execution.name == 'unidist' && 'requirements/env_unidist_win.yml' || 'environment-dev.yml' }}
-          activate-environment: ${{ matrix.execution.name == 'unidist' && 'modin_on_unidist' || 'modin' }}
+          environment-file: ${{ matrix.os == 'ubuntu' && matrix.execution == 'unidist' && 'requirements/env_unidist_linux.yml' || matrix.os == 'windows' && matrix.execution == 'unidist' && 'requirements/env_unidist_win.yml' || 'environment-dev.yml' }}
+          activate-environment: ${{ matrix.execution == 'unidist' && 'modin_on_unidist' || 'modin' }}
           python-version: ${{matrix.python-version}}
       - name: Install HDF5
         run: sudo apt update && sudo apt install -y libhdf5-dev
         if: matrix.os != 'windows'
       - name: Limit ray memory
         run: echo "MODIN_MEMORY=1000000000" >> $GITHUB_ENV
-        if: matrix.os != 'windows' && matrix.execution.name == 'ray'
+        if: matrix.os != 'windows' && matrix.execution == 'ray'
       - name: Tell Modin to use existing ray cluster
         run: echo "MODIN_RAY_CLUSTER=True" >> $GITHUB_ENV
-        if: matrix.os == 'windows' && matrix.execution.name == 'ray'
+        if: matrix.os == 'windows' && matrix.execution == 'ray'
       - name: Start local ray cluster
         # Try a few times to start ray to work around
         # https://github.com/modin-project/modin/issues/4562
@@ -614,71 +634,71 @@ jobs:
           timeout_minutes: 5
           max_attempts: 5
           command: ray start --head --port=6379 --object-store-memory=1000000000
-        if: matrix.os == 'windows' && matrix.execution.name == 'ray'
-      - run: MODIN_BENCHMARK_MODE=True ${{ matrix.execution.shell-ex }} modin/tests/pandas/internals/test_benchmark_mode.py
-      - run: ${{ matrix.execution.shell-ex }} $PARALLEL modin/tests/test_partition_api.py
-      - run: ${{ matrix.execution.shell-ex }} modin/tests/pandas/extensions
+        if: matrix.os == 'windows' && matrix.execution == 'ray'
+      - run: MODIN_BENCHMARK_MODE=True $PYTEST_COMMAND modin/tests/pandas/internals/test_benchmark_mode.py
+      - run: $PYTEST_COMMAND $PARALLEL modin/tests/test_partition_api.py
+      - run: $PYTEST_COMMAND modin/tests/pandas/extensions
       - name: xgboost tests
         run: |
           # TODO(https://github.com/modin-project/modin/issues/5194): Uncap xgboost
           # when we use collective instead of rabit.
           mamba install "xgboost>=1.7.1,<2.0.0" scikit-learn -c conda-forge
-          ${{ matrix.execution.shell-ex }} $PARALLEL \
+          $PYTEST_COMMAND $PARALLEL \
                   modin/tests/experimental/xgboost/test_default.py \
                   modin/tests/experimental/xgboost/test_xgboost.py \
                   modin/tests/experimental/xgboost/test_dmatrix.py
         if: matrix.os != 'windows' && needs.execution-filter.outputs.experimental == 'true'
-      - run: ${{ matrix.execution.shell-ex }} $PARALLEL modin/tests/experimental/test_pipeline.py
-        if: matrix.os != 'windows' && matrix.execution.name != 'unidist' && needs.execution-filter.outputs.experimental == 'true'
+      - run: $PYTEST_COMMAND $PARALLEL modin/tests/experimental/test_pipeline.py
+        if: matrix.os != 'windows' && matrix.execution != 'unidist' && needs.execution-filter.outputs.experimental == 'true'
       - name: "test DF: binary, default, iter"
         run: |
-          ${{ matrix.execution.shell-ex }} $PARALLEL \
+          $PYTEST_COMMAND $PARALLEL \
                   modin/tests/pandas/dataframe/test_binary.py \
                   modin/tests/pandas/dataframe/test_default.py \
                   modin/tests/pandas/dataframe/test_iter.py
         if: matrix.os != 'windows'
       - name: "test DF: reduce, udf, window, pickle"
         run: |
-          ${{ matrix.execution.shell-ex }} $PARALLEL \
+          $PYTEST_COMMAND $PARALLEL \
                   modin/tests/pandas/dataframe/test_reduce.py \
                   modin/tests/pandas/dataframe/test_udf.py \
                   modin/tests/pandas/dataframe/test_window.py \
                   modin/tests/pandas/dataframe/test_pickle.py
         if: matrix.os != 'windows'
-      - run: ${{ matrix.execution.shell-ex }} modin/tests/pandas/test_series.py
-        if: matrix.execution.name == 'ray'
-      - run: ${{ matrix.execution.shell-ex }} -m "not exclude_in_sanity" modin/tests/pandas/test_series.py
-        if: matrix.execution.name != 'ray'
-      - run: ${{ matrix.execution.shell-ex }} modin/tests/pandas/dataframe/test_map_metadata.py
-        if: matrix.execution.name == 'ray'
-      - run: ${{ matrix.execution.shell-ex }} -m "not exclude_in_sanity" modin/tests/pandas/dataframe/test_map_metadata.py
-        if: matrix.execution.name != 'ray'
+      - run: $PYTEST_COMMAND modin/tests/pandas/test_series.py
+        if: matrix.execution == 'ray'
+      - run: $PYTEST_COMMAND -m "not exclude_in_sanity" modin/tests/pandas/test_series.py
+        if: matrix.execution != 'ray'
+      - run: $PYTEST_COMMAND modin/tests/pandas/dataframe/test_map_metadata.py
+        if: matrix.execution == 'ray'
+      - run: $PYTEST_COMMAND -m "not exclude_in_sanity" modin/tests/pandas/dataframe/test_map_metadata.py
+        if: matrix.execution != 'ray'
       - name: "test rolling, expanding, reshape, general, concat"
         run: |
-          ${{ matrix.execution.shell-ex }} $PARALLEL \
+          $PYTEST_COMMAND $PARALLEL \
                   modin/tests/pandas/test_rolling.py \
                   modin/tests/pandas/test_expanding.py \
                   modin/tests/pandas/test_reshape.py \
                   modin/tests/pandas/test_general.py \
                   modin/tests/pandas/test_concat.py
         if: matrix.os != 'windows'
-      - run: ${{ matrix.execution.shell-ex }} $PARALLEL modin/tests/numpy
-      - run: ${{ matrix.execution.shell-ex }} -m "not exclude_in_sanity" modin/tests/pandas/test_io.py --verbose
-        if: matrix.execution.name != 'unidist'
+      - run: $PYTEST_COMMAND $PARALLEL modin/tests/numpy
+      - run: $PYTEST_COMMAND -m "not exclude_in_sanity" modin/tests/pandas/test_io.py --verbose
+        if: matrix.execution != 'unidist'
       - uses: nick-fields/retry@v3
         # to avoid issues with non-stable `to_csv` tests for unidist on MPI backend.
         # for details see: https://github.com/modin-project/modin/pull/6776
         with:
           timeout_minutes: 15
           max_attempts: 3
-          command: conda run --no-capture-output -n modin_on_unidist ${{ matrix.execution.shell-ex }} -m "not exclude_in_sanity" modin/tests/pandas/test_io.py --verbose
-        if: matrix.execution.name == 'unidist'
-      - run: ${{ matrix.execution.shell-ex }} modin/tests/experimental/test_io_exp.py
-      - run: ${{ matrix.execution.shell-ex }} $PARALLEL modin/tests/interchange/dataframe_protocol/test_general.py
-      - run: ${{ matrix.execution.shell-ex }} $PARALLEL modin/tests/interchange/dataframe_protocol/pandas/test_protocol.py
+          command: conda run --no-capture-output -n modin_on_unidist $PYTEST_COMMAND -m "not exclude_in_sanity" modin/tests/pandas/test_io.py --verbose
+        if: matrix.execution == 'unidist'
+      - run: $PYTEST_COMMAND modin/tests/experimental/test_io_exp.py
+      - run: $PYTEST_COMMAND $PARALLEL modin/tests/interchange/dataframe_protocol/test_general.py
+      - run: $PYTEST_COMMAND $PARALLEL modin/tests/interchange/dataframe_protocol/pandas/test_protocol.py
       - name: Stop local ray cluster
         run: ray stop
-        if: matrix.os == 'windows' && matrix.execution.name == 'ray'
+        if: matrix.os == 'windows' && matrix.execution == 'ray'
       - uses: ./.github/actions/upload-coverage
 
   test-experimental:
diff --git a/modin/core/storage_formats/base/query_compiler.py b/modin/core/storage_formats/base/query_compiler.py
index 79818586..1410b148 100644
--- a/modin/core/storage_formats/base/query_compiler.py
+++ b/modin/core/storage_formats/base/query_compiler.py
@@ -4627,12 +4627,13 @@ class BaseQueryCompiler(
             return df
 
         if not is_scalar(item):
-            broadcasted_item, _ = broadcast_item(
+            broadcasted_item, _, _, _ = broadcast_item(
                 self,
                 row_numeric_index,
                 col_numeric_index,
                 item,
                 need_columns_reindex=need_columns_reindex,
+                sort_lookups_and_item=False,
             )
         else:
             broadcasted_item = item
diff --git a/modin/core/storage_formats/pandas/query_compiler.py b/modin/core/storage_formats/pandas/query_compiler.py
index 449eeecd..b6f3c114 100644
--- a/modin/core/storage_formats/pandas/query_compiler.py
+++ b/modin/core/storage_formats/pandas/query_compiler.py
@@ -4704,7 +4704,12 @@ class PandasQueryCompiler(BaseQueryCompiler, QueryCompilerCaster):
             return partition
 
         if not is_scalar(item):
-            broadcasted_item, broadcasted_dtypes = broadcast_item(
+            (
+                broadcasted_item,
+                broadcasted_dtypes,
+                row_numeric_index,
+                col_numeric_index,
+            ) = broadcast_item(
                 self,
                 row_numeric_index,
                 col_numeric_index,
diff --git a/modin/pandas/utils.py b/modin/pandas/utils.py
index ef7f199b..5cd061ca 100644
--- a/modin/pandas/utils.py
+++ b/modin/pandas/utils.py
@@ -15,7 +15,7 @@
 
 from __future__ import annotations
 
-from typing import Iterator, Optional, Tuple
+from typing import Any, Iterator, Optional, Tuple
 
 import numpy as np
 import pandas
@@ -221,7 +221,8 @@ def broadcast_item(
     row_lookup,
     col_lookup,
     item,
-    need_columns_reindex=True,
+    need_columns_reindex: bool = True,
+    sort_lookups_and_item: bool = True,
 ):
     """
     Use NumPy to broadcast or reshape item with reindexing.
@@ -239,12 +240,17 @@ def broadcast_item(
     need_columns_reindex : bool, default: True
         In the case of assigning columns to a dataframe (broadcasting is
         part of the flow), reindexing is not needed.
+    sort_lookups_and_item : bool, default: True
+        If set, sort the lookups in ascending order and the item to match. This is necessary to
+        ensure writes across multiple partitions are ordered correctly when the lookups are unsorted.
 
     Returns
     -------
-    (np.ndarray, Optional[Series])
+    (np.ndarray, Optional[Series], array-like, array-like)
         * np.ndarray - `item` after it was broadcasted to `to_shape`.
         * Series - item's dtypes.
+        * array-like - sorted version of `row_lookup` (may or may not be the same reference)
+        * array-like - sorted version of `col_lookup` (may or may not be the same reference)
 
     Raises
     ------
@@ -259,6 +265,7 @@ def broadcast_item(
     """
     # It is valid to pass a DataFrame or Series to __setitem__ that is larger than
     # the target the user is trying to overwrite.
+
     from .dataframe import DataFrame
     from .series import Series
 
@@ -295,12 +302,61 @@ def broadcast_item(
         # Cast to numpy drop information about heterogeneous types (cast to common)
         # TODO: we shouldn't do that, maybe there should be the if branch
         item = np.array(item)
+
+        def sort_index(lookup: Any) -> np.ndarray:
+            """
+            Return the argsort and sorted version of the lookup index.
+
+            Values in the lookup are guaranteed by the indexing frontend to be non-negative.
+
+            The sort operation must be stable to ensure proper behavior for iloc set, which
+            will use the last item encountered if two items share an index.
+            """
+            if isinstance(lookup, slice):
+                # Special case for if a descending slice is passed
+                # Directly calling np.array(slice(...)) does not work
+                lookup = range(lookup.start or 0, lookup.stop or 0, lookup.step or 0)
+            argsort_index = np.argsort(lookup, kind="stable")
+            return argsort_index, np.array(lookup)[argsort_index]
+
+        def should_avoid_sort(lookup: Any) -> bool:
+            return (
+                not sort_lookups_and_item
+                or (
+                    isinstance(lookup, (range, pandas.RangeIndex, slice))
+                    and lookup.step is not None
+                    and lookup.step > 0
+                )
+                or (isinstance(lookup, slice) and lookup == slice(None))
+            )
+
+        # Fast path to avoid sorting for range/RangeIndex, which are already sorted, or the empty slice
+        avoid_row_lookup_sort = should_avoid_sort(row_lookup)
+        avoid_col_lookup_sort = should_avoid_sort(col_lookup)
+        # Sort both the columns and rows if necessary
+        if item.ndim >= 2:
+            if avoid_row_lookup_sort:
+                if not avoid_col_lookup_sort:
+                    col_argsort, col_lookup = sort_index(col_lookup)
+                    item = item[:, col_argsort]
+            elif avoid_col_lookup_sort:
+                row_argsort, row_lookup = sort_index(row_lookup)
+                item = item[row_argsort, :]
+            else:
+                row_argsort, row_lookup = sort_index(row_lookup)
+                col_argsort, col_lookup = sort_index(col_lookup)
+                # Use np.ix_ to handle broadcasting errors
+                item = item[np.ix_(row_argsort, col_argsort)]
+        elif not avoid_row_lookup_sort:
+            # Item is 1D, so only sort row indexer
+            row_argsort, row_lookup = sort_index(row_lookup)
+            item = item[row_argsort]
         if dtypes is None:
             dtypes = pandas.Series([item.dtype] * len(col_lookup))
         if np.prod(to_shape) == np.prod(item.shape):
-            return item.reshape(to_shape), dtypes
+            return item.reshape(to_shape), dtypes, row_lookup, col_lookup
         else:
-            return np.broadcast_to(item, to_shape), dtypes
+            return np.broadcast_to(item, to_shape), dtypes, row_lookup, col_lookup
     except ValueError:
         from_shape = np.array(item).shape
         raise ValueError(


Test patch:
diff --git a/modin/tests/pandas/dataframe/test_indexing.py b/modin/tests/pandas/dataframe/test_indexing.py
index 52603343..577c9f97 100644
--- a/modin/tests/pandas/dataframe/test_indexing.py
+++ b/modin/tests/pandas/dataframe/test_indexing.py
@@ -2693,3 +2693,77 @@ def test_index_of_empty_frame():
 
     assert md_res.empty and pd_res.empty
     df_equals(md_res.index, pd_res.index)
+
+
+# https://github.com/modin-project/modin/issues/7405
+@pytest.mark.parametrize("indexer", ["loc", "iloc"])
+def test_loc_and_iloc_set_order(indexer):
+    rng = np.random.default_rng(seed=0)
+    is_loc = indexer == "loc"
+    data = {"col": rng.integers(0, 100, size=100)}
+    set_count = 20
+    # Pick a bunch of unsorted row indices; may contain repeat values.
+    row_indexer = rng.integers(0, 100, size=set_count)
+    col_indexer = "col" if is_loc else 0
+    set_data = range(100, 100 + set_count)
+    md_df, pd_df = create_test_dfs(data)
+
+    def get_helper(df):
+        if is_loc:
+            return df.loc[row_indexer, col_indexer]
+        else:
+            return df.iloc[row_indexer, col_indexer]
+
+    # First, ensure loc/iloc read succeeds.
+    eval_general(md_df, pd_df, get_helper)
+
+    def set_helper(df):
+        if is_loc:
+            df.loc[row_indexer, col_indexer] = set_data
+        else:
+            df.iloc[row_indexer, col_indexer] = set_data
+
+    # Second, check results of loc/iloc write.
+    eval_general(
+        md_df,
+        pd_df,
+        set_helper,
+        __inplace__=True,
+    )
+    # Finally, check the result of a loc/iloc read again.
+    eval_general(md_df, pd_df, get_helper)
+
+
+def test_iloc_set_negative_index():
+    rng = np.random.default_rng(seed=0)
+    row_count = 50
+    col_count = 80
+    data = {f"col_{i}": rng.integers(0, 100, size=row_count) for i in range(col_count)}
+    row_set_count = 20
+    col_set_count = 30
+    # Pick a bunch of unsorted row indices; may contain repeat values and negative numbers.
+    row_indexer = rng.integers(-row_count, row_count, size=row_set_count)
+    col_indexer = rng.integers(-col_count, col_count, size=col_set_count)
+    set_data = np.reshape(
+        range(100, 100 + row_set_count * col_set_count), (row_set_count, col_set_count)
+    )
+    md_df, pd_df = create_test_dfs(data)
+
+    def get_helper(df):
+        return df.iloc[row_indexer, col_indexer]
+
+    # First, ensure loc/iloc read succeeds.
+    eval_general(md_df, pd_df, get_helper)
+
+    def set_helper(df):
+        df.iloc[row_indexer, col_indexer] = set_data
+
+    # Second, check results of loc/iloc write.
+    eval_general(
+        md_df,
+        pd_df,
+        set_helper,
+        __inplace__=True,
+    )
+    # Finally, check the result of a loc/iloc read again.
+    eval_general(md_df, pd_df, get_helper)