Skip to content

fix(spark): apply limit via DataFrame.limit before toPandas - #2574

Open
Bartok9 wants to merge 8 commits into
Canner:mainfrom
Bartok9:fix/spark-limit-pushdown-subquery
Open

fix(spark): apply limit via DataFrame.limit before toPandas#2574
Bartok9 wants to merge 8 commits into
Canner:mainfrom
Bartok9:fix/spark-limit-pushdown-subquery

Conversation

@Bartok9

@Bartok9 Bartok9 commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Apply limit with Spark DataFrame.limit(n) before toPandas(), so the engine gets a server-side CollectLimit instead of materializing the full result and slicing the Arrow table on the client
  • Drop SQL subquery wrapping and the non-subqueryable classifier — unnecessary on Spark’s DataFrame API (same physical plan as wrap; works for SHOW/DESCRIBE/SELECT alike)
  • Reject negative limits with a clear ValueError before execution
  • dry_run unchanged vs main (trailing ; strip + .limit(0).count())

License

Apache-2.0 (core/**).

Motivation

On main, Spark applied limit only after toPandas() via Arrow slice, so bounded asks still fully materialized. Maintainer review confirmed DataFrame.limit() is already plan-level pushdown (not a client slice); string-wrapping LIMIT is the right tool for DBAPI connectors, not Spark.

Verification

cd core/wren && .venv/bin/python -m pytest tests/unit/test_spark_semicolon.py -v — 8 passed

Duplicate check

  • No open PR for Spark DataFrame limit-before-toPandas

@github-actions github-actions Bot added python Pull requests that update Python code core labels Jul 24, 2026
@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

SparkConnector now strips trailing semicolons, validates non-negative limits, and applies limits through Spark SQL or the DataFrame API. Dry runs retain limit(0).count() validation. Unit tests cover both execution paths.

Changes

Spark SQL limit handling

Layer / File(s) Summary
Query LIMIT pushdown
core/wren/src/wren/connector/spark.py, core/wren/tests/unit/test_spark_semicolon.py
query cleans SQL and validates limits. Compatible statements use SQL LIMIT pushdown. Non-subqueryable statements use DataFrame limiting. Tests cover semicolons, comments, zero and negative limits, and SHOW TABLES.
Dry-run validation
core/wren/src/wren/connector/spark.py, core/wren/tests/unit/test_spark_semicolon.py
dry_run validates cleaned SQL with DataFrame limit(0).count(). The test verifies this path.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant SparkConnector
  participant SparkSQLSession
  participant SparkDataFrame
  Client->>SparkConnector: Submit SQL and optional limit
  SparkConnector->>SparkConnector: Strip semicolon and validate limit
  SparkConnector->>SparkSQLSession: Execute wrapped or unchanged SQL
  SparkSQLSession-->>SparkConnector: Return DataFrame
  SparkConnector->>SparkDataFrame: Apply DataFrame limit when required
  SparkConnector-->>Client: Return PyArrow table
Loading

Possibly related PRs

Suggested reviewers: goldmedal

Poem

A rabbit trims the semicolon,
Then checks the limit before the run.
Spark wraps compatible queries,
DataFrames handle special commands.
Dry runs count with zero rows,
Tests watch every path.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description covers the summary, motivation, testing, and duplicate check, but it omits the required failure reproduction and actual error output for a fix. Add a "What failure does this repair?" section with reproduction steps and the actual error output.
✅ Passed checks (4 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title identifies Spark limit handling and the DataFrame.limit path, which is a real and important part of the changes.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@core/wren/src/wren/connector/spark.py`:
- Around line 29-30: Validate limit before the SQL construction in the
limit-handling branch, rejecting negative values with a clear connector-level
validation error. Preserve the existing int conversion and LIMIT generation for
non-negative limits, including zero.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 0efd5983-ebbc-421f-83dd-a5fd8d924243

📥 Commits

Reviewing files that changed from the base of the PR and between d472877 and 6875986.

📒 Files selected for processing (2)
  • core/wren/src/wren/connector/spark.py
  • core/wren/tests/unit/test_spark_semicolon.py

Comment thread core/wren/src/wren/connector/spark.py Outdated
@Bartok9

Bartok9 commented Jul 24, 2026

Copy link
Copy Markdown
Contributor Author

Addressed in e1474b4 — negative limits now raise a clear connector-level ValueError before SQL construction, matching the MySQL connector's _coerce_limit pattern (zero still allowed).

@goldmedal

Copy link
Copy Markdown
Collaborator

Code review

The query() half is a real improvement — pushing LIMIT into Spark SQL instead of materialising the full result and slicing client-side is the right change, and it brings Spark in line with the other connectors. The dry_run() half is a regression.

1. dry_run no longer validates non-SELECT statements.

# Prefer a LIMIT 0 subquery wrapper (like other connectors) so EXPLAIN
# is unnecessary and a trailing semicolon cannot break Spark SQL.
cleaned = strip_trailing_semicolon(sql)
self.connection.sql(f"SELECT * FROM ({cleaned}) AS _q LIMIT 0").count()
def close(self) -> None:

main uses the DataFrame API — self.connection.sql(cleaned).limit(0).count() — which validates anything Spark can parse, including SHOW TABLES, DESCRIBE ... and similar. Wrapping in SELECT * FROM (...) AS _q LIMIT 0 restricts validation to statements that are legal as a subquery, so those now fail at dry-run time having previously passed. The same concern applies to query() when a limit is supplied.

If the goal is only to keep a trailing ; from breaking things, strip_trailing_semicolon alone achieves that — main already calls it. The subquery wrap isn't needed for that and costs the broader coverage.

2. The comment describes a change that isn't happening. It says the wrapper is preferred "so EXPLAIN is unnecessary", but main doesn't use EXPLAIN on this path — it uses .limit(0). Worth rewording to say what is actually being replaced and why.

3. Please confirm the wrap is safe on Spark for duplicate output columns. This codebase has already rejected subquery-wrapping once for exactly that reason:

Wrapping the user SQL in ``SELECT * FROM (...) AS _sub LIMIT n`` was
rejected because it fails with ``ER_DUP_FIELDNAME`` whenever the inner
SELECT projects two columns with the same name (e.g. a join that selects
``a.id`` and ``b.id``).
"""
return f"{strip_trailing_semicolon(sql)}\nLIMIT {limit}"

Spark is more permissive than MySQL here, so it may well be fine — but given the documented history, a note in the description confirming a join that projects two same-named columns still works under the wrap would be reassuring.

Rebase before merge — 5 commits behind main.

@goldmedal

Copy link
Copy Markdown
Collaborator

To clean my review queue, I changed the PR status to draft. After addressing the review comment, you can request me again.

@Bartok9
Bartok9 force-pushed the fix/spark-limit-pushdown-subquery branch from e1474b4 to b9ee3ee Compare July 31, 2026 03:10
@Bartok9
Bartok9 marked this pull request as ready for review July 31, 2026 03:10
@Bartok9

Bartok9 commented Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @goldmedal — all three points addressed in the latest push.

  1. dry_run regression fixed. Reverted to the DataFrame API — self.connection.sql(cleaned).limit(0).count() — so SHOW TABLES, DESCRIBE ... and other statements that aren't legal as a subquery still validate exactly as on main. dry_run now only strips a trailing ;, nothing more. query() keeps the SQL LIMIT pushdown (the part you flagged as the real improvement); if a limited query() is ever called with a non-SELECT statement that isn't subquery-legal, that's the caller supplying a limit for something unlimitable, and it fails loudly rather than silently — happy to narrow further if you'd prefer.
  2. Comment reworded. The stale "so EXPLAIN is unnecessary" wording is gone; the comment now says what's actually happening (DataFrame validation + trailing-; strip).
  3. Duplicate-column concern moot for dry_run now that it no longer wraps. For query(), Spark permits duplicate output names in a subquery projection (unlike MySQL's rejection you linked), so a join projecting two same-named columns wraps fine — but since the risky path is only the limited query() and dry-run is back to the DataFrame API, the documented MySQL history no longer applies here.

Test updated to assert the DataFrame validation path. Rebased onto current main. Re-requesting review.

@goldmedal goldmedal left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

The direction is right and matches the established pushdown pattern in this codebase (duckdb / snowflake / databricks all subquery-wrap). Two things I'd like addressed before merge, both stemming from the fact that the wrap path is far hotter than it looks: mcp_server.py::_query_with_limit_probe never passes limit=None (it falls back to DEFAULT_ROW_LIMIT), so every Spark query issued through MCP run_sql now goes through the new subquery wrap.


1. Trailing line comment breaks the wrap (spark.py)

The single-line f-string means user SQL ending in a line comment produces invalid SQL:

-- input:  SELECT 1 -- note
SELECT * FROM (SELECT 1 -- note) AS _q LIMIT 10
--                        ^ swallows ") AS _q LIMIT 10"

snowflake.py hit exactly this and fixed it by putting the user SQL on its own line, with the rationale in a comment:

# Place the user SQL on its own line so a trailing line comment
# (`-- ...`) cannot swallow the closing paren, alias, or LIMIT.
executed = (
    "SELECT * FROM (\n"
    f"{strip_trailing_semicolon(sql)}\n"
    f") AS _wren_sub LIMIT {int(limit)}"
)

Worth mirroring here. On main this SQL works (the limit was applied client-side), so as written this is a behavioural regression for LLM/agent-generated SQL, which routinely carries comments.

2. query() now breaks the statements dry_run deliberately protects

The new dry_run comment states the rule explicitly:

Validate via the DataFrame API so statements that are not legal as a subquery (SHOW TABLES, DESCRIBE, ...) are still accepted, exactly as before.

But query() wraps unconditionally whenever limit is not None, and via MCP that is always. So SHOW TABLES passes dry_run and fails query — and it succeeded on main. Either the same carve-out should apply to query() (skip the wrap / fall back to a client-side slice for non-subqueryable statements), or the trade-off should be stated as a deliberate accepted regression rather than contradicted one method apart.

3. _coerce_limit already exists (minor)

mysql.py:44 has this exact validate-and-coerce, down to the f"limit must be non-negative, got {coerced}" message. This is now a second copy. Promoting it to connector/base.py and calling it from spark/mysql would also give duckdb and snowflake the negative-limit check they currently lack (both interpolate a bare int(limit)).

4. PR description is stale (minor)

The body still says "dry_run uses SELECT * FROM (...) LIMIT 0 instead of client .limit(0)", but commit b9ee3ee reverted that — the dry_run diff is now a no-op (comment + local variable, identical behaviour). Worth correcting, since the body becomes the squash-merge message.

5. Test coverage (minor)

The tests are mock-only string assertions, which is reasonable for shape, but two behaviours added in this PR are untested:

  • negative limit raising ValueError (added in 5e45530)
  • limit=0 producing LIMIT 0 rather than being treated as falsy

A trailing-line-comment case would also lock in the fix for (1). Note there is no Spark entry in tests/connectors/, so string construction is the only evidence we have that the generated SQL is valid — nothing exercises a real Spark session.

Verdict

(1) and (2) are blocking; (3)–(5) are non-blocking.

@Bartok9

Bartok9 commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @goldmedal — addressed the two blocking items (plus the easy test/description nits) in the latest push.

  1. Trailing line-comment wrap — mirrored snowflake.py: user SQL is placed on its own line inside the subquery so a trailing -- ... cannot swallow ) AS _q LIMIT n.
  2. query() vs non-subqueryable statements — when limit is not None and the cleaned SQL looks non-subqueryable (SHOW/DESCRIBE/DESC/EXPLAIN/DDL/DML/…), we keep the DataFrame path (sql(cleaned).limit(n).toPandas()) so MCP’s always-on default limit no longer breaks statements that dry_run deliberately still accepts. SELECT-like SQL still gets the SQL pushdown.
  3. _coerce_limit promotion — left local for this PR to keep the diff scoped; happy to follow up with a shared helper in base.py for spark/mysql/duckdb/snowflake if you want that as a separate change.
  4. PR description — refreshed (no longer claims dry_run subquery wrap).
  5. Tests — added coverage for trailing -- comment wrap, limit=0, negative limitValueError, and SHOW TABLES + limit using the DataFrame slice.

pytest tests/unit/test_spark_semicolon.py -v — 9 passed. Ready for another look when you have a moment.

Bartok9 added a commit to Bartok9/WrenAI that referenced this pull request Aug 3, 2026
Address goldmedal review on Canner#2574:
- Mirror snowflake multiline wrap so trailing `--` comments cannot
  swallow the closing paren/alias/LIMIT
- When limit is set for SHOW/DESCRIBE/etc., keep DataFrame client
  slice (MCP always passes DEFAULT_ROW_LIMIT)
- Cover comment wrap, limit=0, negative limit, and SHOW TABLES path

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@core/wren/src/wren/connector/spark.py`:
- Around line 11-15: Update the classification around _NON_SUBQUERYABLE to skip
leading SQL comments, including line comments followed by newlines, before
testing the command keyword. Ensure standalone commands such as SHOW TABLES
remain classified as non-subqueryable when prefixed by comments, while
preserving the existing keyword matching behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 246f278c-979d-42a1-81ef-f9c21aee1fd4

📥 Commits

Reviewing files that changed from the base of the PR and between b9ee3ee and 397b4f2.

📒 Files selected for processing (2)
  • core/wren/src/wren/connector/spark.py
  • core/wren/tests/unit/test_spark_semicolon.py

Comment thread core/wren/src/wren/connector/spark.py Outdated
@Bartok9

Bartok9 commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

Addressed CodeRabbit’s leading-comment classification note.

`_is_non_subqueryable` now strips leading whitespace and `--` / `/* */` comments before the keyword match, so e.g. `-- metadata\nSHOW TABLES` with a limit stays on the DataFrame path instead of being subquery-wrapped. Added a unit test; `pytest tests/unit/test_spark_semicolon.py -v` — 10 passed.

@goldmedal goldmedal left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review

Re-checked all three points from my last round. They're genuinely addressed, and your duplicate-column claim holds up — I verified it. But in fixing them the patch grew a 35-line SQL-string classifier, and I'm asking for it to come out before merge.

🔴 Blocking — the subquery wrap is unnecessary; DataFrame.limit(n) already is the pushdown

I measured both paths on a local PySpark 4.1.1 session:

spark.sql("SELECT * FROM t").limit(3).explain()
== Physical Plan ==
CollectLimit 3
+- *(1) Range (0, 1000, step=1, splits=1)

spark.sql("SELECT * FROM (\nSELECT * FROM t\n) AS _q LIMIT 3").explain()
== Physical Plan ==
CollectLimit 3
+- *(1) Range (0, 1000, step=1, splits=1)

Identical. DataFrame.limit() is a Limit node in the logical plan — it is server-side, not a client slice.

The bug on main was never "no SQL LIMIT"; it was that the limit was applied to the Arrow table after toPandas():

arrow_table = pa.Table.from_pandas(df)   # full result already materialized
if limit is not None:
    arrow_table = arrow_table.slice(0, limit)

So the else branch this PR already added is the entire fix:

frame = self.connection.sql(strip_trailing_semicolon(sql))
if coerced is not None:
    frame = frame.limit(coerced)
df = frame.toPandas()

That works for every statement class — SELECT, SHOW TABLES, DESCRIBE alike (verified: SHOW TABLES + .limit(5) → OK; the same statement wrapped → AnalysisException [TABLE_OR_VIEW_NOT_FOUND]: table or view 'SHOW').

Please drop the wrap branch. It deletes _NON_SUBQUERYABLE, _LEADING_SQL_NOISE, _strip_leading_sql_comments and _is_non_subqueryable outright, and the trailing-line-comment fix and the non-subqueryable carve-out both stop existing rather than being patched — which is the difference between a fix and a fix plus a permanent maintenance surface.

The reason Spark differs from snowflake/duckdb/postgres is that those hold a raw DBAPI cursor with no plan-level limit, so string surgery is their only option. Spark has a first-class DataFrame API — and databricks.py already skips the wrap in query() for the same reason (cursor.fetchmany_arrow(limit)). "Matching the other connectors" is the right instinct applied to the wrong axis: the shared contract is limit server-side, not limit via string interpolation.

🔴 Blocking (alternative) — if the wrap stays, the classifier has to change shape

I'd rather not merge the denylist in its current form either way, so if you disagree with the above, this is what needs fixing instead.

_NON_SUBQUERYABLE is wrong in both directions. Measured false positive:

_is_non_subqueryable(
    "WITH t AS (SELECT CASE WHEN a=1 THEN 'INSERT' ELSE 'UPDATE' END AS op FROM db.log) SELECT * FROM t"
)  # -> True

WITH\s+.*\bINSERT\b under re.DOTALL scans the whole statement, so any INSERT token anywhere in a CTE query matches. That silently drops the pushdown this PR exists to add — and it matters more than it looks, because the connector never sees raw user SQL: engine.query() runs dry_plan() first, so what reaches SparkConnector.query is CTE-injected planned SQL that begins with WITH for essentially every model-backed query.

The other direction: any non-subqueryable statement not enumerated gets wrapped and fails, and the list can't be completed — it's a denylist over an open set.

An allowlist inverts both failure modes, and sqlglot is already a dependency used for exactly this in sql_classify.pyparse_one(sql, dialect="spark") plus an isinstance(..., exp.Select | exp.Union | ...) check is shorter and doesn't need a hand-rolled comment stripper.

Minor (non-blocking)

  • _strip_leading_sql_comments's while loop is dead code. _LEADING_SQL_NOISE ends in +, so a single sub(..., count=1) already consumes all leading whitespace/--//* */ runs. Verified: single-pass output == loop output on mixed inputs. (Moot if the wrap goes.)
  • _coerce_limit is now the second copy (mysql.py:44), and #2635 is adding a third in redshift.py. Three copies is the point at which connector/base.py wins — and it would give duckdb/snowflake/athena/datafusion the negative-limit check they currently lack.
  • dry_run is a behavioural no-op in this diff (comment + local variable). Correct outcome, but it's churn in a method that didn't change.
  • Still 3 commits behind main.

Verified / closing my earlier points

  • Duplicate output columns under the wrap: your claim is correct. SELECT * FROM (SELECT a.id, b.id FROM t a JOIN t b ON a.id=b.id) AS _q LIMIT 2 returns ['id', 'id'] and both columns round-trip. The MySQL ER_DUP_FIELDNAME history doesn't apply to Spark. Point 3 closed.
  • Inner ORDER BY survives the outer LIMIT[999, 998, 997] from both paths.
  • pytest tests/unit/test_spark_semicolon.py → 10 passed; ruff check / ruff format --check clean. (Full tests/unit has 4 unrelated test_memory.py ModuleNotFoundError failures from the uninstalled memory extra.)

Caveat on my measurements: local mode, not Spark Connect. There's still no tests/connectors/test_spark.py, so nothing in CI exercises a real session either way — which is another argument for the path that requires no generated SQL to be valid.

Verdict

Request changes. The direction is right and the behaviour is now correct, so this is close. Blocking is the implementation shape, not the outcome: keep frame.limit(n), delete the wrap branch and the classifier with it — same plan, all statement classes, ~40 fewer lines. If you want to keep the wrap, then the denylist→allowlist change is required in its place.

Bartok9 added 8 commits August 3, 2026 00:08
Avoid full result materialization before Arrow slice; wrap dry_run in
LIMIT 0 subquery after stripping trailing semicolons.
Address goldmedal review on Canner#2574:
- Mirror snowflake multiline wrap so trailing `--` comments cannot
  swallow the closing paren/alias/LIMIT
- When limit is set for SHOW/DESCRIBE/etc., keep DataFrame client
  slice (MCP always passes DEFAULT_ROW_LIMIT)
- Cover comment wrap, limit=0, negative limit, and SHOW TABLES path
CI ruff format --check wants the subquery wrap as a single f-string.
Classify SHOW/DESCRIBE/... after stripping leading -- and /* */ comments
so MCP default limits still use the DataFrame path for commented meta SQL.
goldmedal measured DataFrame.limit() as server-side CollectLimit —
identical plan to subquery LIMIT wrap. Drop wrap + classifier; fix is
limit before toPandas, not string surgery. Keep non-negative coerce.
@Bartok9
Bartok9 force-pushed the fix/spark-limit-pushdown-subquery branch from 8c19d73 to 3c63da0 Compare August 3, 2026 04:09
@Bartok9 Bartok9 changed the title fix(spark): push LIMIT into SQL instead of client slice fix(spark): apply limit via DataFrame.limit before toPandas Aug 3, 2026
@Bartok9

Bartok9 commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @goldmedal — agreed on the measurements and the shape.

Dropped the subquery wrap and the entire classifier (_NON_SUBQUERYABLE / leading-comment strip / _is_non_subqueryable). query() is now:

coerced = _coerce_limit(limit)
frame = self.connection.sql(strip_trailing_semicolon(sql))
if coerced is not None:
    frame = frame.limit(coerced)
df = frame.toPandas()

That matches the path you identified as the real fix (server-side CollectLimit before materialization; no post-Arrow slice). Tests updated accordingly; dry_run left as on main. Rebased onto current main. Re-requesting review.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

core python Pull requests that update Python code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants