aminghadersohi commented on code in PR #45283:
URL: https://github.com/apache/superset/pull/45283#discussion_r4278509073
##########
superset/sql/execution/celery_task.py:
##########
@@ -481,25 +570,60 @@ def _execute_sql_statements(
)
) from ex
- # Check if stopped
- if not execution_results:
- payload.update({"status": QueryStatus.STOPPED.value})
- return payload
+ # Cancelling can clear cursor.description, leaving a nonempty result
+ # list that looks like successful DML. Check intent, not just results.
+ stopped = not execution_results or check_stopped()
# Commit for mutations
- if parsed_script.has_mutation() or query.select_as_cta:
+ committed = False
+ if not stopped and (parsed_script.has_mutation() or
query.select_as_cta):
Review Comment:
Thanks, this was valid at 24bca1e910010632f50dc19c477fca0ef94625c3. Fixed in
0eab948613546ed4a7a678c7b19c5fcdc3220ab7.
The commit decision now asks the connection whether it commits each
statement: `BaseEngineSpec.is_autocommit(conn)`
(superset/db_engine_specs/base.py:3028) reads `autocommit` (psycopg2, pyodbc),
`get_autocommit()` (MySQL drivers) or an `AUTOCOMMIT` isolation level (Trino).
Drivers that report none of these use `autocommit_by_default`, which is True
for the PyHive specs (Presto, Hive and its descendants) and for impyla
(Impala), whose `commit()` does nothing. On such a connection, a mutation or
CTAS that finished executing counts as committed work: `on_commit` fires and
the query ends SUCCESS even if a stop was recorded after it finished.
A stop still wins if it was loaded into the worker's session while the
statements ran. Either the check between statements loads it, or the engine's
polling does: Impala, Hive and Presto `handle_cursor` load it, then cancel the
running statement. `stop_seen_during_execution`
(superset/sql/execution/celery_task.py:573) looks only at attributes already
loaded and does not read the row again. That is what separates a stop that
interrupted execution from one that arrived after the statement finished.
Regression tests (each [autocommit-after_finish-*] case fails at
24bca1e910010632f50dc19c477fca0ef94625c3 with `assert 'stopped' ==
<QueryStatus.SUCCESS: 'success'>` and passes at
0eab948613546ed4a7a678c7b19c5fcdc3220ab7). The old-head run used 24bca1e9's
`celery_task.py` and `sql_lab.py`, with the new tests and the engine-spec
detection copied in:
-
`tests/unit_tests/sql/execution/test_celery_task.py::test_autocommit_work_finished_before_a_stop_is_committed`:
16 cases (autocommit or transaction × stop after the statement finished or
loaded while it ran × STOPPED or early-cancel intent × mutation or CTAS), with
the stop committed from a second SQLite session. The 4
[autocommit-after_finish-*] cases are the regression; the other 12 pass on both
heads and end STOPPED without `conn.commit()`.
-
`tests/unit_tests/sql_lab_test.py::test_get_sql_results_autocommit_work_finished_before_a_stop_is_committed`:
the same for the legacy path. [autocommit-after_finish-mutation] and
[autocommit-after_finish-ctas] are the regression.
- `tests/unit_tests/db_engine_specs/test_base.py::test_is_autocommit`,
`test_is_autocommit_real_connections`,
`test_is_autocommit_falls_back_to_the_engine_default`, and
`tests/unit_tests/db_engine_specs/test_trino.py::test_is_autocommit_reads_trino_isolation_level`
(a real `trino.dbapi.Connection`) cover the detection.
Siblings checked:
- superset/sql_lab.py:762 — fixed in
0eab948613546ed4a7a678c7b19c5fcdc3220ab7 (the legacy target-commit check uses
the same rule)
- superset/sql/execution/celery_task.py:712 — not affected: transactional
connections keep the stop check before `conn.commit()`
- superset/sql/execution/celery_task.py:745 — not affected:
`_publish_query_result(durable=True)` already publishes committed work over a
later stop
- superset/sql/execution/executor.py:787 — not affected: SQLExecutor's sync
path passes no stop check and never calls `conn.commit()`
- superset/db_engine_specs/presto.py:171 — fixed in
0eab948613546ed4a7a678c7b19c5fcdc3220ab7 (PyHive reports no transaction mode,
so the spec default applies; Trino is read from the connection)
- superset/db_engine_specs/impala.py:54 — fixed in
0eab948613546ed4a7a678c7b19c5fcdc3220ab7 (impyla reports no transaction mode)
Remaining:
- In a multi-statement script on an autocommit engine, statements that
finished before the stop are durable, but the query ends STOPPED because the
stop interrupted the script. The status does not say which statements applied.
- If the engine finishes the statement between its last status poll and the
cancel call, the cancel does nothing, but the query ends STOPPED with the rows
written. The window is the time between `cursor.status()` and the cancel RPC in
`handle_cursor`.
- On the legacy path, `execute_query` reloads the row just before it
executes. A stop committed after the per-block check but before that reload is
loaded too, so on an engine that does not poll for stops the statement still
runs and the query ends STOPPED with its rows written.
- Drivers that report no transaction mode on engines whose spec does not set
`autocommit_by_default` (for example BigQuery, or Snowflake, whose connector
exposes `autocommit` as a method) behave as before: a stop before the commit
check ends STOPPED with the work already applied.
##########
superset/sql_lab.py:
##########
@@ -825,7 +867,9 @@ def execute_sql_statements( # noqa: C901
# unit-test suite driving this fix never exercised.
db.session.flush()
db.session.refresh(query)
- if query.status not in (QueryStatus.FAILED, QueryStatus.STOPPED):
+ if query.status != QueryStatus.FAILED and (
+ committed or query.status != QueryStatus.STOPPED
+ ):
query.status = QueryStatus.SUCCESS
Review Comment:
Thanks, this was valid at 24bca1e910010632f50dc19c477fca0ef94625c3: the row
stayed STOPPED, but the returned payload still said SUCCESS and carried the
data, and the STOPPED row referenced the stored results. Fixed in
0eab948613546ed4a7a678c7b19c5fcdc3220ab7.
The final check in `execute_sql_statements` (superset/sql_lab.py:971)
reloads only the status, without flushing. If a stop landed while the results
were serialized or written to the results backend, and the target database did
not commit, it now:
- reloads the row so the pending success fields are dropped and the stop's
end time is kept;
- deletes the stored results, because the stopped row will not reference
them;
- commits, and returns `{"query_id": ..., "status": "stopped"}`. That is the
same minimal payload as the write-failure stop return at :931.
Regression test:
`tests/unit_tests/sql_lab_test.py::test_get_sql_results_stop_during_select_finalization_ends_stopped`,
5 cases. A second SQLite session commits the stop during
`_serialize_and_expand_data` (no store; store and return; store only) or during
`results_backend.set` (store and return; store only). All 5 fail at
24bca1e910010632f50dc19c477fca0ef94625c3 (old `sql_lab.py`, new tests). For
example, [serialize-False-True] fails with `assert {'columns': [...],
...SUCCESS...} == {'query_id': ..., 'status': 'stopped'}` and
[backend_write-True-False] with `assert None == {'query_id': 123, 'status':
<QueryStatus.STOPPED: 'stopped'>}`. All 5 pass at
0eab948613546ed4a7a678c7b19c5fcdc3220ab7. They also check that the row is
STOPPED with the stop's `end_time`, `results_key` is None, and the stored blob
was deleted.
Changed test:
`test_get_sql_results_keeps_published_results_of_stopped_query` relied on a
stopped row keeping its key. It is now
`test_get_sql_results_keeps_published_results_after_final_commit`, without a
stop: once the SUCCESS row with the key is committed, a later failure ends
FAILED and keeps the key and the blob.
Siblings checked:
- superset/sql/execution/celery_task.py:759 — not affected: the Celery
publish check returns the minimal STOPPED payload and deletes the blob
- superset/sql/execution/celery_task.py:765 — not affected: when the guarded
publish loses to a stop, it does the same
- superset/sql_lab.py:810 — not affected: the check before the SUCCESS
payload is built already returns STOPPED
- superset/sql_lab.py:919 — not affected: the write-failure branch already
returns the minimal STOPPED payload
- superset/sql_lab.py:730 — not affected: a stop raised by the statement
returns STOPPED before any payload is built
Remaining: a stop committed between this status read and the final commit is
still overwritten with SUCCESS. The payload and the row then agree on SUCCESS.
The legacy path has no guarded UPDATE here (the Celery path does), and this
window is disclosed in the code comment.
##########
superset/sql/execution/celery_task.py:
##########
@@ -198,102 +218,110 @@ def _finalize_successful_query(
}
)
- query.rows = total_rows
- query.progress = 100
- query.set_extra_json_key("progress", None)
- # Store columns from last statement (for compatibility)
- if execution_results and execution_results[-1][1] is not None:
- query.set_extra_json_key("columns", execution_results[-1][1].columns)
- query.end_time = now_as_float()
-
- payload.update(
- {
- "status": QueryStatus.SUCCESS.value,
- "statements": statements_data,
- "total_execution_time_ms": total_execution_time_ms,
- "query": query.to_dict(),
- }
- )
- payload["query"]["state"] = QueryStatus.SUCCESS.value
+ with db.session.no_autoflush:
+ query.rows = total_rows
+ query.progress = 100
+ query.set_extra_json_key("progress", None)
+ # Store columns from last statement (for compatibility)
+ if execution_results and execution_results[-1][1] is not None:
+ query.set_extra_json_key("columns",
execution_results[-1][1].columns)
+ query.end_time = now_as_float()
+
+ payload.update(
+ {
+ "status": QueryStatus.SUCCESS.value,
+ "statements": statements_data,
+ "total_execution_time_ms": total_execution_time_ms,
+ "query": query.to_dict(),
+ }
+ )
+ payload["query"]["state"] = QueryStatus.SUCCESS.value
def _store_results_in_backend(
query: Query,
payload: dict[str, Any],
database: Any,
+ durable: bool = False,
) -> None:
"""Store query results in the results backend."""
- key = str(uuid.uuid4())
- payload["query"]["resultsKey"] = key
- logger.info(
- "Query %s: Storing results in results backend, key: %s",
- str(query.id),
- key,
- )
- stats_logger = app.config["STATS_LOGGER"]
- with stats_timing("sqllab.query.results_backend_write", stats_logger):
- with stats_timing(
- "sqllab.query.results_backend_write_serialization", stats_logger
- ):
- serialized_payload = _serialize_payload(payload)
-
- # Check payload size limit
- if sql_lab_payload_max_mb :=
app.config.get("SQLLAB_PAYLOAD_MAX_MB"):
- serialized_payload_size = len(serialized_payload)
- max_bytes = sql_lab_payload_max_mb * BYTES_IN_MB
-
- if serialized_payload_size > max_bytes:
- logger.info("Result size exceeds the allowed limit.")
- raise SupersetErrorException(
- SupersetError(
- message=(
- f"Result size "
- f"({serialized_payload_size / BYTES_IN_MB:.2f}
MB) "
- f"exceeds the allowed limit of "
- f"{sql_lab_payload_max_mb} MB."
- ),
-
error_type=SupersetErrorType.RESULT_TOO_LARGE_ERROR,
- level=ErrorLevel.ERROR,
+ with db.session.no_autoflush:
+ key = str(uuid.uuid4())
+ payload["query"]["resultsKey"] = key
+ logger.info(
+ "Query %s: Storing results in results backend, key: %s",
+ str(query.id),
+ key,
+ )
+ stats_logger = app.config["STATS_LOGGER"]
+ with stats_timing("sqllab.query.results_backend_write", stats_logger):
+ with stats_timing(
+ "sqllab.query.results_backend_write_serialization",
stats_logger
+ ):
+ serialized_payload = _serialize_payload(payload)
+
+ # Check payload size limit
+ if sql_lab_payload_max_mb :=
app.config.get("SQLLAB_PAYLOAD_MAX_MB"):
+ serialized_payload_size = len(serialized_payload)
+ max_bytes = sql_lab_payload_max_mb * BYTES_IN_MB
+
+ if serialized_payload_size > max_bytes:
+ logger.info("Result size exceeds the allowed limit.")
+ raise SupersetErrorException(
+ SupersetError(
+ message=(
+ f"Result size "
+ f"({serialized_payload_size /
BYTES_IN_MB:.2f} MB) "
+ f"exceeds the allowed limit of "
+ f"{sql_lab_payload_max_mb} MB."
+ ),
+
error_type=SupersetErrorType.RESULT_TOO_LARGE_ERROR,
+ level=ErrorLevel.ERROR,
+ )
)
- )
-
- cache_timeout = database.cache_timeout
- if cache_timeout is None:
- cache_timeout = app.config["CACHE_DEFAULT_TIMEOUT"]
- compressed = zlib_compress(serialized_payload)
- logger.debug("*** serialized payload size: %i",
len(serialized_payload))
- logger.debug("*** compressed payload size: %i", len(compressed))
-
- write_success = results_backend.set(key, compressed, cache_timeout)
- if not write_success:
- logger.error(
- "Query %s: Failed to store results in backend, key: %s",
- str(query.id),
- key,
- )
- stats_logger.incr("sqllab.results_backend.write_failure")
- query.results_key = None
- query.status = QueryStatus.FAILED
- query.error_message = (
- "Failed to store query results in the results backend. "
- "Please try again or contact your administrator."
- )
- db.session.commit() # pylint: disable=consider-using-transaction
- raise SupersetErrorException(
- SupersetError(
- message=__("Failed to store query results. Please try
again."),
- error_type=SupersetErrorType.RESULTS_BACKEND_ERROR,
- level=ErrorLevel.ERROR,
+ cache_timeout = database.cache_timeout
+ if cache_timeout is None:
+ cache_timeout = app.config["CACHE_DEFAULT_TIMEOUT"]
+
+ compressed = zlib_compress(serialized_payload)
+ logger.debug("*** serialized payload size: %i",
len(serialized_payload))
+ logger.debug("*** compressed payload size: %i", len(compressed))
+
+ write_success = results_backend.set(key, compressed, cache_timeout)
+ if not durable and _make_check_stopped_fn(query,
preserve_pending=True)():
Review Comment:
Follow-up: an independent check of my last reply found a case I had only
listed as remaining. A metadata commit can reach the database and still raise,
for example when its acknowledgement is lost. The final cleanup in
`get_sql_results` (both `return_results` modes) and in `execute_sql_task` then
deleted the stored blob, although the committed FAILED or SUCCESS row pointed
at it.
Fixed in 0eab948613546ed4a7a678c7b19c5fcdc3220ab7. Both cleanups
(superset/sql_lab.py:365, superset/sql/execution/celery_task.py:451) now call
`_discard_unreferenced_results` (superset/sql_lab.py:226,
superset/sql/execution/celery_task.py:511). It rolls back, reads the row's
`results_key` in a new transaction, and deletes the blob only if the row does
not reference it. If that read also fails because the metadata database is
unreachable, it keeps the blob and logs "Kept results that may be referenced";
the blob expires with `cache_timeout`.
This changes one case on purpose. When the metadata database stays down, the
blob used to be deleted; now it is kept, because the worker cannot tell whether
a commit applied. An orphaned blob that expires is safer than a row that points
at deleted results.
Regression tests. The old-head runs used
24bca1e910010632f50dc19c477fca0ef94625c3's `celery_task.py` and `sql_lab.py`
with the new tests; each case below fails there with "Expected 'delete' to not
have been called. Called 1 times." and passes at
0eab948613546ed4a7a678c7b19c5fcdc3220ab7:
-
`tests/unit_tests/sql/execution/test_celery_task.py::test_commit_that_applies_and_raises_keeps_referenced_results[failed_row]`:
the error handler's FAILED commit reaches the database and then raises.
- `[published_row]` of the same test: the publish commit reaches the
database and raises, and the metadata database then stays down.
-
`tests/unit_tests/sql_lab_test.py::test_get_sql_results_commit_that_applies_and_raises_keeps_results[failed_row-False]`,
`[failed_row-True]` and `[published_row-True]`: the same on the legacy path. A
second session reads the committed row with the key, and the blob is still
stored.
- `[no_reread]` of
`tests/unit_tests/sql/execution/test_celery_task.py::test_unrecorded_error_after_results_write_deletes_only_unreferenced_results`
and of
`tests/unit_tests/sql_lab_test.py::test_get_sql_results_unrecorded_error_after_write_deletes_unreferenced_results`:
every metadata statement fails, so the blob is kept and the warning logged.
- Controls that pass on both heads: the `[reread-*]` cases of those two
tests (the read works and shows no reference, so the blob is deleted, including
when the delete itself fails), and
`test_error_path_deletes_results_only_without_committed_failure` and
`test_get_sql_results_deletes_results_only_without_committed_failure` (a commit
that did not apply, so the blob is deleted).
- `test_discard_unreferenced_results_without_stored_results` covers the
early return when nothing was stored.
Siblings checked:
- superset/sql/execution/celery_task.py:451 — fixed in
0eab948613546ed4a7a678c7b19c5fcdc3220ab7
- superset/sql_lab.py:365 — fixed in 0eab948613546ed4a7a678c7b19c5fcdc3220ab7
- superset/sql/execution/celery_task.py:106 — not affected: the stop check
found STOPPED before any commit carried the key
- superset/sql/execution/celery_task.py:137 — not affected: the guarded
UPDATE found STOPPED, so nothing was committed
- superset/sql/execution/celery_task.py:315 — not affected: deleted before
any commit carried the key
- superset/sql/execution/celery_task.py:760 — not affected: deleted before
the publish commit
- superset/sql/execution/celery_task.py:766 — not affected: the guarded
publish committed nothing
- superset/sql_lab.py:151 — not affected: the legacy handler's stop return
commits nothing
- superset/sql_lab.py:976 — not affected: new in
0eab948613546ed4a7a678c7b19c5fcdc3220ab7; the reload drops the pending key
before the delete, so the STOPPED commit does not carry it
Remaining:
- If the metadata database cannot be read after the failure, the blob is
kept until `cache_timeout`, even when no row references it.
- If the worker dies between the write and the publish, or a delete fails,
the blob also stays until `cache_timeout`, as before.
- The legacy `handle_query_error` still lets a recorded stop replace a
timeout and records FAILED rather than TIMED_OUT. That is master's behaviour
(see its TODO), and this PR does not change it.
##########
superset/sql_lab.py:
##########
@@ -628,8 +651,12 @@ def execute_sql_statements( # noqa: C901
return payload
# Commit the connection so CTA queries will create the table and any
DML.
+ committed = False
if parsed_script.has_mutation() or query.select_as_cta:
Review Comment:
Follow-up: my last reply listed autocommit engines as remaining. An
independent check also showed that on connections that commit each statement,
both the legacy and the Celery paths could end STOPPED with the rows or the
table already written, whenever the stop arrived after the statement finished
but before the target-commit check. Fixed in
0eab948613546ed4a7a678c7b19c5fcdc3220ab7.
Both paths now ask the connection whether each statement commits when it
finishes: `BaseEngineSpec.is_autocommit(conn)`, at superset/sql_lab.py:762 and
superset/sql/execution/celery_task.py:702. It reads the driver's `autocommit`,
`get_autocommit()` or an `AUTOCOMMIT` isolation level (Trino). For drivers that
report nothing it falls back to `autocommit_by_default`, which is True for
PyHive (Presto, Hive) and impyla (Impala). On such a connection, finished work
counts as committed and the query ends SUCCESS. A stop ends STOPPED only if it
was loaded into the worker's session while the statements ran: by the check
between statements, or by engine polling that then cancels the running
statement. `stop_seen_during_execution` at
superset/sql/execution/celery_task.py:573 does not read the row again.
Transactional connections keep the check you asked for: a stop before
`conn.commit()` skips it and ends STOPPED.
Regression tests (each [autocommit-after_finish-*] case fails at
24bca1e910010632f50dc19c477fca0ef94625c3 with `assert 'stopped' ==
<QueryStatus.SUCCESS: 'success'>` and passes at
0eab948613546ed4a7a678c7b19c5fcdc3220ab7):
-
`tests/unit_tests/sql_lab_test.py::test_get_sql_results_autocommit_work_finished_before_a_stop_is_committed`:
mutation and CTAS, with a stop committed by a second SQLite session as the
last statement finishes. The [autocommit-while_running-*] and [transaction-*]
cases pass on both heads and end STOPPED without `conn.commit()`.
-
`tests/unit_tests/sql/execution/test_celery_task.py::test_autocommit_work_finished_before_a_stop_is_committed`:
the same for Celery, with both STOPPED and early-cancel intent (16 cases).
-
`tests/unit_tests/sql_lab_test.py::test_get_sql_results_stop_before_target_commit_stays_stopped`
and `test_get_sql_results_keeps_committed_work_when_stop_races_commit` still
pass.
- `tests/unit_tests/db_engine_specs/test_base.py::test_is_autocommit*` and
`tests/unit_tests/db_engine_specs/test_trino.py::test_is_autocommit_reads_trino_isolation_level`
cover the detection.
Siblings checked:
- superset/sql_lab.py:762 — fixed in 0eab948613546ed4a7a678c7b19c5fcdc3220ab7
- superset/sql/execution/celery_task.py:702 — fixed in
0eab948613546ed4a7a678c7b19c5fcdc3220ab7
- superset/sql_lab.py:810 — not affected: once `committed` is set, the later
stop checks treat the work as committed, as they already did after a real commit
- superset/sql_lab.py:971 — not affected: the same `committed` flag applies
at the final status check
- superset/sql/execution/executor.py:787 — not affected: SQLExecutor's sync
path has no stop check and never calls `conn.commit()`
Remaining:
- On an autocommit engine, statements in a multi-statement script that
finished before a stop are durable, but the query ends STOPPED. The status does
not say which statements applied.
- If the engine finishes a statement between its last status poll and the
cancel call, the query ends STOPPED with the rows written.
- On the legacy path, a stop committed after the per-block check but before
`execute_query` reloads the row still runs the statement on engines that do not
poll for stops, and ends STOPPED with the rows written.
- Drivers that report no transaction mode on engines without
`autocommit_by_default` (for example BigQuery, Snowflake) behave as before.
--
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]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]