dhruv-techdev opened a new issue, #69204:
URL: https://github.com/apache/airflow/issues/69204

   ### Under which category would you file this issue?
   
   Airflow Core
   
   ### Apache Airflow version
   
   main (3.4.0.dev0), reproduced at commit a3f51fcd9c
   
   ### What happened and how to reproduce it?
   
   ## Issue Description
   
   The mapped XCom sequence-slice endpoint in the Execution API returns 
incorrect results for **crossed-bound slices**—cases where the effective `stop` 
index comes before the effective `start` index. According to Python slicing 
semantics, these slices should always evaluate to an empty list.
   
   **Endpoint**
   
   ```text
   GET 
/execution/xcoms/{dag_id}/{run_id}/{task_id}/{key}/slice?start=&stop=&step=
   ```
   
   **Handler**
   
   ```text
   airflow-core/src/airflow/api_fastapi/execution_api/routes/xcoms.py
   get_mapped_xcom_by_slice()
   ```
   
   The handler is intended to reproduce Python list slicing over stored XCom 
rows, but it translates the slice into SQLAlchemy's `Query.slice(a, b)`, which 
generates:
   
   ```sql
   OFFSET a
   LIMIT (b - a)
   ```
   
   Unlike Python slicing, SQLAlchemy does **not** clamp negative limits. When 
`b < a`, the generated `LIMIT` becomes negative, resulting in backend-dependent 
behavior.
   
   ### Backend behavior
   
   - **SQLite**
     - A negative `LIMIT` is treated as **"no limit"**.
     - The query silently returns rows from `OFFSET` onward.
     - **Result:** incorrect data is returned.
   
   - **PostgreSQL / MySQL**
     - Negative `LIMIT` values are rejected.
     - **Result:** the request fails with a server error.
   
   The endpoint should instead return an empty result (`[]`) for all 
crossed-bound slices, matching Python semantics.
   
   ---
   
   ## Examples
   
   Assume six stored XCom values:
   
   ```text
   ["v0", "v1", "v2", "v3", "v4", "v5"]
   ```
   
   ### Case 1
   
   Request:
   
   ```text
   start=2&stop=-3&step=-1
   ```
   
   Python:
   
   ```python
   list(range(6))[2:-3:-1]
   # []
   ```
   
   Current implementation:
   
   ```text
   stop -> 3
   slice(4, 3)
   OFFSET 4
   LIMIT -1
   ```
   
   SQLite returns:
   
   ```text
   ["v5", "v4"]
   ```
   
   Expected:
   
   ```text
   []
   ```
   
   ---
   
   ### Case 2
   
   Request:
   
   ```text
   start=4&stop=2
   ```
   
   Python:
   
   ```python
   list(range(6))[4:2]
   # []
   ```
   
   Current implementation:
   
   ```text
   slice(4, 2)
   OFFSET 4
   LIMIT -2
   ```
   
   SQLite returns:
   
   ```text
   ["v4", "v5"]
   ```
   
   Expected:
   
   ```text
   []
   ```
   
   This second case is particularly interesting because it does **not** involve 
mixed-sign normalization or a `COUNT` query. It simply occurs when `stop < 
start`.
   
   ---
   
   ## Existing Test Coverage
   
   The current test suite does not include any crossed-bound slice scenarios, 
so the negative-`LIMIT` path is never exercised.
   
   ---
   
   ## Steps to Reproduce
   
   The underlying issue is in the slice-to-SQL translation and can be 
reproduced without Airflow using only the Python standard library.
   
   ```python
   import sqlite3
   
   con = sqlite3.connect(":memory:")
   con.execute("CREATE TABLE x(map_index INTEGER)")
   con.executemany("INSERT INTO x VALUES (?)", [(i,) for i in range(6)])
   
   def q(sql):
       return [r[0] for r in con.execute(sql)]
   
   # SQLAlchemy Query.slice(a, b)
   # => OFFSET a
   # => LIMIT (b - a)
   
   # Case A: start=2, stop=-3, step=-1
   # normalized to slice(4, 3)
   print(
       "A SQLite:",
       q("SELECT map_index FROM x ORDER BY map_index ASC LIMIT -1 OFFSET 4"),
   )
   # [4, 5]
   
   # Case B: start=4, stop=2
   # translated to slice(4, 2)
   print(
       "B SQLite:",
       q("SELECT map_index FROM x ORDER BY map_index ASC LIMIT -2 OFFSET 4"),
   )
   # [4, 5]
   
   print("Python A:", list(range(6))[2:-3:-1])
   # []
   
   print("Python B:", list(range(6))[4:2])
   # []
   ```
   
   Output:
   
   ```text
   A SQLite: [4, 5]
   B SQLite: [4, 5]
   Python A: []
   Python B: []
   ```
   
   The same behavior can be reproduced end-to-end by storing at least six 
