-
Notifications
You must be signed in to change notification settings - Fork 18k
fix(reports): positive readiness check for non-tiled screenshots #42253
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
225625b
674464e
62408be
ddd9ad0
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -22,13 +22,71 @@ | |
| import time | ||
| from typing import TYPE_CHECKING | ||
|
|
||
| from celery import current_task | ||
| from PIL import Image | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
| # Time to wait after scrolling for content to settle and load (in milliseconds) | ||
| SCROLL_SETTLE_TIMEOUT_MS = 1000 | ||
|
|
||
| # Runtime task-budget policy shared with the approach introduced in #42118. | ||
| # Celery exposes the effective per-task hard/soft limits only on the running | ||
| # task, so a static Superset timeout cannot reliably stay below task-level | ||
| # overrides. Reserve at most 20% (capped at five minutes) for browser cleanup, | ||
| # cache error transition, and the remaining report pipeline. | ||
| SCREENSHOT_TASK_BUDGET_MARGIN_FRACTION = 0.2 | ||
| SCREENSHOT_TASK_BUDGET_MAX_MARGIN_SECONDS = 300 | ||
|
|
||
|
|
||
| def resolve_screenshot_task_budget_seconds( | ||
| log_context: str | None = None, | ||
| ) -> float | None: | ||
| """ | ||
| Return the safe screenshot budget derived from the active Celery task. | ||
|
|
||
| Celery exposes ``request.timelimit`` as ``(hard, soft)``. Prefer the soft | ||
| limit because cleanup must finish before Celery raises it, falling back to | ||
| the hard limit when no soft limit is configured. Outside Celery, or when | ||
| the metadata is absent or malformed, return ``None`` so callers preserve | ||
| their configured standalone timeout. | ||
| """ | ||
| context_suffix = f" [{log_context}]" if log_context else "" | ||
| try: | ||
| if not current_task: | ||
| return None | ||
| timelimit = current_task.request.timelimit | ||
| if not isinstance(timelimit, (tuple, list)) or len(timelimit) != 2: | ||
| return None | ||
| hard_limit, soft_limit = timelimit | ||
| limit = soft_limit or hard_limit | ||
| if isinstance(limit, bool) or not isinstance(limit, (int, float)) or limit <= 0: | ||
| return None | ||
| margin = min( | ||
| SCREENSHOT_TASK_BUDGET_MAX_MARGIN_SECONDS, | ||
| limit * SCREENSHOT_TASK_BUDGET_MARGIN_FRACTION, | ||
| ) | ||
| budget = max(0.0, float(limit) - margin) | ||
| logger.debug( | ||
| "Screenshot budget derived from Celery task %s=%.1fs: %.1fs " | ||
| "(cleanup margin=%.1fs)%s", | ||
| "soft_time_limit" if soft_limit else "time_limit", | ||
| limit, | ||
| budget, | ||
| margin, | ||
| context_suffix, | ||
| ) | ||
| return budget | ||
| except Exception: | ||
| logger.debug( | ||
| "Failed to derive screenshot budget from Celery task context; " | ||
| "using the configured screenshot timeout%s", | ||
| context_suffix, | ||
| exc_info=True, | ||
| ) | ||
| return None | ||
|
|
||
|
|
||
| try: | ||
| from playwright.sync_api import TimeoutError as PlaywrightTimeout | ||
| except ImportError: | ||
|
|
@@ -40,79 +98,113 @@ | |
| except ImportError: | ||
| Page = None | ||
|
|
||
| # Selectors used to build a positive per-tile readiness check. A chart holder | ||
| # is only "ready" once it shows a terminal state (a rendered chart or an | ||
| # error/empty state) -- the mere absence of a `.loading` element is not | ||
| # sufficient, since a chart holder that intersects the viewport but hasn't | ||
| # mounted anything yet (e.g. its IntersectionObserver callback hasn't fired) | ||
| # would otherwise pass vacuously. | ||
| # See superset-frontend/src/dashboard/components/gridComponents/ChartHolder/ | ||
| # ChartHolder.tsx for `data-test="dashboard-component-chart-holder"`, | ||
| # superset-frontend/src/components/Chart/Chart.tsx for `.slice_container` | ||
| # (rendered chart container, `data-test="slice-container"`) and `.loading` | ||
| # (spinner, via the shared Loading component), and | ||
| # superset-frontend/packages/superset-ui-core/src/components/EmptyState for | ||
| # `.ant-empty` (e.g. "no results"/"add required control values" states). | ||
| # | ||
| # For diagnostics, each unready holder is additionally classified by *why* | ||
| # it isn't ready, distinguishing a slow query from the virtualization race: | ||
| # - "waiting_on_database": `.loading` present with no `.slice_container` | ||
| # -- Chart.tsx's `renderSpinner()` replaces the whole container while | ||
| # the initial query is in flight (`chartStatus === 'loading'`). | ||
| # - "spinner_mounted": `.loading` present *inside* `.slice_container` | ||
| # -- the chart's query finished, but it isn't in the virtualization | ||
| # viewport yet, so `renderChartContainer()` shows a bare spinner instead | ||
| # of the chart. | ||
| # - "nothing_mounted": neither `.loading` nor any ready marker present -- | ||
| # the vacuous-pass race this check exists to close. | ||
| _UNREADY_CHART_HOLDERS_JS_BODY = """ | ||
| const holders = document.querySelectorAll( | ||
| '[data-test="dashboard-component-chart-holder"]' | ||
| ); | ||
| # Production dashboard builds run ``babel-plugin-jsx-remove-data-test-id`` | ||
| # under the production BABEL_ENV (including Docker builds), so readiness must | ||
| # never depend on ``data-test`` attributes. These runtime classes are the | ||
| # production contract shared by readiness polling and diagnostics. | ||
| CHART_HOLDER_SELECTOR = ( | ||
| r'.dashboard-component-chart-holder[class*="dashboard-chart-id-"]' | ||
| ) | ||
| SLICE_CONTAINER_SELECTOR = r".slice_container" | ||
| LOADING_SELECTOR = r".loading" | ||
| ALERT_SELECTOR = r'[role="alert"]' | ||
| EMPTY_SELECTOR = r".ant-empty" | ||
| MISSING_CHART_SELECTOR = r".missing-chart-container" | ||
| TERMINAL_MARKER_SELECTOR = ( | ||
| f"{SLICE_CONTAINER_SELECTOR}, {ALERT_SELECTOR}, {EMPTY_SELECTOR}, " | ||
| f"{MISSING_CHART_SELECTOR}" | ||
| ) | ||
| CHART_ID_CLASS_PATTERN = r"\bdashboard-chart-id-(\d+)\b" | ||
|
|
||
| # Shared body for holder readiness and timeout diagnostics. A holder is ready | ||
| # only after a terminal marker appears and its loading marker disappears. | ||
| UNREADY_CHART_HOLDERS_JS_BODY = f""" | ||
| const holders = document.querySelectorAll('{CHART_HOLDER_SELECTOR}'); | ||
| const unready = []; | ||
| for (const holder of holders) { | ||
| for (const holder of holders) {{ | ||
| const r = holder.getBoundingClientRect(); | ||
| if (!(r.top < window.innerHeight && r.bottom > 0)) { | ||
| if (!(r.top < window.innerHeight && r.bottom > 0)) {{ | ||
| continue; | ||
| } | ||
| }} | ||
| const hasSliceContainer = holder.querySelector( | ||
| '[data-test="slice-container"]' | ||
| '{SLICE_CONTAINER_SELECTOR}' | ||
| ) !== null; | ||
| const stillLoading = holder.querySelector('.loading') !== null; | ||
| const isReady = hasSliceContainer || holder.querySelector( | ||
| '[role="alert"], .ant-empty, .missing-chart-container' | ||
| ) !== null; | ||
| if (stillLoading || !isReady) { | ||
| const chartIdEl = holder.querySelector('[data-test-chart-id]'); | ||
| const stillLoading = holder.querySelector('{LOADING_SELECTOR}') !== null; | ||
| const isReady = holder.querySelector('{TERMINAL_MARKER_SELECTOR}') !== null; | ||
| if (stillLoading || !isReady) {{ | ||
| const chartIdMatch = holder.className.match(/{CHART_ID_CLASS_PATTERN}/); | ||
| const chartId = chartIdMatch ? chartIdMatch[1] : null; | ||
| let state; | ||
| if (stillLoading && hasSliceContainer) { | ||
| if (stillLoading && hasSliceContainer) {{ | ||
| state = 'spinner_mounted'; | ||
| } else if (stillLoading) { | ||
| }} else if (stillLoading) {{ | ||
| state = 'waiting_on_database'; | ||
| } else { | ||
| }} else {{ | ||
| state = 'nothing_mounted'; | ||
| } | ||
| unready.push({ | ||
| chartId: chartIdEl | ||
| ? chartIdEl.getAttribute('data-test-chart-id') | ||
| : 'unknown', | ||
| }} | ||
| unready.push({{ | ||
| chartId: chartId, | ||
| state: state, | ||
| }); | ||
| } | ||
| } | ||
| }}); | ||
| }} | ||
| }} | ||
| """ | ||
|
|
||
| # Predicate for page.wait_for_function: true once every viewport-visible chart | ||
| # holder has reached a terminal state. | ||
| _TILE_READY_CHECK_JS = ( | ||
| f"() => {{ {_UNREADY_CHART_HOLDERS_JS_BODY} return unready.length === 0; }}" | ||
| ) | ||
| # Diagnostic query for every chart holder, including terminal and virtualized | ||
| # states. It interpolates the same selector constants as the predicates. | ||
| FIND_CHART_HOLDER_STATES_JS = f""" | ||
| () => {{ | ||
| const holders = document.querySelectorAll('{CHART_HOLDER_SELECTOR}'); | ||
| return Array.from(holders).map(holder => {{ | ||
| const chartIdMatch = holder.className.match(/{CHART_ID_CLASS_PATTERN}/); | ||
| const chartId = chartIdMatch ? chartIdMatch[1] : null; | ||
| const r = holder.getBoundingClientRect(); | ||
| if (!(r.top < window.innerHeight && r.bottom > 0)) {{ | ||
| return {{ chartId, state: 'virtualized' }}; | ||
| }} | ||
| const hasSliceContainer = holder.querySelector( | ||
| '{SLICE_CONTAINER_SELECTOR}' | ||
| ) !== null; | ||
| const stillLoading = holder.querySelector('{LOADING_SELECTOR}') !== null; | ||
| if (stillLoading && hasSliceContainer) {{ | ||
| return {{ chartId, state: 'spinner_mounted' }}; | ||
| }} | ||
| if (stillLoading) {{ | ||
| return {{ chartId, state: 'waiting_on_database' }}; | ||
| }} | ||
| if (holder.querySelector('{ALERT_SELECTOR}') !== null) {{ | ||
| return {{ chartId, state: 'error' }}; | ||
| }} | ||
| if (holder.querySelector( | ||
| '{EMPTY_SELECTOR}, {MISSING_CHART_SELECTOR}' | ||
| ) !== null) {{ | ||
| return {{ chartId, state: 'empty' }}; | ||
| }} | ||
| if (hasSliceContainer) {{ | ||
| return {{ chartId, state: 'rendered' }}; | ||
| }} | ||
| return {{ chartId, state: 'nothing_mounted' }}; | ||
| }}); | ||
| }} | ||
| """ | ||
|
|
||
| # Diagnostic query for page.evaluate: chart id + state of holders still not | ||
| # ready, used to build the timeout log message. | ||
| _FIND_UNREADY_CHART_HOLDERS_JS = ( | ||
| f"() => {{ {_UNREADY_CHART_HOLDERS_JS_BODY} return unready; }}" | ||
| CHART_HOLDERS_READY_JS = ( | ||
| f"() => {{ {UNREADY_CHART_HOLDERS_JS_BODY} return unready.length === 0; }}" | ||
| ) | ||
|
Comment on lines
+191
to
193
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Suggestion: The readiness predicate still has a vacuous-pass path: it returns ready when zero chart holders are found. During initial React/bootstrap timing, the DOM can temporarily contain no holders, so the wait can complete immediately and still allow a blank/partial screenshot. Require at least one holder (or another explicit dashboard-ready signal) before returning ready. [logic error] Severity Level: Critical 🚨- ❌ Reports may show blank dashboards despite successful screenshot.
- ⚠️ Screenshot timeouts won't fire when no charts mounted.Steps of Reproduction ✅1. Trigger any flow that uses `WebDriverPlaywright.get_screenshot()` at
`superset/utils/webdriver.py:338` with a dashboard URL (e.g. report generation or manual
invocation in tests).
2. In the non-tiled branch of `get_screenshot()` at `superset/utils/webdriver.py:493-547`,
`_wait_for_charts_ready()` at `superset/utils/webdriver.py:283-337` is called before
taking the standard screenshot.
3. `_wait_for_charts_ready()` calls `page.wait_for_function(CHART_HOLDERS_READY_JS,
timeout=load_wait * 1000)` at `superset/utils/webdriver.py:318-321`.
`CHART_HOLDERS_READY_JS` is defined at `superset/utils/screenshot_utils.py:119-121` to
execute `UNREADY_CHART_HOLDERS_JS_BODY` and then `return unready.length === 0`.
4. During the gap between page load and React dashboard bootstrap, the DOM can
legitimately contain no `[data-test="dashboard-component-chart-holder"]` elements (as
described in comments at `superset/utils/screenshot_utils.py:51-57`). In that state,
`document.querySelectorAll(...)` in `UNREADY_CHART_HOLDERS_JS_BODY` at
`superset/utils/screenshot_utils.py:81-83` returns an empty NodeList, the loop never
pushes into `unready`, `unready.length` remains `0`, and `CHART_HOLDERS_READY_JS`
evaluates to `true`. `page.wait_for_function` therefore returns immediately and
`get_screenshot()` proceeds to capture a blank or partially-loaded dashboard without ever
timing out or logging a warning.(Use Cmd/Ctrl + Click for best experience) Prompt for AI Agent 🤖This is a comment left during a code review.
**Path:** superset/utils/screenshot_utils.py
**Line:** 119:121
**Comment:**
*Logic Error: The readiness predicate still has a vacuous-pass path: it returns ready when zero chart holders are found. During initial React/bootstrap timing, the DOM can temporarily contain no holders, so the wait can complete immediately and still allow a blank/partial screenshot. Require at least one holder (or another explicit dashboard-ready signal) before returning ready.
Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix |
||
| FIND_UNREADY_CHART_HOLDERS_JS = ( | ||
| f"() => {{ {UNREADY_CHART_HOLDERS_JS_BODY} return unready; }}" | ||
| ) | ||
|
|
||
| # A chart capture has one target rather than dashboard holders, but needs the | ||
| # same positive terminal-state guarantee and loading exclusion. | ||
| CHART_CONTAINER_READY_JS = f""" | ||
| () => {{ | ||
| const chart = document.querySelector('.chart-container'); | ||
| return chart !== null | ||
| && chart.querySelector('{LOADING_SELECTOR}') === null | ||
| && chart.querySelector('{TERMINAL_MARKER_SELECTOR}') !== null; | ||
| }} | ||
| """ | ||
|
|
||
|
|
||
| def combine_screenshot_tiles(screenshot_tiles: list[bytes]) -> bytes: | ||
|
|
@@ -249,12 +341,12 @@ def take_tiled_screenshot( | |
| tile_wait_start = time.monotonic() | ||
| try: | ||
| page.wait_for_function( | ||
| _TILE_READY_CHECK_JS, | ||
| CHART_HOLDERS_READY_JS, | ||
| timeout=load_wait * 1000, | ||
|
Comment on lines
343
to
345
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Suggestion: The tiled path continues to use the full configured Severity Level: Critical 🚨- ❌ Tiled dashboard reports can time out before completion.
- ⚠️ Browser resources may not be cleaned up before termination.
- ⚠️ Cache error transitions may not execute reliably.Steps of Reproduction ✅1. Trigger a dashboard screenshot through `WebDriverPlaywright.get_screenshot()` at
`superset/utils/webdriver.py:409` for a dashboard that exceeds the tiling threshold and
therefore calls `take_tiled_screenshot()` at `superset/utils/screenshot_utils.py:275`.
2. Use a Celery report task with a soft or hard time limit shorter than the configured
screenshot wait; the active task metadata is available through
`current_task.request.timelimit` and is otherwise handled by
`resolve_screenshot_task_budget_seconds()` at `superset/utils/screenshot_utils.py:42`.
3. For each tile, `take_tiled_screenshot()` at
`superset/utils/screenshot_utils.py:354-365` invokes `page.wait_for_function()` with the
full `load_wait` value and does not call the task-budget resolver.
4. If multiple tiles each wait for slow chart readiness, the cumulative waits can reach
the Celery deadline before `take_tiled_screenshot()` combines tiles or the report caller
performs cleanup, allowing Celery to terminate the task.(Use Cmd/Ctrl + Click for best experience) Prompt for AI Agent 🤖This is a comment left during a code review.
**Path:** superset/utils/screenshot_utils.py
**Line:** 364:366
**Comment:**
*Possible Bug: The tiled path continues to use the full configured `load_wait` without deriving a remaining budget from the active Celery task. A task with a shorter soft or hard limit can therefore spend the entire static timeout waiting for each tile and be terminated before screenshot cleanup and error handling complete, unlike the non-tiled path.
Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix |
||
| ) | ||
| except PlaywrightTimeout: | ||
| elapsed = time.monotonic() - tile_wait_start | ||
| unready_chart_holders = page.evaluate(_FIND_UNREADY_CHART_HOLDERS_JS) | ||
| unready_chart_holders = page.evaluate(FIND_UNREADY_CHART_HOLDERS_JS) | ||
| # A chart failing to load in time is a customer chart-loading | ||
| # issue (slow query, error state, etc.), not a Superset system | ||
| # fault, so this stays at WARNING -- the report still fails | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Suggestion: Celery provides
request.timelimitin soft-limit, hard-limit order, but this unpacking treats the first value as the hard limit and the second as the soft limit. When both limits are configured, the code therefore derives the budget from the hard limit and may continue past the soft limit, allowing Celery to interrupt the screenshot before cleanup completes. [logic error]Severity Level: Major⚠️
Steps of Reproduction ✅
(Use Cmd/Ctrl + Click for best experience)
Prompt for AI Agent 🤖