gabotorresruiz commented on code in PR #44082:
URL: https://github.com/apache/superset/pull/44082#discussion_r4094594136


##########
superset/dashboards/api.py:
##########
@@ -1886,64 +1898,204 @@ def export_xlsx(self, pk: int) -> WerkzeugResponse:
             return self.response_403()
 
         # A requester with no email on file (e.g. an embedded/guest session)
-        # still gets a usable export: they poll export_xlsx_status/<job_id>/
-        # for the download link instead of relying on an email notification.
+        # still gets a usable export: a queued export is polled at
+        # export_xlsx_status/<job_id>/, and a direct download needs no email.
         if not dashboard.slices:
-            return self.response_400(message="Dashboard has no charts to 
export.")
+            return self.response_400(
+                message=gettext("Dashboard has no charts to export.")
+            )
 
-        # Throttle: one concurrent export per user+dashboard. Acquire a shared,
-        # atomic distributed lock (Redis when configured, the metadata DB
-        # otherwise) so the guard works across the web server and workers and 
is
-        # not a no-op under the default cache. The task releases it when it
-        # settles; the TTL is the backstop if that release is ever lost.
+        active_data_mask = payload.get("active_data_mask", {})
+        mode = payload.get("mode", "data")
+
+        if not queued and mode == EXPORT_MODE_IMAGES:
+            # Webdriver rendering is too slow and unbounded for a web request.
+            return self.response_400(
+                message=gettext(
+                    "Exporting images to Excel requires background exports. "
+                    "Ask an administrator to enable them, or export the "
+                    "dashboard's data instead."
+                )
+            )
+
+        # Allow one export per user and dashboard across web and worker 
processes.
+        # The TTL releases the lock if normal cleanup fails.
         # A guest/embedded requester has no DB-backed user id (GuestUser 
carries
-        # no ``id`` attribute at all), so all guests share lock slot 0 for the
-        # dashboard; the task reconstructs the guest (with the token's RLS 
rules
-        # and resource claims) from the token payload passed alongside.
+        # no ``id`` attribute at all), so guests get a stable slot derived from
+        # their token; the task reconstructs the guest (with the token's RLS
+        # rules and resource claims) from the token payload passed alongside.
         user_id = get_user_id()
         guest_token_payload = (
             getattr(g.user, "guest_token", None) if user_id is None else None
         )
         lock_params = export_lock_params(
             user_id or guest_lock_slot(guest_token_payload), dashboard.id
         )
-        acquire = AcquireDistributedLock(
+        acquire_lock = AcquireDistributedLock(
             EXPORT_LOCK_NAMESPACE,
             lock_params,
             ttl_seconds=EXPORT_LOCK_TTL_SECONDS,
         )
         try:
-            acquire.run()
+            acquire_lock.run()
         except LockAlreadyHeldException:
             return self.response(
                 202,
                 message="An Excel export for this dashboard is already in 
progress.",
             )
+        # Every release is checked against this acquisition's token, so an 
export
+        # that outlives the TTL cannot delete the lock of whoever acquired 
next.
+        lock_token = acquire_lock.token
 
         job_id = str(uuid.uuid4())
+        if queued:
+            return self._export_xlsx_queued(
+                dashboard,
+                user_id,
+                guest_token_payload,
+                active_data_mask,
+                mode,
+                job_id,
+                lock_params,
+                lock_token,
+            )
+
+        # 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=gettext(
+                        "This dashboard has too much data to download 
directly. "
+                        "Ask an administrator to enable background exports, or 
"
+                        "lower the row limits of its charts."
+                    )
+                )
+            lock_delegated = True
+            return self._export_xlsx_inline(
+                dashboard,
+                active_data_mask,
+                job_id,
+                lock_params,
+                lock_token,
+                plan,
+            )
+        finally:
+            if not lock_delegated:
+                try:
+                    ReleaseDistributedLock(
+                        EXPORT_LOCK_NAMESPACE, lock_params, token=lock_token
+                    ).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,
+        user_id: int | None,
+        guest_token: GuestToken | None,
+        active_data_mask: dict[str, Any],
+        mode: str,
+        job_id: str,
+        lock_params: dict[str, int],
+        lock_token: str,
+    ) -> WerkzeugResponse:
+        """Queue an export for upload and delivery by email or status 
polling."""
         try:
             export_dashboard_excel.apply_async(
                 kwargs={
                     "dashboard_id": dashboard.id,
                     "user_id": 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"),
-                    "guest_token": guest_token_payload,
-                    "lock_token": acquire.token,
+                    "mode": mode,
+                    "guest_token": guest_token,
+                    "lock_token": lock_token,
                 },
                 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, token=acquire.token
+                EXPORT_LOCK_NAMESPACE, lock_params, token=lock_token
             ).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],
+        lock_token: str,
+        plan: InlineExportPlan,
+    ) -> 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=plan.query_contexts,
+                skipped_charts=plan.skipped,
+            )
+            # A dashboard may be untitled; fall back the same way the task 
does.
+            filename = get_filename(
+                dashboard.dashboard_title or f"Dashboard {dashboard.id}",
+                dashboard.id,
+                skip_id=False,
+            )
+            response = send_file(

Review Comment:
   Just a small NIT, not a blocker. This response ships with `send_file`'s 
defaults, which I measured as `Cache-Control: no-cache, max-age=0` plus an 
`ETag`, while the streamed download at `export_xlsx/download/<uuid>/` goes 
through `_never_cache` (`no-store`, `private`, `Pragma: no-cache`). Same 
data-bearing file, two policies. It is a POST response so browsers will not 
cache it regardless, so this is symmetry rather than a bug, but 
`after_this_request(_never_cache)` before returning here would keep the two 
aligned, and `test_export_xlsx_200_streams_workbook_without_storage` could pin 
`"no-store" in rv.headers["Cache-Control"]`.
   



##########
superset/dashboards/excel_export/storage.py:
##########
@@ -0,0 +1,33 @@
+# 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.
+"""Check whether dashboard Excel export storage is configured."""
+
+from __future__ import annotations
+
+from flask import current_app
+
+
+def is_export_storage_configured() -> bool:
+    """Return whether exports can be uploaded and shared by link.
+
+    Both a bucket and a backend are required; the task cannot upload without
+    either, so a partial ``EXPORT_STORAGE`` falls back to direct downloads.
+    """
+    storage_config = current_app.config["EXPORT_STORAGE"]
+    return bool(storage_config.get("bucket")) and (

Review Comment:
   Not a blocker, cheap insurance. A bucket without a `backend` (or the 
reverse) now quietly selects the direct download, where master returned a loud 
`501`. `test_export_xlsx_bucket_without_backend_downloads_directly` pins that 
and UPDATING documents it, so I am fine with the choice; but the upgrade 
scenario UPDATING describes (bucket ported, `backend` forgotten) would only 
surface as image exports disappearing and no links arriving. A `logger.warning` 
when exactly one of the two keys is set would make that one line in the logs 
instead of a support ticket, with a caplog assertion in 
`test_excel_export_storage.py`.
   



##########
superset/dashboards/api.py:
##########
@@ -1886,64 +1898,204 @@ def export_xlsx(self, pk: int) -> WerkzeugResponse:
             return self.response_403()
 
         # A requester with no email on file (e.g. an embedded/guest session)
-        # still gets a usable export: they poll export_xlsx_status/<job_id>/
-        # for the download link instead of relying on an email notification.
+        # still gets a usable export: a queued export is polled at
+        # export_xlsx_status/<job_id>/, and a direct download needs no email.
         if not dashboard.slices:
-            return self.response_400(message="Dashboard has no charts to 
export.")
+            return self.response_400(
+                message=gettext("Dashboard has no charts to export.")
+            )
 
-        # Throttle: one concurrent export per user+dashboard. Acquire a shared,
-        # atomic distributed lock (Redis when configured, the metadata DB
-        # otherwise) so the guard works across the web server and workers and 
is
-        # not a no-op under the default cache. The task releases it when it
-        # settles; the TTL is the backstop if that release is ever lost.
+        active_data_mask = payload.get("active_data_mask", {})
+        mode = payload.get("mode", "data")
+
+        if not queued and mode == EXPORT_MODE_IMAGES:
+            # Webdriver rendering is too slow and unbounded for a web request.
+            return self.response_400(
+                message=gettext(
+                    "Exporting images to Excel requires background exports. "
+                    "Ask an administrator to enable them, or export the "
+                    "dashboard's data instead."
+                )
+            )
+
+        # Allow one export per user and dashboard across web and worker 
processes.
+        # The TTL releases the lock if normal cleanup fails.
         # A guest/embedded requester has no DB-backed user id (GuestUser 
carries
-        # no ``id`` attribute at all), so all guests share lock slot 0 for the
-        # dashboard; the task reconstructs the guest (with the token's RLS 
rules
-        # and resource claims) from the token payload passed alongside.
+        # no ``id`` attribute at all), so guests get a stable slot derived from
+        # their token; the task reconstructs the guest (with the token's RLS
+        # rules and resource claims) from the token payload passed alongside.
         user_id = get_user_id()
         guest_token_payload = (
             getattr(g.user, "guest_token", None) if user_id is None else None
         )
         lock_params = export_lock_params(
             user_id or guest_lock_slot(guest_token_payload), dashboard.id
         )
-        acquire = AcquireDistributedLock(
+        acquire_lock = AcquireDistributedLock(
             EXPORT_LOCK_NAMESPACE,
             lock_params,
             ttl_seconds=EXPORT_LOCK_TTL_SECONDS,
         )
         try:
-            acquire.run()
+            acquire_lock.run()
         except LockAlreadyHeldException:
             return self.response(
                 202,
                 message="An Excel export for this dashboard is already in 
progress.",
             )
+        # Every release is checked against this acquisition's token, so an 
export
+        # that outlives the TTL cannot delete the lock of whoever acquired 
next.
+        lock_token = acquire_lock.token
 
         job_id = str(uuid.uuid4())
+        if queued:
+            return self._export_xlsx_queued(
+                dashboard,
+                user_id,
+                guest_token_payload,
+                active_data_mask,
+                mode,
+                job_id,
+                lock_params,
+                lock_token,
+            )
+
+        # 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:

Review Comment:
   Question, not a blocker. When every chart ends up in `plan.skipped` (a 
dashboard of pivot tables with AVG metrics all go to `ERROR_UNBOUNDED`), 
`requested_rows` stays `0`, this check passes, `build_workbook` writes only the 
"No chart data could be exported." summary sheet, and the UI shows "Dashboard 
data exported to Excel". The queued path behaves the same way with its 
summary-only workbook, so this may be intentional. Would a `400` with the 
unbounded message be friendlier when `plan.query_contexts` has no runnable 
chart, or am I misunderstanding the intent here?
   



-- 
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]

Reply via email to