mapped XCom values and requesting either:
   
   ```text
   start=4&stop=2
   ```
   
   or
   
   ```text
   start=2&stop=-3&step=-1
   ```
   
   On SQLite the endpoint returns incorrect rows instead of an empty list.
   
   On PostgreSQL/MySQL the request fails due to the negative `LIMIT`.
   
   ---
   
   ## Root Cause
   
   The crossed-bound branches currently call:
   
   ```python
   query.slice(start, stop)
   ```
   
   and
   
   ```python
   query.slice(stop + 1, start + 1)
   ```
   
   assuming that `Query.slice()` behaves like Python slicing.
   
   However, SQLAlchemy directly translates the bounds into:
   
   ```sql
   OFFSET a
   LIMIT (b - a)
   ```
   
   without clamping negative limits.
   
   As a result, crossed-bound slices generate a negative SQL `LIMIT`, producing 
incorrect results on SQLite and backend errors on PostgreSQL/MySQL.
   
   The affected code paths are currently around:
   
   ```text
   airflow-core/src/airflow/api_fastapi/execution_api/routes/xcoms.py
   ```
   
   specifically the branches that call:
   
   ```python
   query.slice(start, stop)
   ```
   
   and
   
   ```python
   query.slice(stop + 1, start + 1)
   ```
   ## Proposed Fix
   
   The issue only affects the code paths that call `Query.slice(low, high)`. 
These are the only branches where the computed bounds can cross, causing 
SQLAlchemy to generate a negative `LIMIT`.
   
   The branches using `.limit(...)` and `.offset(...)` are already safe because 
their arguments are guaranteed to be non-negative by their surrounding 
conditions.
   
   A small helper could centralize the guard and preserve Python slicing 
semantics before calling `Query.slice()`:
   
   ```python
   def _sliced_or_empty(query, low: int, high: int):
       """Apply ``.slice(low, high)`` but return no rows when the bounds are 
crossed.
   
       SQLAlchemy's ``Query.slice(low, high)`` compiles to:
   
           OFFSET low
           LIMIT (high - low)
   
       It does not clamp a negative LIMIT. A crossed slice (``high <= low``),
       which Python evaluates as an empty sequence, therefore becomes backend-
       dependent:
   
       * SQLite interprets a negative LIMIT as "no limit", returning incorrect 
rows.
       * PostgreSQL/MySQL reject the query because LIMIT cannot be negative.
       """
       if high <= low:
           return query.limit(0)
   
       return query.slice(low, high)
   ```
   
   The four `Query.slice()` call sites in `get_mapped_xcom_by_slice()` could 
then become:
   
   ```python
   # start >= 0, stop given
   
   # step >= 0
   query = _sliced_or_empty(query, start, stop)
   
   # step < 0
   query = _sliced_or_empty(query, stop + 1, start + 1)
   
   
   # start < 0, stop given
   
   # step > 0
   query = _sliced_or_empty(query, -1 - start, -1 - stop)
   
   # step < 0
   query = _sliced_or_empty(query, -stop, -start)
   ```
   
   ### Why `high <= low`?
   
   By the time these branches execute:
   
   - the query ordering has already been normalized (ascending or descending),
   - the direction of iteration is handled separately via the final 
`values[::step]`.
   
   Each `Query.slice(low, high)` therefore always represents an **ascending 
window** over the already-normalized ordering.
   
   A crossed window (`high <= low`) is exactly the case that Python evaluates 
as:
   
   ```python
   []
   ```
   
   Returning:
   
   ```python
   query.limit(0)
   ```
   
   preserves Python semantics while producing consistent SQL across SQLite, 
PostgreSQL, and MySQL.
   
   ### Why only guard `Query.slice()`?
   
   The remaining branches use expressions such as:
   
   ```python
   query.limit(stop)
   query.limit(-stop - 1)
   query.limit(start + 1)
   query.limit(-start)
   ```
   
   In every case, the surrounding branch conditions already guarantee these 
values are non-negative.
   
   Only `Query.slice(low, high)` combines two independently computed bounds, 
making it possible for the generated SQL to contain:
   
   ```sql
   LIMIT (high - low)
   ```
   
   where `(high - low) < 0`.
   
   ---
   
   ## Suggested Tests
   
   Add regression cases to:
   
   ```text
   airflow-core/tests/unit/api_fastapi/execution_api/versions/head/test_xcoms.py
   ```
   
   specifically to the slice endpoint test suite.
   
   Suggested parameterized cases include:
   
   - `start=4, stop=2`
     - Simple crossed positive bounds.
     - No mixed-sign normalization.
     - No `COUNT` query.
   
   - `start=2, stop=-3, step=-1`
     - Exercises the negative-stop normalization path.
   
   - Descending equivalents where `start < 0`
     - Covers the branches using:
       - `slice(-1 - start, -1 - stop)`
       - `slice(-stop, -start)`
   
   Each of these cases should currently fail on the existing implementation:
   
   - **SQLite:** returns incorrect rows instead of an empty list.
   - **PostgreSQL/MySQL:** fails due to a negative `LIMIT`.
   
   With the proposed guard in place, all cases should consistently return an 
empty result, matching Python slicing semantics.
   
   ---
   
   > **Note**
   >
   > This is one possible implementation approach intended to facilitate 
discussion.
   > An alternative would be to clamp the computed bounds individually at each 
call site, but centralizing the behavior in a helper keeps the four 
`Query.slice()` branches consistent and makes the intent explicit.
   >
   > If this approach is acceptable, I'd be happy to open a PR with the 
regression tests and the corresponding fix.
   
   ### What you think should happen instead?
   
   ## What do I think should happen instead?
   
   The slice endpoint should match Python list-slicing semantics.
   
   For crossed-bound slices, where the effective `stop` index is before the 
effective `start` index, the endpoint should return an empty list:
   
   ```python
   list(range(6))[4:2] == []
   list(range(6))[2:-3:-1] == []
   ```
   
   The API should return:
   
   ```json
   []
   ```
   
   consistently across SQLite, PostgreSQL, and MySQL.
   
   It should not generate a negative SQL `LIMIT`.
   
   ---
   
   ## What do I think went wrong?
   
   The handler translates Python-style slice bounds into SQLAlchemy 
`Query.slice(low, high)`.
   
   However, `Query.slice(low, high)` does not behave exactly like Python 
slicing. It compiles to:
   
   ```sql
   OFFSET low
   LIMIT (high - low)
   ```
   
   When `high <= low`, the computed `LIMIT` becomes zero or negative.
   
   Python treats that as an empty slice, but SQL backends do not:
   
   - SQLite treats negative `LIMIT` as unlimited, so it can return incorrect 
rows.
   - PostgreSQL/MySQL reject negative `LIMIT`, causing the request to fail.
   
   So the bug comes from assuming `Query.slice()` clamps crossed bounds the 
same way Python slicing does. It does not.
   
   ### Operating System
   
   macOS 26.5.1 (arm64)
   
   ### Deployment
   
   Virtualenv installation
   
   ### Apache Airflow Provider(s)
   
   _No response_
   
   ### Versions of Apache Airflow Providers
   
   Not Applicable
   
   ### Official Helm Chart version
   
   Not Applicable
   
   ### Kubernetes Version
   
   Not Applicable"
   
   ### Helm Chart configuration
   
   Not Applicable
   
   ### Docker Image customizations
   
   Not Applicable. The bug is in the core Execution API logic.
   
   ### Anything else?
   
   The bug is hidden here, Exact line numbers in 
airflow-core/src/airflow/api_fastapi/execution_api/routes/xcoms.py:
   
   get_mapped_xcom_by_slice() — starts at line 185
   The four crossed-capable .slice() calls:
   line 238 — query = query.slice(start, stop)
   line 240 — query = query.slice(stop + 1, start + 1)
   line 253 — query = query.slice(-1 - start, -1 - stop)
   line 255 — query = query.slice(-stop, -start)
   
   <img width="991" height="531" alt="Image" 
src="https://github.com/user-attachments/assets/9dc8e79b-06d9-446f-9fca-878fdcfcbb65";
 />
   
   ### Are you willing to submit PR?
   
   - [x] Yes I am willing to submit a PR!
   
   ### Code of Conduct
   
   - [x] I agree to follow this project's [Code of 
Conduct](https://github.com/apache/airflow/blob/main/CODE_OF_CONDUCT.md)
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to