sadpandajoe commented on code in PR #44082:
URL: https://github.com/apache/superset/pull/44082#discussion_r4017947154
##########
superset/dashboards/api.py:
##########
@@ -1825,25 +1848,131 @@ def export_xlsx(self, pk: int) -> WerkzeugResponse:
)
job_id = str(uuid.uuid4())
+ if queued:
+ return self._export_xlsx_queued(
+ dashboard, active_data_mask, mode, job_id, lock_params
+ )
+
+ # Plan after locking because query-context resolution can be expensive.
+ # Release here unless the inline exporter takes over cleanup.
+ lock_delegated = False
+ try:
+ plan: InlineExportPlan = plan_inline_export(dashboard)
+ if not plan.fits_row_budget:
+ return self.response_400(
+ message=(
+ "This dashboard requests too many rows to export in a "
+ "single request. Configure EXCEL_EXPORT_S3_BUCKET to "
+ "export it in the background, or lower the row limits
of "
+ "its charts."
+ )
+ )
+ lock_delegated = True
+ return self._export_xlsx_inline(
+ dashboard,
+ active_data_mask,
+ job_id,
+ lock_params,
+ plan.query_contexts,
+ )
+ finally:
+ if not lock_delegated:
+ try:
+ ReleaseDistributedLock(EXPORT_LOCK_NAMESPACE,
lock_params).run()
+ except Exception: # pylint: disable=broad-except
+ # The TTL is the fallback if release fails.
+ logger.exception(
+ "Failed to release in-flight export lock for dashboard
%s",
+ dashboard.id,
+ )
+
+ def _export_xlsx_queued( # pylint: disable=too-many-arguments
+ self,
+ dashboard: Dashboard,
+ active_data_mask: dict[str, Any],
+ mode: str,
+ job_id: str,
+ lock_params: dict[str, int],
+ ) -> WerkzeugResponse:
+ """Queue an export for upload and email delivery."""
try:
export_dashboard_excel.apply_async(
kwargs={
"dashboard_id": dashboard.id,
"user_id": g.user.id,
- "active_data_mask": payload.get("active_data_mask", {}),
+ "active_data_mask": active_data_mask,
"job_id": job_id,
- "mode": payload.get("mode", "data"),
+ "mode": mode,
},
task_id=job_id,
)
except Exception:
- # If enqueuing fails (e.g. broker down) the task will never run to
- # release the lock, so free it now rather than block exports until
- # the TTL expires.
+ # No task will release the lock if enqueueing fails.
ReleaseDistributedLock(EXPORT_LOCK_NAMESPACE, lock_params).run()
raise
return self.response(202, job_id=job_id)
+ @staticmethod
+ def _export_xlsx_inline( # pylint: disable=too-many-arguments
+ dashboard: Dashboard,
+ active_data_mask: dict[str, Any],
+ job_id: str,
+ lock_params: dict[str, int],
+ query_contexts: ResolvedQueryContexts,
+ ) -> WerkzeugResponse:
+ """Build a planned data export and return it in the response."""
+ tmp_path: str | None = None
+ try:
+ file_descriptor, tmp_path = tempfile.mkstemp(
+ suffix=".xlsx", prefix=f"dash-export-{job_id}-"
+ )
+ os.close(file_descriptor)
+
+ build_workbook(
+ tmp_path,
+ dashboard,
+ active_data_mask,
+ job_id,
+ EXPORT_MODE_DATA,
+ g.user,
+ query_contexts=query_contexts,
+ )
+ filename = get_filename(
+ dashboard.dashboard_title, dashboard.id, skip_id=False
Review Comment:
Dashboards are allowed to have a null `dashboard_title`, but this passes
`None` to `get_filename`, whose sanitizer expects a string, so an otherwise
valid direct export builds the workbook and then returns 500. Could this use
the queued path's `Dashboard {id}` fallback before constructing the filename?
##########
superset/dashboards/excel_export/sync_budget.py:
##########
@@ -0,0 +1,107 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Plan and size dashboard Excel exports served in the HTTP response."""
+
+from __future__ import annotations
+
+import logging
+from dataclasses import dataclass
+from typing import Any
+
+from celery.exceptions import SoftTimeLimitExceeded
+from flask import current_app
+
+from superset.dashboards.excel_export.layout import get_charts_in_layout_order
+from superset.dashboards.excel_export.workbook import (
+ resolve_query_context,
+ ResolvedQueryContexts,
+)
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass(frozen=True)
+class InlineExportPlan:
+ """Queries planned for a direct download and their row budget."""
+
+ #: Resolved query contexts by chart id. ``None`` marks a skipped chart.
+ query_contexts: ResolvedQueryContexts
+ #: Combined row limit, or ``None`` when any query has no finite limit.
+ requested_rows: int | None
+ #: Configured limit for direct downloads.
+ max_rows: int
+
+ @property
+ def fits_row_budget(self) -> bool:
+ """Return whether the export can run during the request."""
+ return self.requested_rows is not None and self.requested_rows <=
self.max_rows
+
+
+def _finite_row_limit(query: Any) -> int | None:
+ """Return a safe upper bound for one query's result rows."""
+ if not isinstance(query, dict):
+ return None
+ # Grouping sets do not apply row_limit and may fan out into several
queries.
+ if query.get("grouping_sets"):
+ return None
+ columns = query.get("columns")
+ metrics = query.get("metrics")
+ if (
+ columns == []
+ and isinstance(metrics, list)
+ and metrics
+ and not query.get("is_timeseries")
+ ):
+ # A metric query with no grouping columns returns one aggregate row.
+ return 1
+ row_limit = query.get("row_limit") or current_app.config["ROW_LIMIT"]
+ if isinstance(row_limit, bool) or not isinstance(row_limit, int):
Review Comment:
A builder payload with `"row_limit": "1000"` is accepted and coerced by
`ChartDataQueryContextSchema` in the queued/execution path, but this planner
classifies it as unbounded and returns 400 for the entire direct export. Could
the planner normalize through the same schema, or otherwise mirror its accepted
integer forms, before enforcing the budget?
--
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]